@app-vitals/agent-admin 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile +43 -0
- package/README.md +72 -2
- package/e2e/agents-page.e2e.ts +338 -0
- package/e2e/chat-degraded.e2e.ts +153 -0
- package/e2e/chat-page.e2e.ts +169 -0
- package/e2e/chat-progress.e2e.ts +206 -0
- package/e2e/chat-thread-page.e2e.ts +444 -0
- package/e2e/login-page.e2e.ts +148 -0
- package/e2e/test-server.ts +427 -0
- package/openapi.json +6098 -0
- package/openapi.platform.json +5553 -0
- package/package.json +64 -4
- package/playwright.config.ts +25 -0
- package/prisma/client/browser.ts +134 -0
- package/prisma/client/client.ts +158 -0
- package/prisma/client/commonInputTypes.ts +578 -0
- package/prisma/client/enums.ts +15 -0
- package/prisma/client/internal/class.ts +424 -0
- package/prisma/client/internal/prismaNamespace.ts +2898 -0
- package/prisma/client/internal/prismaNamespaceBrowser.ts +476 -0
- package/prisma/client/models/Account.ts +1774 -0
- package/prisma/client/models/AccountInvite.ts +1386 -0
- package/prisma/client/models/AccountMember.ts +1351 -0
- package/prisma/client/models/Agent.ts +4554 -0
- package/prisma/client/models/AgentAsk.ts +1373 -0
- package/prisma/client/models/AgentChatTokenUsageDailyByModel.ts +1638 -0
- package/prisma/client/models/AgentCronJob.ts +2416 -0
- package/prisma/client/models/AgentCronRun.ts +2858 -0
- package/prisma/client/models/AgentCronRunModelBreakdown.ts +1532 -0
- package/prisma/client/models/AgentCronRunSkillUsage.ts +1642 -0
- package/prisma/client/models/AgentEnv.ts +1421 -0
- package/prisma/client/models/AgentGitHubInstallationsSnapshot.ts +1289 -0
- package/prisma/client/models/AgentMember.ts +1316 -0
- package/prisma/client/models/AgentPhaseMethodology.ts +1355 -0
- package/prisma/client/models/AgentPlugin.ts +1421 -0
- package/prisma/client/models/AgentToken.ts +1380 -0
- package/prisma/client/models/AgentTool.ts +1351 -0
- package/prisma/client/models/AgentWorkQueueSnapshot.ts +1297 -0
- package/prisma/client/models/ChatThreadWatch.ts +1183 -0
- package/prisma/client/models/PushSubscription.ts +1233 -0
- package/prisma/client/models/SessionAlertState.ts +1187 -0
- package/prisma/client/models/SessionFollow.ts +1187 -0
- package/prisma/client/models/UserNotificationPrefs.ts +1221 -0
- package/prisma/client/models.ts +34 -0
- package/prisma/migrations/20260608000000_init/migration.sql +112 -0
- package/prisma/migrations/20260616000000_agent_members/migration.sql +18 -0
- package/prisma/migrations/20260624000000_add_agent_self_hosted/migration.sql +2 -0
- package/prisma/migrations/20260624120000_add_agent_repos/migration.sql +3 -0
- package/prisma/migrations/20260625000000_add_agent_cron_run/migration.sql +26 -0
- package/prisma/migrations/20260626000000_add_cron_run_token_fields/migration.sql +10 -0
- package/prisma/migrations/20260627000000_add_agent_chat_token_usage_daily/migration.sql +18 -0
- package/prisma/migrations/20260627010000_add_agent_chat_token_usage_fk/migration.sql +2 -0
- package/prisma/migrations/20260701000000_add_chat_threads_messages/migration.sql +37 -0
- package/prisma/migrations/20260701010000_drop_chat_threads_messages/migration.sql +17 -0
- package/prisma/migrations/20260701020000_add_cron_run_model_breakdown/migration.sql +23 -0
- package/prisma/migrations/20260701020000_chat_token_by_model/migration.sql +25 -0
- package/prisma/migrations/20260701030000_add_secret_flag_to_agent_env/migration.sql +2 -0
- package/prisma/migrations/20260701030000_drop_cron_run_legacy_columns/migration.sql +5 -0
- package/prisma/migrations/20260706000000_consolidate_cron_run_token_accounting/migration.sql +54 -0
- package/prisma/migrations/20260709000000_add_phase_to_agent_cron_run/migration.sql +8 -0
- package/prisma/migrations/20260717000000_add_item_tracking_to_agent_cron_run/migration.sql +10 -0
- package/prisma/migrations/20260717010000_add_agent_work_queue_snapshot/migration.sql +20 -0
- package/prisma/migrations/20260718000000_add_cron_phase_columns/migration.sql +25 -0
- package/prisma/migrations/20260718100000_drop_agent_cron_run_phase_column/migration.sql +8 -0
- package/prisma/migrations/20260724000000_add_agent_type_name/migration.sql +3 -0
- package/prisma/migrations/20260725232433_add_agent_author_allowlist/migration.sql +2 -0
- package/prisma/migrations/20260727120000_add_not_null_to_agent_author_allowlist/migration.sql +3 -0
- package/prisma/migrations/20260731000000_add_session_id_to_agent_cron_run/migration.sql +2 -0
- package/prisma/migrations/20260821000000_add_agent_restrict_slack_to_members/migration.sql +3 -0
- package/prisma/migrations/20260901000000_add_push_subscription_and_chat_thread_watch/migration.sql +40 -0
- package/prisma/migrations/20260903000000_add_agent_patch_author_allowlist/migration.sql +2 -0
- package/prisma/migrations/20260903120000_add_agent_review_author_allowlist/migration.sql +18 -0
- package/prisma/migrations/20260903150000_drop_agent_author_allowlist/migration.sql +14 -0
- package/prisma/migrations/20260905000000_add_started_at_index_to_agent_cron_run/migration.sql +11 -0
- package/prisma/migrations/20260906000000_add_session_follow_notification_prefs_alert_state/migration.sql +47 -0
- package/prisma/migrations/20260911000000_backfill_auto_follow_since/migration.sql +16 -0
- package/prisma/migrations/20260923115814_add_last_heartbeat_at_to_agent_cron_run/migration.sql +2 -0
- package/prisma/migrations/20260924120000_add_trial_expiry_fields_to_agent/migration.sql +6 -0
- package/prisma/migrations/20260929000000_add_agent_phase_methodology/migration.sql +21 -0
- package/prisma/migrations/20260929120000_add_agent_policy_fields/migration.sql +15 -0
- package/prisma/migrations/20261005000000_add_agent_github_installations_snapshot/migration.sql +21 -0
- package/prisma/migrations/20261008000000_add_cron_run_telemetry/migration.sql +50 -0
- package/prisma/migrations/20261008010000_add_account_models/migration.sql +48 -0
- package/prisma/migrations/20261008020000_add_cron_lockdown_disabled_at/migration.sql +3 -0
- package/prisma/migrations/20261009000000_chat_token_usage_bigint/migration.sql +7 -0
- package/prisma/migrations/20261010000000_add_cron_run_command_variant/migration.sql +2 -0
- package/prisma/migrations/20261019000000_add_agent_anthropic_identity/migration.sql +9 -0
- package/prisma/migrations/20261025000000_add_agent_ask/migration.sql +23 -0
- package/prisma/migrations/migration_lock.toml +3 -0
- package/prisma/schema/coding.prisma +85 -0
- package/prisma/schema/schema.prisma +506 -0
- package/prisma.config.ts +34 -0
- package/pwa-assets/icons/apple-touch-icon.png +0 -0
- package/pwa-assets/icons/favicon-32.png +0 -0
- package/pwa-assets/icons/icon-192.png +0 -0
- package/pwa-assets/icons/icon-512.png +0 -0
- package/pwa-assets/icons/icon-maskable-192.png +0 -0
- package/pwa-assets/icons/icon-maskable-512.png +0 -0
- package/src/account-agent-create.ts +42 -0
- package/src/account-agent-create.unit.test.ts +28 -0
- package/src/account-created-notifier.ts +71 -0
- package/src/account-created-notifier.unit.test.ts +70 -0
- package/src/account-email.ts +9 -0
- package/src/account-email.unit.test.ts +21 -0
- package/src/account-invites.ts +70 -0
- package/src/account-lifecycle.integration.test.ts +329 -0
- package/src/account-lifecycle.ts +206 -0
- package/src/account-lifecycle.unit.test.ts +108 -0
- package/src/account-members.ts +128 -0
- package/src/account-onboarding.integration.test.ts +101 -0
- package/src/account-onboarding.ts +95 -0
- package/src/account-trial-expiry-sweeper.ts +85 -0
- package/src/account-trial-warning-sweeper.integration.test.ts +228 -0
- package/src/account-trial-warning-sweeper.ts +216 -0
- package/src/accounts-api.integration.test.ts +196 -0
- package/src/accounts-api.smoke.test.ts +292 -0
- package/src/accounts-api.ts +289 -0
- package/src/accounts-api.unit.test.ts +93 -0
- package/src/accounts.integration.test.ts +178 -0
- package/src/accounts.ts +208 -0
- package/src/admin-boot-config.ts +574 -0
- package/src/admin-boot-config.unit.test.ts +28 -0
- package/src/admin-boot.boot-order.unit.test.ts +91 -0
- package/src/admin-boot.ts +690 -0
- package/src/admin-dev-login.smoke.test.ts +317 -0
- package/src/admin-extension.ts +432 -0
- package/src/admin-extension.unit.test.ts +532 -0
- package/src/admin-local-agent.smoke.test.ts +1643 -0
- package/src/admin-spec.smoke.test.ts +96 -0
- package/src/admin-spec.ts +186 -0
- package/src/admin-ui-account-nav.ts +20 -0
- package/src/admin-ui-account-pages.ts +131 -0
- package/src/admin-ui-account-pages.unit.test.ts +112 -0
- package/src/admin-ui-account.smoke.test.ts +286 -0
- package/src/admin-ui-account.ts +185 -0
- package/src/admin-ui-accounts-pages.ts +202 -0
- package/src/admin-ui-accounts-pages.unit.test.ts +138 -0
- package/src/admin-ui-accounts.smoke.test.ts +434 -0
- package/src/admin-ui-accounts.ts +266 -0
- package/src/admin-ui-layout.ts +60 -0
- package/src/admin-ui-layout.unit.test.ts +140 -0
- package/src/admin-ui-pages.ts +3489 -0
- package/src/admin-ui-pages.unit.test.ts +4355 -0
- package/src/admin-ui-push-test.smoke.test.ts +125 -0
- package/src/admin-ui-push-test.ts +102 -0
- package/src/admin-ui-styles.ts +656 -0
- package/src/admin-ui-styles.unit.test.ts +103 -0
- package/src/admin-ui.fixtures.ts +457 -0
- package/src/admin-ui.smoke.test.ts +7227 -0
- package/src/admin-ui.ts +3824 -0
- package/src/agent-account-assignment.ts +49 -0
- package/src/agent-account-assignment.unit.test.ts +99 -0
- package/src/agent-anthropic-identity.integration.test.ts +99 -0
- package/src/agent-anthropic-identity.ts +78 -0
- package/src/agent-anthropic-identity.unit.test.ts +77 -0
- package/src/agent-ask-config.ts +59 -0
- package/src/agent-ask-config.unit.test.ts +70 -0
- package/src/agent-ask.integration.test.ts +175 -0
- package/src/agent-ask.smoke.test.ts +457 -0
- package/src/agent-ask.ts +378 -0
- package/src/agent-chat-tokens.integration.test.ts +420 -0
- package/src/agent-chat-tokens.smoke.test.ts +551 -0
- package/src/agent-chat-tokens.ts +259 -0
- package/src/agent-cron-jobs.integration.test.ts +582 -0
- package/src/agent-cron-jobs.ts +615 -0
- package/src/agent-cron-jobs.unit.test.ts +420 -0
- package/src/agent-cron-run-stats.integration.test.ts +1551 -0
- package/src/agent-cron-run-stats.smoke.test.ts +953 -0
- package/src/agent-cron-run-stats.ts +752 -0
- package/src/agent-cron-runs.integration.test.ts +1491 -0
- package/src/agent-cron-runs.ts +489 -0
- package/src/agent-deletion-checklist.ts +128 -0
- package/src/agent-deletion-checklist.unit.test.ts +201 -0
- package/src/agent-deletion-per-agent.integration.test.ts +323 -0
- package/src/agent-deletion.integration.test.ts +323 -0
- package/src/agent-deletion.ts +329 -0
- package/src/agent-deletion.unit.test.ts +412 -0
- package/src/agent-envs.integration.test.ts +323 -0
- package/src/agent-envs.ts +244 -0
- package/src/agent-github-installations.integration.test.ts +115 -0
- package/src/agent-github-installations.ts +52 -0
- package/src/agent-manifest.ts +685 -0
- package/src/agent-manifest.unit.test.ts +870 -0
- package/src/agent-members.ts +69 -0
- package/src/agent-members.unit.test.ts +250 -0
- package/src/agent-patch-author-allowlist.integration.test.ts +218 -0
- package/src/agent-plugins.integration.test.ts +157 -0
- package/src/agent-plugins.ts +95 -0
- package/src/agent-plugins.unit.test.ts +43 -0
- package/src/agent-provisioner-per-agent.integration.test.ts +949 -0
- package/src/agent-provisioner.integration.test.ts +1245 -0
- package/src/agent-provisioner.ts +1650 -0
- package/src/agent-provisioner.unit.test.ts +1852 -0
- package/src/agent-tokens.integration.test.ts +150 -0
- package/src/agent-tokens.ts +112 -0
- package/src/agent-tools.integration.test.ts +129 -0
- package/src/agent-tools.ts +111 -0
- package/src/agents-api.integration.test.ts +705 -0
- package/src/agents-api.sentry.smoke.test.ts +478 -0
- package/src/agents-api.smoke.test.ts +5986 -0
- package/src/agents-api.ts +2826 -0
- package/src/agents.integration.test.ts +512 -0
- package/src/agents.ts +1097 -0
- package/src/agents.unit.test.ts +1736 -0
- package/src/anthropic-credential-guard.ts +44 -0
- package/src/anthropic-credential-guard.unit.test.ts +88 -0
- package/src/anthropic-federated-token-provider.integration.test.ts +248 -0
- package/src/anthropic-federated-token-provider.ts +249 -0
- package/src/anthropic-identity-client.fixtures.ts +237 -0
- package/src/anthropic-identity-client.integration.test.ts +342 -0
- package/src/anthropic-identity-client.ts +473 -0
- package/src/anthropic-identity-fixture-spec.integration.test.ts +169 -0
- package/src/anthropic-identity-name.integration.test.ts +83 -0
- package/src/anthropic-identity-name.ts +163 -0
- package/src/anthropic-identity-name.unit.test.ts +345 -0
- package/src/anthropic-openapi-contract.fixtures.ts +192 -0
- package/src/api-auth.ts +383 -0
- package/src/api-auth.unit.test.ts +837 -0
- package/src/api.smoke.test.ts +1225 -0
- package/src/api.ts +376 -0
- package/src/attachment-validation.ts +124 -0
- package/src/attachment-validation.unit.test.ts +218 -0
- package/src/caller-scope.ts +131 -0
- package/src/caller-scope.unit.test.ts +116 -0
- package/src/chat-credential-config.ts +22 -0
- package/src/chat-credentials-logging.unit.test.ts +92 -0
- package/src/chat-markers.ts +114 -0
- package/src/chat-markers.unit.test.ts +116 -0
- package/src/chat-service-provisioning-client.integration.test.ts +412 -0
- package/src/chat-service-provisioning-client.ts +221 -0
- package/src/chat.smoke.test.ts +1875 -0
- package/src/clock.ts +47 -0
- package/src/coding-task-store-provisioning-client.integration.test.ts +372 -0
- package/src/coding-task-store-provisioning-client.ts +156 -0
- package/src/crypto.ts +83 -0
- package/src/dev-auth-guard.ts +29 -0
- package/src/dev-auth-guard.unit.test.ts +51 -0
- package/src/errors.ts +77 -0
- package/src/errors.unit.test.ts +52 -0
- package/src/fixtures/anthropic-identity-recorded-fixtures.json +850 -0
- package/src/fixtures/anthropic-openapi/SDK_VERSION +1 -0
- package/src/fixtures/anthropic-openapi/mock-spec.json.gz +0 -0
- package/src/fixtures/chat-service-provisioning-cassette.json +156 -0
- package/src/fixtures/github-app-manifest-cassette.json +44 -0
- package/src/fixtures/google-auth-cassette.json +36 -0
- package/src/fixtures/http-chat-client-cassette.json +226 -0
- package/src/fixtures/k8s-cassette.json +470 -0
- package/src/fixtures/okta-auth-cassette.json +36 -0
- package/src/fixtures/slack-provision-cassette.json +12 -0
- package/src/fixtures/slack-provisioning-http-cassette.json +98 -0
- package/src/fixtures/task-store-provisioning-cassette.json +85 -0
- package/src/github-app-provisioning-client.integration.test.ts +149 -0
- package/src/github-app-provisioning-client.ts +161 -0
- package/src/github-app-provisioning-client.unit.test.ts +114 -0
- package/src/github-provisioning-service.ts +569 -0
- package/src/github-provisioning-service.unit.test.ts +123 -0
- package/src/google-auth-client.integration.test.ts +195 -0
- package/src/google-auth-client.ts +121 -0
- package/src/http-chat-client.integration.test.ts +554 -0
- package/src/http-chat-client.ts +533 -0
- package/src/http-chat-client.unit.test.ts +119 -0
- package/src/import-boundary.unit.test.ts +240 -0
- package/src/kubernetes-client.integration.test.ts +697 -0
- package/src/kubernetes-client.ts +1027 -0
- package/src/kubernetes-client.unit.test.ts +726 -0
- package/src/main.smoke.test.ts +91 -0
- package/src/main.ts +21 -0
- package/src/main.unit.test.ts +801 -0
- package/src/okta-auth-client.integration.test.ts +263 -0
- package/src/okta-auth-client.ts +152 -0
- package/src/openapi-schemas.ts +1310 -0
- package/src/openapi-schemas.unit.test.ts +348 -0
- package/src/per-agent-identity.fixtures.ts +305 -0
- package/src/platform-doc-route.ts +40 -0
- package/src/prisma-client.integration.test.ts +57 -0
- package/src/prisma-client.ts +187 -0
- package/src/prisma-client.unit.test.ts +178 -0
- package/src/prisma-tx.ts +23 -0
- package/src/push-content.ts +177 -0
- package/src/push-content.unit.test.ts +213 -0
- package/src/push-crypto.ts +135 -0
- package/src/push-crypto.unit.test.ts +66 -0
- package/src/push-sender.ts +169 -0
- package/src/push-sender.unit.test.ts +107 -0
- package/src/push-service.ts +332 -0
- package/src/push-service.unit.test.ts +679 -0
- package/src/push-toggle.ts +135 -0
- package/src/push-toggle.unit.test.ts +78 -0
- package/src/pwa-https-gating.smoke.test.ts +229 -0
- package/src/pwa-icons.unit.test.ts +41 -0
- package/src/pwa-routes.smoke.test.ts +276 -0
- package/src/pwa.ts +380 -0
- package/src/pwa.unit.test.ts +321 -0
- package/src/schema.unit.test.ts +122 -0
- package/src/self-serve-config.ts +52 -0
- package/src/self-serve-config.unit.test.ts +79 -0
- package/src/server.smoke.test.ts +273 -0
- package/src/server.ts +50 -0
- package/src/service-integration.ts +67 -0
- package/src/service-integration.unit.test.ts +129 -0
- package/src/slack-provisioning-client.integration.test.ts +308 -0
- package/src/slack-provisioning-client.ts +411 -0
- package/src/slack-provisioning-client.unit.test.ts +80 -0
- package/src/slack-provisioning-service.integration.test.ts +637 -0
- package/src/slack-provisioning-service.ts +451 -0
- package/src/slack-provisioning.integration.test.ts +395 -0
- package/src/specialty-manifest-loader.fixture.integration.test.ts +63 -0
- package/src/specialty-manifest-loader.ts +255 -0
- package/src/specialty-manifest-loader.unit.test.ts +301 -0
- package/src/specialty-registry.schema.unit.test.ts +35 -0
- package/src/specialty-registry.ts +236 -0
- package/src/specialty-registry.unit.test.ts +272 -0
- package/src/startup-policy-reconcile.ts +77 -0
- package/src/startup-policy-reconcile.unit.test.ts +251 -0
- package/src/theme-tokens.json +12 -0
- package/src/token-crypto.ts +60 -0
- package/src/token-crypto.unit.test.ts +182 -0
- package/src/trial-expiry-sweeper.integration.test.ts +318 -0
- package/src/trial-expiry-sweeper.ts +132 -0
- package/src/trial-expiry-sweeper.unit.test.ts +391 -0
- package/src/trial-expiry-warning-sweeper.integration.test.ts +468 -0
- package/src/trial-expiry-warning-sweeper.ts +447 -0
- package/src/trial-expiry-warning-sweeper.unit.test.ts +148 -0
- package/src/voice-contract.unit.test.ts +102 -0
- package/tsconfig.json +9 -0
package/src/admin-ui.ts
ADDED
|
@@ -0,0 +1,3824 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent/src/admin-ui.ts
|
|
3
|
+
* Admin UI — server-rendered Hono app factory.
|
|
4
|
+
*
|
|
5
|
+
* Routes:
|
|
6
|
+
* GET /admin/login — login page (Google sign-in button)
|
|
7
|
+
* GET /admin/auth/google — redirect to Google OAuth consent
|
|
8
|
+
* GET /admin/auth/callback — Google OAuth callback → set session cookie
|
|
9
|
+
* GET /admin/auth/okta — redirect to Okta OIDC consent
|
|
10
|
+
* GET /admin/auth/okta/callback — Okta OAuth callback → set session cookie
|
|
11
|
+
* POST /admin/logout — clear cookie → redirect to login
|
|
12
|
+
* GET /admin/agents — list all agents (auth required)
|
|
13
|
+
* GET /admin/agents/:id — agent detail (auth required)
|
|
14
|
+
* POST /admin/agents/:id/envs — add/update env var (auth required)
|
|
15
|
+
* POST /admin/agents/:id/envs/delete — delete env var (auth required)
|
|
16
|
+
* GET /admin/provision — 302 redirect to /admin/agents/new (legacy entry point)
|
|
17
|
+
*
|
|
18
|
+
* Auth: httpOnly JWT cookie named "admin_session".
|
|
19
|
+
* Login is OAuth (Google or Okta) — no password, no DB user lookup. Both
|
|
20
|
+
* providers share a common post-auth step (completeLogin()): verified-email
|
|
21
|
+
* check, admin-allowlist/AgentMember-fallback check, session JWT + cookie.
|
|
22
|
+
* Allowed users are controlled by the adminAllowedEmails allowlist in deps.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
import { isGithubLogin } from "@app-vitals/agent-lib/github-login";
|
|
27
|
+
import { isOrgRepo } from "@app-vitals/agent-lib/org-repo";
|
|
28
|
+
import { SECRET_ENV_VARS } from "@app-vitals/agent-lib/secret-env-vars";
|
|
29
|
+
import { type Context, Hono, type MiddlewareHandler } from "hono";
|
|
30
|
+
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
|
31
|
+
import { sign, verify } from "hono/jwt";
|
|
32
|
+
import {
|
|
33
|
+
accountIdFromScope,
|
|
34
|
+
getSelfServeSpecialty,
|
|
35
|
+
} from "./account-agent-create.ts";
|
|
36
|
+
import type { AccountCreatedNotifier } from "./account-created-notifier.ts";
|
|
37
|
+
import { fireAccountCreatedNotification } from "./account-created-notifier.ts";
|
|
38
|
+
import type { AccountInviteService } from "./account-invites.ts";
|
|
39
|
+
import type { AccountLifecycle } from "./account-lifecycle.ts";
|
|
40
|
+
import type { AccountMemberService } from "./account-members.ts";
|
|
41
|
+
import type { AccountOnboardingService } from "./account-onboarding.ts";
|
|
42
|
+
import type { AccountService } from "./accounts.ts";
|
|
43
|
+
import {
|
|
44
|
+
type AgentDetailCardSlot,
|
|
45
|
+
renderAgentDetailCardSlots,
|
|
46
|
+
} from "./admin-extension.ts";
|
|
47
|
+
import { registerAccountRoutes } from "./admin-ui-account.ts";
|
|
48
|
+
import { runWithAccountNav } from "./admin-ui-account-nav.ts";
|
|
49
|
+
import { renderZeroQuotaNotice } from "./admin-ui-account-pages.ts";
|
|
50
|
+
import { registerAdminAccountsRoutes } from "./admin-ui-accounts.ts";
|
|
51
|
+
import {
|
|
52
|
+
type AgentDetail,
|
|
53
|
+
type AgentOption,
|
|
54
|
+
renderAgentDetailPage,
|
|
55
|
+
renderAgentsPage,
|
|
56
|
+
renderChatMessageBubble,
|
|
57
|
+
renderChatPage,
|
|
58
|
+
renderChatThreadPage,
|
|
59
|
+
renderGithubAppInstalledPage,
|
|
60
|
+
renderGithubAppInstallPage,
|
|
61
|
+
renderGithubAppManifestRedirectPage,
|
|
62
|
+
renderLoginPage,
|
|
63
|
+
renderNewLocalAgentPage,
|
|
64
|
+
renderProvisionCompletePage,
|
|
65
|
+
renderProvisionXappTokenPage,
|
|
66
|
+
} from "./admin-ui-pages.ts";
|
|
67
|
+
import type { AgentCronJobService } from "./agent-cron-jobs.ts";
|
|
68
|
+
import type { DeleteAgentFullyDeps } from "./agent-deletion.ts";
|
|
69
|
+
import { deleteAgentFully } from "./agent-deletion.ts";
|
|
70
|
+
import type { ManualStep } from "./agent-deletion-checklist.ts";
|
|
71
|
+
import type { AgentEnvService } from "./agent-envs.ts";
|
|
72
|
+
import type { AgentMemberService } from "./agent-members.ts";
|
|
73
|
+
import type { AgentPluginService } from "./agent-plugins.ts";
|
|
74
|
+
import type { AgentProvisioner } from "./agent-provisioner.ts";
|
|
75
|
+
import type { AgentTokenService } from "./agent-tokens.ts";
|
|
76
|
+
import type { AgentToolService } from "./agent-tools.ts";
|
|
77
|
+
import {
|
|
78
|
+
type SpecialtyManifestResolver,
|
|
79
|
+
SpecialtyRegistry,
|
|
80
|
+
} from "./specialty-manifest-loader.ts";
|
|
81
|
+
import type { AgentService } from "./agents.ts";
|
|
82
|
+
import { createAgent, describeRepoNameCollision } from "./agents.ts";
|
|
83
|
+
import { AnthropicCredentialsBlockedError } from "./anthropic-credential-guard.ts";
|
|
84
|
+
import {
|
|
85
|
+
audioContentTypeForFilename,
|
|
86
|
+
validateAttachment,
|
|
87
|
+
} from "./attachment-validation.ts";
|
|
88
|
+
import {
|
|
89
|
+
type CallerScopeResolver,
|
|
90
|
+
memberOnlyCallerScopeResolver,
|
|
91
|
+
scopeIncludesAgent,
|
|
92
|
+
} from "./caller-scope.ts";
|
|
93
|
+
import { ForbiddenError, UnprocessableEntityError } from "./errors.ts";
|
|
94
|
+
import {
|
|
95
|
+
GITHUB_PROVISION_STATE_COOKIE,
|
|
96
|
+
GITHUB_PROVISION_STATE_TTL_SECONDS,
|
|
97
|
+
GithubProvisioningService,
|
|
98
|
+
} from "./github-provisioning-service.ts";
|
|
99
|
+
import type { GoogleAuthClient } from "./google-auth-client.ts";
|
|
100
|
+
import {
|
|
101
|
+
type ChatClient,
|
|
102
|
+
type ChatThread,
|
|
103
|
+
filterSince,
|
|
104
|
+
toPolledMessage,
|
|
105
|
+
} from "./http-chat-client.ts";
|
|
106
|
+
import type { OktaAuthClient } from "./okta-auth-client.ts";
|
|
107
|
+
import type { PrismaTransactionClient } from "./prisma-tx.ts";
|
|
108
|
+
import type { PushService } from "./push-service.ts";
|
|
109
|
+
import {
|
|
110
|
+
buildManifest,
|
|
111
|
+
buildOfflinePageHtml,
|
|
112
|
+
buildServiceWorkerBody,
|
|
113
|
+
getPrecacheList,
|
|
114
|
+
PWA_ASSETS_DIR,
|
|
115
|
+
PWA_ICONS,
|
|
116
|
+
renderPwaHeadTags,
|
|
117
|
+
sanitizeStartUrl,
|
|
118
|
+
} from "./pwa.ts";
|
|
119
|
+
import {
|
|
120
|
+
DEFAULT_SELF_SERVE_CONTACT_EMAIL,
|
|
121
|
+
type SelfServeConfig,
|
|
122
|
+
} from "./self-serve-config.ts";
|
|
123
|
+
import type { AppManifest } from "./slack-provisioning-client.ts";
|
|
124
|
+
import {
|
|
125
|
+
AGENT_BOT_SCOPES,
|
|
126
|
+
buildAgentManifest,
|
|
127
|
+
} from "./slack-provisioning-client.ts";
|
|
128
|
+
import {
|
|
129
|
+
PROVISION_STATE_COOKIE,
|
|
130
|
+
PROVISION_STATE_TTL_SECONDS,
|
|
131
|
+
SlackProvisioningService,
|
|
132
|
+
} from "./slack-provisioning-service.ts";
|
|
133
|
+
|
|
134
|
+
export type AdminUIEnv = { Variables: { userEmail: string; isAdmin: boolean } };
|
|
135
|
+
|
|
136
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Narrow interface for the admin UI's Slack dependency.
|
|
140
|
+
* Deliberately narrower than the full SlackProvisioningClient
|
|
141
|
+
* in slack-provisioning-client.ts — only this surface is needed here.
|
|
142
|
+
*/
|
|
143
|
+
export interface AdminUISlackClient {
|
|
144
|
+
createAppManifest(
|
|
145
|
+
xoxpToken: string,
|
|
146
|
+
manifest: AppManifest,
|
|
147
|
+
): Promise<{
|
|
148
|
+
appId: string;
|
|
149
|
+
oauthRedirectUrl: string;
|
|
150
|
+
clientId: string;
|
|
151
|
+
clientSecret: string;
|
|
152
|
+
signingSecret: string;
|
|
153
|
+
}>;
|
|
154
|
+
updateAppManifest(
|
|
155
|
+
xoxpToken: string,
|
|
156
|
+
appId: string,
|
|
157
|
+
manifest: AppManifest,
|
|
158
|
+
): Promise<void>;
|
|
159
|
+
exchangeOAuthCode(
|
|
160
|
+
code: string,
|
|
161
|
+
clientId: string,
|
|
162
|
+
clientSecret: string,
|
|
163
|
+
redirectUri: string,
|
|
164
|
+
): Promise<{ botToken: string }>;
|
|
165
|
+
authTest(botToken: string): Promise<{ userId: string }>;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Narrow interface for the admin UI's GitHub App provisioning dependency.
|
|
170
|
+
* Mirrors AdminUISlackClient's DI pattern — deliberately narrower than the
|
|
171
|
+
* full GithubAppProvisioningClient in github-app-provisioning-client.ts.
|
|
172
|
+
*/
|
|
173
|
+
export interface AdminUIGithubAppClient {
|
|
174
|
+
exchangeManifestCode(code: string): Promise<{
|
|
175
|
+
appId: string;
|
|
176
|
+
slug: string;
|
|
177
|
+
pem: string;
|
|
178
|
+
clientId: string;
|
|
179
|
+
clientSecret: string;
|
|
180
|
+
}>;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
interface PrismaAgentLike {
|
|
184
|
+
findMany(args?: object): Promise<
|
|
185
|
+
Array<{
|
|
186
|
+
id: string;
|
|
187
|
+
name: string;
|
|
188
|
+
slackId: string | null;
|
|
189
|
+
createdAt: Date;
|
|
190
|
+
updatedAt?: Date;
|
|
191
|
+
repos?: string[];
|
|
192
|
+
}>
|
|
193
|
+
>;
|
|
194
|
+
findUnique(args: { where: { id: string } }): Promise<{
|
|
195
|
+
id: string;
|
|
196
|
+
name: string;
|
|
197
|
+
slackId: string | null;
|
|
198
|
+
selfHosted: boolean;
|
|
199
|
+
createdAt: Date;
|
|
200
|
+
updatedAt: Date;
|
|
201
|
+
repos: string[];
|
|
202
|
+
} | null>;
|
|
203
|
+
create(args: {
|
|
204
|
+
data: { name: string; slackId?: string | null; selfHosted?: boolean };
|
|
205
|
+
}): Promise<{
|
|
206
|
+
id: string;
|
|
207
|
+
name: string;
|
|
208
|
+
slackId: string | null;
|
|
209
|
+
createdAt: Date;
|
|
210
|
+
updatedAt: Date;
|
|
211
|
+
repos: string[];
|
|
212
|
+
}>;
|
|
213
|
+
update(args: { where: { id: string }; data: { repos: string[] } }): Promise<{
|
|
214
|
+
id: string;
|
|
215
|
+
name: string;
|
|
216
|
+
slackId: string | null;
|
|
217
|
+
createdAt: Date;
|
|
218
|
+
updatedAt: Date;
|
|
219
|
+
repos: string[];
|
|
220
|
+
}>;
|
|
221
|
+
delete(args: { where: { id: string } }): Promise<{
|
|
222
|
+
id: string;
|
|
223
|
+
name: string;
|
|
224
|
+
slackId: string | null;
|
|
225
|
+
createdAt: Date;
|
|
226
|
+
updatedAt: Date;
|
|
227
|
+
repos: string[];
|
|
228
|
+
}>;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
interface PrismaLike {
|
|
232
|
+
agent: PrismaAgentLike;
|
|
233
|
+
agentEnv: {
|
|
234
|
+
findMany(args: {
|
|
235
|
+
where: { agentId: string };
|
|
236
|
+
select: { key: true; value: true; secret: true };
|
|
237
|
+
}): Promise<{ key: string; value: string; secret: boolean }[]>;
|
|
238
|
+
};
|
|
239
|
+
agentPlugin: {
|
|
240
|
+
findMany(args: { where: { agentId: string; enabled: boolean } }): Promise<
|
|
241
|
+
Array<{
|
|
242
|
+
id: string;
|
|
243
|
+
name: string;
|
|
244
|
+
version: string | null;
|
|
245
|
+
enabled: boolean;
|
|
246
|
+
}>
|
|
247
|
+
>;
|
|
248
|
+
};
|
|
249
|
+
agentMember: {
|
|
250
|
+
findMany(args: {
|
|
251
|
+
where: { email?: string; agentId?: string };
|
|
252
|
+
}): Promise<
|
|
253
|
+
Array<{ id: string; agentId: string; email: string; createdAt: Date }>
|
|
254
|
+
>;
|
|
255
|
+
findUnique(args: {
|
|
256
|
+
where: { agentId_email: { agentId: string; email: string } };
|
|
257
|
+
}): Promise<{ id: string; agentId: string; email: string } | null>;
|
|
258
|
+
create(args: {
|
|
259
|
+
data: { agentId: string; email: string };
|
|
260
|
+
}): Promise<{ id: string; agentId: string; email: string }>;
|
|
261
|
+
deleteMany(args: { where: { id: string; agentId: string } }): Promise<{
|
|
262
|
+
count: number;
|
|
263
|
+
}>;
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export interface AdminUIDeps {
|
|
268
|
+
prisma: PrismaLike;
|
|
269
|
+
agentEnvService: Pick<
|
|
270
|
+
AgentEnvService,
|
|
271
|
+
"getByAgentId" | "upsert" | "patch" | "deleteKey" | "getConfigBundle"
|
|
272
|
+
>;
|
|
273
|
+
agentCronJobService: Pick<
|
|
274
|
+
AgentCronJobService,
|
|
275
|
+
| "list"
|
|
276
|
+
| "listWithRunSummary"
|
|
277
|
+
| "create"
|
|
278
|
+
| "update"
|
|
279
|
+
| "setEnabled"
|
|
280
|
+
| "delete"
|
|
281
|
+
| "get"
|
|
282
|
+
| "reconcileSystemCrons"
|
|
283
|
+
>;
|
|
284
|
+
/**
|
|
285
|
+
* Optional extension card slots (AEP-2.1, admin-extension.ts) rendered on
|
|
286
|
+
* the agent detail page at each slot's placement (header or Configuration
|
|
287
|
+
* group); absent renders none.
|
|
288
|
+
*/
|
|
289
|
+
agentDetailCards?: readonly AgentDetailCardSlot[];
|
|
290
|
+
/**
|
|
291
|
+
* True when an extension mounts /admin/agents/:id/queue-activity (the coding
|
|
292
|
+
* extension, AEP-3.6); the agents list and agent detail render their Queue &
|
|
293
|
+
* Activity / Logs links only then. Absent renders none.
|
|
294
|
+
*/
|
|
295
|
+
queueActivityLinks?: boolean;
|
|
296
|
+
agentToolService: Pick<
|
|
297
|
+
AgentToolService,
|
|
298
|
+
"list" | "add" | "toggle" | "remove"
|
|
299
|
+
>;
|
|
300
|
+
agentTokenService: Pick<
|
|
301
|
+
AgentTokenService,
|
|
302
|
+
"listForAgent" | "create" | "revoke"
|
|
303
|
+
>;
|
|
304
|
+
agentPluginService: Pick<AgentPluginService, "list" | "add">;
|
|
305
|
+
agentMemberService: Pick<
|
|
306
|
+
AgentMemberService,
|
|
307
|
+
"listByEmail" | "exists" | "add" | "remove" | "listByAgentId"
|
|
308
|
+
>;
|
|
309
|
+
/**
|
|
310
|
+
* Account-aware caller-scope resolver (SSP-2.1). Defaults to the
|
|
311
|
+
* AgentMember-only resolver, i.e. today's behavior with the flag off.
|
|
312
|
+
*/
|
|
313
|
+
callerScopeResolver?: CallerScopeResolver;
|
|
314
|
+
/**
|
|
315
|
+
* Account lookups for the new-agent form's remaining-quota display
|
|
316
|
+
* (SSP-4.2). Absent → the quota line is omitted.
|
|
317
|
+
*/
|
|
318
|
+
accountService?: Pick<
|
|
319
|
+
AccountService,
|
|
320
|
+
"getById" | "countAgents" | "listAgentIds"
|
|
321
|
+
>;
|
|
322
|
+
agentService: Pick<
|
|
323
|
+
AgentService,
|
|
324
|
+
| "listAll"
|
|
325
|
+
| "listByIds"
|
|
326
|
+
| "searchByName"
|
|
327
|
+
| "listOptions"
|
|
328
|
+
| "create"
|
|
329
|
+
| "delete"
|
|
330
|
+
| "getDetail"
|
|
331
|
+
| "updateFields"
|
|
332
|
+
> & {
|
|
333
|
+
/**
|
|
334
|
+
* Runs a function inside a single interactive Prisma transaction — see
|
|
335
|
+
* AgentService.runTransaction(). Optional here (unlike the rest of this
|
|
336
|
+
* Pick) so the many existing test doubles for routes that never touch
|
|
337
|
+
* agent creation don't all need to implement it: POST /admin/agents
|
|
338
|
+
* (the only caller, via createAgent() in agents.ts) falls back to a
|
|
339
|
+
* non-transactional passthrough when it's absent. Every production
|
|
340
|
+
* wiring (main.ts) always supplies the real AgentService method.
|
|
341
|
+
*/
|
|
342
|
+
runTransaction?: AgentService["runTransaction"];
|
|
343
|
+
};
|
|
344
|
+
provisioner: AgentProvisioner;
|
|
345
|
+
/** WIF-2.1: Anthropic federation is enabled; defaults to false. */
|
|
346
|
+
anthropicFederationEnabled?: boolean;
|
|
347
|
+
/**
|
|
348
|
+
* Resolves an agent's typeName to its parsed Specialty manifest and lists
|
|
349
|
+
* the registry's discoverable types (for the new-specialty picker).
|
|
350
|
+
* Defaults to a disk-backed SpecialtyRegistry, matching the DI pattern
|
|
351
|
+
* already used by agents-api.ts's AdminDeps.
|
|
352
|
+
*/
|
|
353
|
+
specialtyRegistry?: SpecialtyManifestResolver;
|
|
354
|
+
/**
|
|
355
|
+
* Cleanup deps for the full delete-agent orchestration (POST
|
|
356
|
+
* /admin/agents/:id/delete → deleteAgentFully()). Matches
|
|
357
|
+
* DeleteAgentFullyDeps's shapes exactly, mirroring agents-api.ts's AdminDeps
|
|
358
|
+
* so both delete entry points share the same production wiring in main.ts.
|
|
359
|
+
*/
|
|
360
|
+
serviceIntegrations: DeleteAgentFullyDeps["serviceIntegrations"];
|
|
361
|
+
chatService: DeleteAgentFullyDeps["chatService"];
|
|
362
|
+
slack: DeleteAgentFullyDeps["slack"];
|
|
363
|
+
decrypt: DeleteAgentFullyDeps["decrypt"];
|
|
364
|
+
sessionSecret: string;
|
|
365
|
+
googleClient: GoogleAuthClient;
|
|
366
|
+
googleClientId: string;
|
|
367
|
+
googleClientSecret: string;
|
|
368
|
+
/**
|
|
369
|
+
* Okta OIDC client for GET /admin/auth/okta and GET /admin/auth/okta/callback.
|
|
370
|
+
* Optional — when oktaClientId/oktaIssuer are unset (or oktaClient is absent),
|
|
371
|
+
* both Okta routes redirect to /admin/login?error=server_error without
|
|
372
|
+
* attempting any external call, mirroring Google's own missing-config guard.
|
|
373
|
+
*/
|
|
374
|
+
oktaClient?: OktaAuthClient;
|
|
375
|
+
oktaClientId?: string;
|
|
376
|
+
oktaClientSecret?: string;
|
|
377
|
+
oktaIssuer?: string;
|
|
378
|
+
adminAllowedEmails: string[];
|
|
379
|
+
slackClient: AdminUISlackClient;
|
|
380
|
+
/**
|
|
381
|
+
* GitHub App manifest-flow provisioning client, used by the auto-provision
|
|
382
|
+
* sub-flow under ghAuthMode=app: GET /admin/provision/github-app/complete
|
|
383
|
+
* exchanges the manifest-flow code via exchangeManifestCode().
|
|
384
|
+
*/
|
|
385
|
+
githubAppClient: AdminUIGithubAppClient;
|
|
386
|
+
appBaseUrl: string;
|
|
387
|
+
/** Enable the /admin/dev-login route. Hard-blocked in production regardless of this value. */
|
|
388
|
+
devAuthEnabled?: boolean;
|
|
389
|
+
/** Resolved self-serve provisioning config (SSP-1.3). */
|
|
390
|
+
selfServe?: SelfServeConfig;
|
|
391
|
+
/**
|
|
392
|
+
* First-login account provisioning (SSP-3.1). Consulted by the Google
|
|
393
|
+
* callback only, and only when selfServe.enabled; absent → no auto-create.
|
|
394
|
+
*/
|
|
395
|
+
accountOnboarding?: Pick<AccountOnboardingService, "provisionForEmail">;
|
|
396
|
+
/** Best-effort operator push on auto-created accounts (SSP-3.3). */
|
|
397
|
+
accountCreatedNotifier?: AccountCreatedNotifier;
|
|
398
|
+
/**
|
|
399
|
+
* Account page + nav services (SSP-3.2). Used only when selfServe.enabled;
|
|
400
|
+
* absent → no /admin/account routes content, no nav entry, no quota notice.
|
|
401
|
+
*/
|
|
402
|
+
accountServices?: {
|
|
403
|
+
accounts: Pick<
|
|
404
|
+
AccountService,
|
|
405
|
+
| "getByMemberEmail"
|
|
406
|
+
| "update"
|
|
407
|
+
| "countAgents"
|
|
408
|
+
| "listWithCounts"
|
|
409
|
+
| "getWithCounts"
|
|
410
|
+
| "listAgents"
|
|
411
|
+
>;
|
|
412
|
+
members: Pick<
|
|
413
|
+
AccountMemberService,
|
|
414
|
+
"listByAccount" | "getByEmail" | "add" | "remove" | "promote" | "demote"
|
|
415
|
+
>;
|
|
416
|
+
invites: Pick<AccountInviteService, "create" | "listPending" | "revoke">;
|
|
417
|
+
/** SSP-8.2: admin account edits + suspend/reactivate side effects. */
|
|
418
|
+
lifecycle: Pick<AccountLifecycle, "update">;
|
|
419
|
+
};
|
|
420
|
+
/**
|
|
421
|
+
* IANA timezone name for date/time display in the admin UI.
|
|
422
|
+
* Defaults to "America/Los_Angeles" when absent.
|
|
423
|
+
*/
|
|
424
|
+
timezone?: string;
|
|
425
|
+
/**
|
|
426
|
+
* Extra `?error=` codes (code → message) an extension's routes redirect to
|
|
427
|
+
* the agent detail page with — merged over the platform's own codes.
|
|
428
|
+
*/
|
|
429
|
+
agentDetailErrorMessages?: Record<string, string>;
|
|
430
|
+
/**
|
|
431
|
+
* Chat service client for the /admin/chat routes.
|
|
432
|
+
* When absent, all chat routes render in degraded mode (notice, no table/messages).
|
|
433
|
+
*/
|
|
434
|
+
chatClient?: ChatClient;
|
|
435
|
+
/**
|
|
436
|
+
* Base directory the PWA shell's icon route reads committed PNGs from.
|
|
437
|
+
* Injectable for tests; defaults to the committed admin/pwa-assets/icons
|
|
438
|
+
* dir (see admin/src/pwa.ts's PWA_ASSETS_DIR, populated by the
|
|
439
|
+
* one-time/on-demand scripts/build-pwa-icons.ts).
|
|
440
|
+
*/
|
|
441
|
+
pwaAssetsDir?: string;
|
|
442
|
+
/**
|
|
443
|
+
* App version string interpolated into the service worker's cache name
|
|
444
|
+
* (see admin/src/pwa.ts's buildServiceWorkerBody) so a version bump busts
|
|
445
|
+
* any previously installed cache. Read once from version.txt at startup —
|
|
446
|
+
* matches the "inject time via a Clock" isolation convention rather than
|
|
447
|
+
* reading the file inside the route handler.
|
|
448
|
+
*/
|
|
449
|
+
appVersion?: string;
|
|
450
|
+
/**
|
|
451
|
+
* Web Push (CFB-4.2). All three are set together or none: main.ts only wires
|
|
452
|
+
* them when isPushEnabled() is true (public + private + subject all set), so
|
|
453
|
+
* when push is not fully configured pushService is undefined, the routes 503,
|
|
454
|
+
* and the toggle renders "" (page degrades to the CFB-3.2 page).
|
|
455
|
+
*/
|
|
456
|
+
pushService?: PushService;
|
|
457
|
+
/** VAPID PUBLIC key — sent to the browser (NOT a secret). "" disables the toggle. */
|
|
458
|
+
vapidPublicKey?: string;
|
|
459
|
+
/** Shared token the chat service presents to the push-notify webhook. */
|
|
460
|
+
pushWebhookToken?: string;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
464
|
+
|
|
465
|
+
const SESSION_COOKIE = "admin_session";
|
|
466
|
+
const OAUTH_STATE_COOKIE = "oauth_state";
|
|
467
|
+
const GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
468
|
+
const SESSION_TTL_SECONDS = 8 * 60 * 60; // 8 hours
|
|
469
|
+
const OAUTH_STATE_TTL_SECONDS = 600; // 10 min
|
|
470
|
+
|
|
471
|
+
// ─── Auth helpers ─────────────────────────────────────────────────────────────
|
|
472
|
+
|
|
473
|
+
async function createSessionToken(
|
|
474
|
+
secret: string,
|
|
475
|
+
userId: string,
|
|
476
|
+
email: string,
|
|
477
|
+
isAdmin: boolean,
|
|
478
|
+
): Promise<string> {
|
|
479
|
+
const now = Math.floor(Date.now() / 1000);
|
|
480
|
+
return sign(
|
|
481
|
+
{
|
|
482
|
+
userId,
|
|
483
|
+
email,
|
|
484
|
+
isAdmin,
|
|
485
|
+
iat: now,
|
|
486
|
+
exp: now + SESSION_TTL_SECONDS,
|
|
487
|
+
},
|
|
488
|
+
secret,
|
|
489
|
+
"HS256",
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async function getSessionUser(
|
|
494
|
+
token: string,
|
|
495
|
+
secret: string,
|
|
496
|
+
): Promise<{ email: string; isAdmin: boolean } | null> {
|
|
497
|
+
try {
|
|
498
|
+
const payload = (await verify(token, secret, "HS256")) as Record<
|
|
499
|
+
string,
|
|
500
|
+
unknown
|
|
501
|
+
>;
|
|
502
|
+
if (
|
|
503
|
+
typeof payload.userId === "string" &&
|
|
504
|
+
payload.userId.length > 0 &&
|
|
505
|
+
typeof payload.email === "string" &&
|
|
506
|
+
payload.email.length > 0
|
|
507
|
+
) {
|
|
508
|
+
return { email: payload.email, isAdmin: payload.isAdmin === true };
|
|
509
|
+
}
|
|
510
|
+
return null;
|
|
511
|
+
} catch {
|
|
512
|
+
return null;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// ─── Auth middleware ──────────────────────────────────────────────────────────
|
|
517
|
+
|
|
518
|
+
export function createUIAuthMiddleware(
|
|
519
|
+
sessionSecret: string,
|
|
520
|
+
): MiddlewareHandler<AdminUIEnv> {
|
|
521
|
+
return async (c, next) => {
|
|
522
|
+
const sessionToken = getCookie(c, SESSION_COOKIE);
|
|
523
|
+
const user = sessionToken
|
|
524
|
+
? await getSessionUser(sessionToken, sessionSecret)
|
|
525
|
+
: null;
|
|
526
|
+
if (!user) {
|
|
527
|
+
return c.redirect("/admin/login", 302);
|
|
528
|
+
}
|
|
529
|
+
c.set("userEmail", user.email);
|
|
530
|
+
c.set("isAdmin", user.isAdmin);
|
|
531
|
+
return next();
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// ─── Back-link validation ───────────────────────────────────────────────────
|
|
536
|
+
|
|
537
|
+
// Shared allowlist resolver: a `from` query param is only honored as a back
|
|
538
|
+
// link when it matches the given same-origin pattern. Rejects protocol-relative
|
|
539
|
+
// (//evil.com), absolute (https://evil.com), javascript:, and any other-path
|
|
540
|
+
// values — those fall back to the given default so a "← X" anchor can never be
|
|
541
|
+
// used as an open-redirect/phishing hop off the admin domain.
|
|
542
|
+
export function resolveBackHref(
|
|
543
|
+
fromParam: string | undefined,
|
|
544
|
+
pattern: RegExp,
|
|
545
|
+
fallback: string,
|
|
546
|
+
): string {
|
|
547
|
+
if (fromParam && pattern.test(fromParam)) {
|
|
548
|
+
return fromParam;
|
|
549
|
+
}
|
|
550
|
+
return fallback;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Redirects to the agent detail page, appending the zero-members warning
|
|
555
|
+
* query param when restrictSlackToMembers was just enabled on an agent with
|
|
556
|
+
* no AgentMember rows. Non-blocking — the caller's save already succeeded;
|
|
557
|
+
* this only decides which redirect target to use.
|
|
558
|
+
*/
|
|
559
|
+
async function redirectWithMembersWarning(
|
|
560
|
+
c: Context<AdminUIEnv>,
|
|
561
|
+
agentMemberService: Pick<AgentMemberService, "listByAgentId">,
|
|
562
|
+
agentId: string,
|
|
563
|
+
restrictSlackToMembers: boolean,
|
|
564
|
+
) {
|
|
565
|
+
if (restrictSlackToMembers) {
|
|
566
|
+
const members = await agentMemberService.listByAgentId(agentId);
|
|
567
|
+
if (members.length === 0) {
|
|
568
|
+
return c.redirect(
|
|
569
|
+
`/admin/agents/${agentId}?warning=restrict_slack_no_members`,
|
|
570
|
+
302,
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
return c.redirect(`/admin/agents/${agentId}`, 302);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// ─── HTML helper ──────────────────────────────────────────────────────────────
|
|
578
|
+
|
|
579
|
+
/** Renders an admin page body as an HTML Response (see createHtmlResponder). */
|
|
580
|
+
export type AdminHtmlResponder = (
|
|
581
|
+
content: string,
|
|
582
|
+
opts?: { includePwaTags?: boolean; status?: number },
|
|
583
|
+
) => Response;
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* The admin UI's HTML response helper, shared by the platform UI and extension
|
|
587
|
+
* UIs (AEP-3.2) so every admin page gets the same head tags and headers.
|
|
588
|
+
*/
|
|
589
|
+
export function createHtmlResponder(appBaseUrl: string): AdminHtmlResponder {
|
|
590
|
+
// Precomputed once per app instance (not per-request): the manifest link +
|
|
591
|
+
// SW-registration script, gated on appBaseUrl being https (see
|
|
592
|
+
// admin/src/pwa.ts's renderPwaHeadTags — "" when the gate fails, e.g. a
|
|
593
|
+
// home-lab operator on plain HTTP).
|
|
594
|
+
const pwaHeadTags = renderPwaHeadTags(appBaseUrl);
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Splices the PWA head tags in just before </head> so every rendered
|
|
598
|
+
* admin page gets the manifest link + SW registration site-wide, without
|
|
599
|
+
* threading appBaseUrl through every render*Page function. A no-op when
|
|
600
|
+
* pwaHeadTags is "" (non-https appBaseUrl).
|
|
601
|
+
*/
|
|
602
|
+
function injectPwaHeadTags(content: string): string {
|
|
603
|
+
if (!pwaHeadTags) return content;
|
|
604
|
+
return content.replace("</head>", ` ${pwaHeadTags}\n</head>`);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* `includePwaTags` defaults to true for every /admin/* page. The opt-out is
|
|
609
|
+
* for an extension's unauthenticated pages outside /admin/ — the manifest's
|
|
610
|
+
* scope is /admin/, and such a page must never reference /admin/ URLs.
|
|
611
|
+
*/
|
|
612
|
+
return (content, opts) => {
|
|
613
|
+
const body =
|
|
614
|
+
opts?.includePwaTags === false ? content : injectPwaHeadTags(content);
|
|
615
|
+
return new Response(body, {
|
|
616
|
+
status: opts?.status ?? 200,
|
|
617
|
+
headers: {
|
|
618
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
619
|
+
// CSP is out of scope (would need unsafe-inline today and buys
|
|
620
|
+
// nothing here) — these three are the low-risk, high-value headers
|
|
621
|
+
// for a server-rendered admin console.
|
|
622
|
+
"X-Content-Type-Options": "nosniff",
|
|
623
|
+
"Referrer-Policy": "same-origin",
|
|
624
|
+
"X-Frame-Options": "DENY",
|
|
625
|
+
},
|
|
626
|
+
});
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// ─── Agent access ─────────────────────────────────────────────────────────────
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Agent access checks shared by the platform UI and extension UIs (AEP-3.2).
|
|
634
|
+
* resolveAccessibleAgents: admins see the full fleet, non-admins only the
|
|
635
|
+
* agents in their caller scope (no ordering beyond what listAll()/listByIds()
|
|
636
|
+
* return). assertAgentAccess: admins always, others when the agent is in scope.
|
|
637
|
+
*/
|
|
638
|
+
export function createAgentAccessChecks(deps: {
|
|
639
|
+
agentService: Pick<AgentService, "listAll" | "listByIds">;
|
|
640
|
+
callerScopeResolver: CallerScopeResolver;
|
|
641
|
+
}) {
|
|
642
|
+
const { agentService, callerScopeResolver } = deps;
|
|
643
|
+
return {
|
|
644
|
+
async resolveAccessibleAgents(
|
|
645
|
+
userEmail: string,
|
|
646
|
+
isAdmin: boolean,
|
|
647
|
+
): Promise<Awaited<ReturnType<AgentService["listAll"]>>> {
|
|
648
|
+
if (isAdmin) {
|
|
649
|
+
return agentService.listAll();
|
|
650
|
+
}
|
|
651
|
+
const scope = await callerScopeResolver(userEmail, false);
|
|
652
|
+
return agentService.listByIds(scope.kind === "all" ? [] : scope.agentIds);
|
|
653
|
+
},
|
|
654
|
+
async assertAgentAccess(
|
|
655
|
+
agentId: string,
|
|
656
|
+
userEmail: string,
|
|
657
|
+
isAdmin: boolean,
|
|
658
|
+
): Promise<boolean> {
|
|
659
|
+
if (isAdmin) return true;
|
|
660
|
+
return scopeIncludesAgent(
|
|
661
|
+
await callerScopeResolver(userEmail, false),
|
|
662
|
+
agentId,
|
|
663
|
+
);
|
|
664
|
+
},
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// ─── App factory ──────────────────────────────────────────────────────────────
|
|
669
|
+
|
|
670
|
+
export function createAdminUIApp(deps: AdminUIDeps): Hono<AdminUIEnv> {
|
|
671
|
+
const {
|
|
672
|
+
prisma,
|
|
673
|
+
agentEnvService,
|
|
674
|
+
agentCronJobService,
|
|
675
|
+
agentToolService,
|
|
676
|
+
agentTokenService,
|
|
677
|
+
agentPluginService,
|
|
678
|
+
agentMemberService,
|
|
679
|
+
agentService,
|
|
680
|
+
provisioner,
|
|
681
|
+
selfServe,
|
|
682
|
+
accountService,
|
|
683
|
+
accountOnboarding,
|
|
684
|
+
accountCreatedNotifier,
|
|
685
|
+
accountServices,
|
|
686
|
+
specialtyRegistry = new SpecialtyRegistry(),
|
|
687
|
+
callerScopeResolver = memberOnlyCallerScopeResolver(agentMemberService),
|
|
688
|
+
serviceIntegrations,
|
|
689
|
+
chatService,
|
|
690
|
+
slack,
|
|
691
|
+
decrypt,
|
|
692
|
+
sessionSecret,
|
|
693
|
+
googleClient,
|
|
694
|
+
googleClientId,
|
|
695
|
+
googleClientSecret,
|
|
696
|
+
oktaClient,
|
|
697
|
+
oktaClientId = "",
|
|
698
|
+
oktaClientSecret = "",
|
|
699
|
+
oktaIssuer = "",
|
|
700
|
+
adminAllowedEmails,
|
|
701
|
+
slackClient,
|
|
702
|
+
githubAppClient,
|
|
703
|
+
appBaseUrl,
|
|
704
|
+
devAuthEnabled = false,
|
|
705
|
+
timezone = "America/Los_Angeles",
|
|
706
|
+
chatClient,
|
|
707
|
+
pwaAssetsDir = PWA_ASSETS_DIR,
|
|
708
|
+
appVersion = "0.0.0",
|
|
709
|
+
pushService,
|
|
710
|
+
vapidPublicKey = "",
|
|
711
|
+
pushWebhookToken,
|
|
712
|
+
} = deps;
|
|
713
|
+
|
|
714
|
+
// Push is enabled only when the service was constructed (VAPID fully set) AND
|
|
715
|
+
// a public key is available for the browser. A single derived flag so the
|
|
716
|
+
// toggle-render gate and the route-503 gate can't drift.
|
|
717
|
+
const pushEnabled = Boolean(pushService && vapidPublicKey);
|
|
718
|
+
|
|
719
|
+
const app = new Hono<AdminUIEnv>();
|
|
720
|
+
|
|
721
|
+
const requireAuth = createUIAuthMiddleware(sessionSecret);
|
|
722
|
+
|
|
723
|
+
// SSP-3.2: show the toolbar's "Account" entry only for signed-in users who
|
|
724
|
+
// belong to an account, and only while self-serve is on. The flag is carried
|
|
725
|
+
// in AsyncLocalStorage so renderAdminToolbar() needs no extra parameter.
|
|
726
|
+
const accountNavEnabled = Boolean(selfServe?.enabled && accountServices);
|
|
727
|
+
if (accountNavEnabled && accountServices) {
|
|
728
|
+
app.use("/admin/*", async (c, next) => {
|
|
729
|
+
const token = getCookie(c, SESSION_COOKIE);
|
|
730
|
+
const user = token ? await getSessionUser(token, sessionSecret) : null;
|
|
731
|
+
let showAccount = false;
|
|
732
|
+
if (user) {
|
|
733
|
+
try {
|
|
734
|
+
showAccount =
|
|
735
|
+
(await accountServices.accounts.getByMemberEmail(user.email)) !==
|
|
736
|
+
null;
|
|
737
|
+
} catch {
|
|
738
|
+
// nav is cosmetic — never fail the page over the lookup
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
return runWithAccountNav(showAccount, () => next());
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// Extracted Slack app-manifest-creation/OAuth/app-token orchestration
|
|
746
|
+
// (UAP-1.1) — shared by the legacy /admin/provision/* wizard's Slack
|
|
747
|
+
// branch and the new per-agent /admin/agents/:id/connect-slack routes so
|
|
748
|
+
// both reach the same outcome via the same code path.
|
|
749
|
+
const slackProvisioningService = new SlackProvisioningService({
|
|
750
|
+
slackClient,
|
|
751
|
+
agentService,
|
|
752
|
+
agentEnvService,
|
|
753
|
+
agentCronJobService,
|
|
754
|
+
sessionSecret,
|
|
755
|
+
appBaseUrl,
|
|
756
|
+
secretEnvVars: new Set(SECRET_ENV_VARS),
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
// Extracted GitHub App manifest-flow/PAT provisioning orchestration
|
|
760
|
+
// (UAP-1.2) — shared by the legacy /admin/provision/* wizard's GitHub
|
|
761
|
+
// branches (+ github-app/complete + github-app/installed) and the new
|
|
762
|
+
// per-agent /admin/agents/:id/connect-github routes so both reach the same
|
|
763
|
+
// outcome via the same code path.
|
|
764
|
+
const githubProvisioningService = new GithubProvisioningService({
|
|
765
|
+
githubAppClient,
|
|
766
|
+
agentService,
|
|
767
|
+
agentEnvService,
|
|
768
|
+
agentCronJobService,
|
|
769
|
+
sessionSecret,
|
|
770
|
+
appBaseUrl,
|
|
771
|
+
secretEnvVars: new Set(SECRET_ENV_VARS),
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
const html = createHtmlResponder(appBaseUrl);
|
|
775
|
+
|
|
776
|
+
// ─── Login / OAuth / Logout ───────────────────────────────────────────────
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* Shared post-authentication step for every OAuth provider (Google, Okta):
|
|
780
|
+
* checks the verified-email + admin-allowlist/AgentMember-fallback rules,
|
|
781
|
+
* mints the session JWT, sets the session cookie, and redirects to
|
|
782
|
+
* `returnTo` (or the default landing page). Each provider's callback route
|
|
783
|
+
* normalizes its own userinfo shape into this common `{sub, email,
|
|
784
|
+
* email_verified}` input before calling in — callers never see a
|
|
785
|
+
* provider-specific type here.
|
|
786
|
+
*
|
|
787
|
+
* Returns a Response in every case (a redirect or a 403), matching each
|
|
788
|
+
* inline early-return the Google callback used before this was extracted.
|
|
789
|
+
*/
|
|
790
|
+
async function completeLogin(
|
|
791
|
+
// biome-ignore lint/suspicious/noExplicitAny: matches each OAuth callback route's own path-string literal type; the path itself is irrelevant here.
|
|
792
|
+
c: Context<AdminUIEnv, any>,
|
|
793
|
+
userInfo: { sub: string; email?: string; email_verified?: boolean },
|
|
794
|
+
returnTo: string | undefined,
|
|
795
|
+
opts: { selfServeSignup?: boolean } = {},
|
|
796
|
+
): Promise<Response> {
|
|
797
|
+
if (!userInfo.email) {
|
|
798
|
+
return c.redirect("/admin/login?error=auth_failed", 302);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
if (!userInfo.email_verified) {
|
|
802
|
+
return c.redirect("/admin/login?error=auth_failed", 302);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// Check admin allowlist first, then fall back to member access
|
|
806
|
+
const isAdmin = adminAllowedEmails
|
|
807
|
+
.map((e) => e.toLowerCase())
|
|
808
|
+
.includes(userInfo.email.toLowerCase());
|
|
809
|
+
|
|
810
|
+
if (!isAdmin) {
|
|
811
|
+
const memberships = await agentMemberService.listByEmail(
|
|
812
|
+
userInfo.email.toLowerCase(),
|
|
813
|
+
);
|
|
814
|
+
if (memberships.length === 0) {
|
|
815
|
+
// Self-serve (SSP-3.1): a verified Google email with no AgentMember
|
|
816
|
+
// rows joins its invited account or gets a new one. Okta and flag-off
|
|
817
|
+
// keep the existing 403.
|
|
818
|
+
const onboarding = accountOnboarding;
|
|
819
|
+
if (!(opts.selfServeSignup && selfServe?.enabled && onboarding)) {
|
|
820
|
+
return new Response("Forbidden", { status: 403 });
|
|
821
|
+
}
|
|
822
|
+
const email = userInfo.email.toLowerCase();
|
|
823
|
+
let result: Awaited<ReturnType<typeof onboarding.provisionForEmail>>;
|
|
824
|
+
try {
|
|
825
|
+
result = await onboarding.provisionForEmail(email);
|
|
826
|
+
} catch (err) {
|
|
827
|
+
console.error("[admin] self-serve account provisioning failed", err);
|
|
828
|
+
return c.redirect("/admin/login?error=server_error", 302);
|
|
829
|
+
}
|
|
830
|
+
if (result.kind !== "existing") {
|
|
831
|
+
console.log(
|
|
832
|
+
JSON.stringify({
|
|
833
|
+
event: `account_${result.kind}`,
|
|
834
|
+
accountId: result.accountId,
|
|
835
|
+
emailDomain: email.slice(email.lastIndexOf("@") + 1),
|
|
836
|
+
}),
|
|
837
|
+
);
|
|
838
|
+
}
|
|
839
|
+
// Only a brand-new account notifies; invite-joins do not.
|
|
840
|
+
if (result.kind === "created") {
|
|
841
|
+
fireAccountCreatedNotification(accountCreatedNotifier, {
|
|
842
|
+
accountId: result.accountId,
|
|
843
|
+
emailDomain: email.slice(email.lastIndexOf("@") + 1),
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// Create session
|
|
850
|
+
const token = await createSessionToken(
|
|
851
|
+
sessionSecret,
|
|
852
|
+
userInfo.sub,
|
|
853
|
+
userInfo.email,
|
|
854
|
+
isAdmin,
|
|
855
|
+
);
|
|
856
|
+
setCookie(c, SESSION_COOKIE, token, {
|
|
857
|
+
httpOnly: true,
|
|
858
|
+
secure: appBaseUrl.startsWith("https://"),
|
|
859
|
+
sameSite: "Lax",
|
|
860
|
+
maxAge: SESSION_TTL_SECONDS,
|
|
861
|
+
path: "/",
|
|
862
|
+
});
|
|
863
|
+
// Redirect to returnTo if it's a valid same-origin relative path, otherwise default.
|
|
864
|
+
const destination =
|
|
865
|
+
returnTo?.startsWith("/") && !returnTo.startsWith("//")
|
|
866
|
+
? returnTo
|
|
867
|
+
: "/admin/agents";
|
|
868
|
+
return c.redirect(destination, 302);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* Shared OAuth-start step for every provider (Google, Okta): mints a CSRF
|
|
873
|
+
* nonce, validates `returnTo` as a same-origin relative path (dropping
|
|
874
|
+
* malformed/absolute values), and stores both in the oauth_state cookie.
|
|
875
|
+
*/
|
|
876
|
+
function beginOAuthFlow(
|
|
877
|
+
// biome-ignore lint/suspicious/noExplicitAny: matches each OAuth start route's own path-string literal type; the path itself is irrelevant here.
|
|
878
|
+
c: Context<AdminUIEnv, any>,
|
|
879
|
+
): { nonce: string; returnTo: string | undefined } {
|
|
880
|
+
const nonce = crypto.randomUUID();
|
|
881
|
+
|
|
882
|
+
const rawReturnTo = c.req.query("returnTo");
|
|
883
|
+
const returnTo =
|
|
884
|
+
rawReturnTo?.startsWith("/") && !rawReturnTo.startsWith("//")
|
|
885
|
+
? rawReturnTo
|
|
886
|
+
: undefined;
|
|
887
|
+
|
|
888
|
+
const oauthState = JSON.stringify({ nonce, returnTo });
|
|
889
|
+
setCookie(c, OAUTH_STATE_COOKIE, oauthState, {
|
|
890
|
+
httpOnly: true,
|
|
891
|
+
sameSite: "Lax",
|
|
892
|
+
maxAge: OAUTH_STATE_TTL_SECONDS,
|
|
893
|
+
path: "/admin/auth",
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
return { nonce, returnTo };
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* Shared OAuth-callback CSRF check for every provider: reads + clears the
|
|
901
|
+
* oauth_state cookie, and confirms its nonce matches the `state` query
|
|
902
|
+
* param. Returns the carried `returnTo` on success, or `null` if the
|
|
903
|
+
* cookie is missing/malformed or the nonce doesn't match.
|
|
904
|
+
*/
|
|
905
|
+
function validateOAuthState(
|
|
906
|
+
// biome-ignore lint/suspicious/noExplicitAny: matches each OAuth callback route's own path-string literal type; the path itself is irrelevant here.
|
|
907
|
+
c: Context<AdminUIEnv, any>,
|
|
908
|
+
state: string | undefined,
|
|
909
|
+
): { returnTo: string | undefined } | null {
|
|
910
|
+
const storedStateCookie = getCookie(c, OAUTH_STATE_COOKIE);
|
|
911
|
+
deleteCookie(c, OAUTH_STATE_COOKIE, { path: "/admin/auth" });
|
|
912
|
+
|
|
913
|
+
let storedNonce: string | undefined;
|
|
914
|
+
let returnTo: string | undefined;
|
|
915
|
+
try {
|
|
916
|
+
if (storedStateCookie) {
|
|
917
|
+
const parsed = JSON.parse(storedStateCookie) as {
|
|
918
|
+
nonce?: string;
|
|
919
|
+
returnTo?: string;
|
|
920
|
+
};
|
|
921
|
+
storedNonce = parsed.nonce;
|
|
922
|
+
returnTo = parsed.returnTo;
|
|
923
|
+
}
|
|
924
|
+
} catch {
|
|
925
|
+
// Malformed cookie — treat as missing
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
if (!storedNonce || !state || storedNonce !== state) {
|
|
929
|
+
return null;
|
|
930
|
+
}
|
|
931
|
+
return { returnTo };
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// ─── PWA shell (manifest, service worker, icons, offline page) ───────────
|
|
935
|
+
//
|
|
936
|
+
// Deliberately unauthenticated (no requireAuth) — this is required, not a
|
|
937
|
+
// shortcut. Browsers fetch the manifest and service worker with
|
|
938
|
+
// credentials:omit, so gating either behind the session cookie would 302
|
|
939
|
+
// to /admin/login and silently break install. Neither route leaks
|
|
940
|
+
// secrets: the manifest is static config, and the SW's own caching
|
|
941
|
+
// predicate (admin/src/pwa.ts) refuses every authenticated document/JSON
|
|
942
|
+
// route by construction.
|
|
943
|
+
|
|
944
|
+
app.get("/admin/manifest.webmanifest", (c) => {
|
|
945
|
+
// PWA-1.1: renderPwaHeadTags rewrites the manifest link's href with
|
|
946
|
+
// ?start=<current pathname> on every page, so "Add to Home Screen"
|
|
947
|
+
// installs a shortcut back into whichever page the user was on.
|
|
948
|
+
// sanitizeStartUrl always returns a safe value (defaulting to
|
|
949
|
+
// /admin/chat), so this is safe to call unconditionally even when the
|
|
950
|
+
// query param is absent, malformed, or malicious.
|
|
951
|
+
const startUrl = sanitizeStartUrl(c.req.query("start"));
|
|
952
|
+
return c.json(buildManifest(startUrl), 200, {
|
|
953
|
+
"Content-Type": "application/manifest+json",
|
|
954
|
+
});
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
app.get("/admin/sw.js", (_c) => {
|
|
958
|
+
const body = buildServiceWorkerBody(appVersion, getPrecacheList());
|
|
959
|
+
return new Response(body, {
|
|
960
|
+
headers: {
|
|
961
|
+
"Content-Type": "application/javascript; charset=utf-8",
|
|
962
|
+
// A cached service worker is unupdatable — browsers must always
|
|
963
|
+
// revalidate this file so a version bump's new cache name (and any
|
|
964
|
+
// precache-list change) actually takes effect.
|
|
965
|
+
"Cache-Control": "no-cache",
|
|
966
|
+
},
|
|
967
|
+
});
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
app.get("/admin/offline.html", (_c) => {
|
|
971
|
+
return new Response(buildOfflinePageHtml(), {
|
|
972
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
973
|
+
});
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
const PWA_ICON_FILENAMES = new Set(PWA_ICONS.map((icon) => icon.filename));
|
|
977
|
+
|
|
978
|
+
app.get("/admin/icons/:filename", async (c) => {
|
|
979
|
+
const filename = c.req.param("filename");
|
|
980
|
+
if (!PWA_ICON_FILENAMES.has(filename)) {
|
|
981
|
+
return c.json({ error: "Not found" }, 404);
|
|
982
|
+
}
|
|
983
|
+
try {
|
|
984
|
+
const body = await Bun.file(
|
|
985
|
+
join(pwaAssetsDir, "icons", filename),
|
|
986
|
+
).arrayBuffer();
|
|
987
|
+
return new Response(body, {
|
|
988
|
+
headers: {
|
|
989
|
+
"Content-Type": "image/png",
|
|
990
|
+
"Cache-Control": "public, max-age=86400",
|
|
991
|
+
},
|
|
992
|
+
});
|
|
993
|
+
} catch (err) {
|
|
994
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
995
|
+
return c.json({ error: "Not found" }, 404);
|
|
996
|
+
}
|
|
997
|
+
console.error(`[admin-ui] PWA icon read error [${filename}]:`, err);
|
|
998
|
+
return c.json({ error: "Internal server error" }, 500);
|
|
999
|
+
}
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
app.get("/admin/login", (c) => {
|
|
1003
|
+
const error = c.req.query("error") ?? undefined;
|
|
1004
|
+
const returnTo = c.req.query("returnTo") ?? undefined;
|
|
1005
|
+
const googleEnabled = Boolean(googleClientId && googleClientSecret);
|
|
1006
|
+
const oktaEnabled = Boolean(oktaClientId && oktaIssuer && oktaClient);
|
|
1007
|
+
return html(
|
|
1008
|
+
renderLoginPage({ error, returnTo, googleEnabled, oktaEnabled }),
|
|
1009
|
+
);
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
app.get("/admin/auth/google", (c) => {
|
|
1013
|
+
if (!googleClientId) {
|
|
1014
|
+
return c.redirect("/admin/login?error=server_error", 302);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
const { nonce } = beginOAuthFlow(c);
|
|
1018
|
+
|
|
1019
|
+
const params = new URLSearchParams({
|
|
1020
|
+
client_id: googleClientId,
|
|
1021
|
+
redirect_uri: `${appBaseUrl}/admin/auth/callback`,
|
|
1022
|
+
response_type: "code",
|
|
1023
|
+
scope: "openid profile email",
|
|
1024
|
+
state: nonce,
|
|
1025
|
+
prompt: "select_account",
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
return c.redirect(`${GOOGLE_AUTH_URL}?${params.toString()}`, 302);
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
app.get("/admin/auth/callback", async (c) => {
|
|
1032
|
+
const { code, state, error: googleError } = c.req.query();
|
|
1033
|
+
|
|
1034
|
+
// Google returned an error (e.g. access_denied)
|
|
1035
|
+
if (googleError) {
|
|
1036
|
+
const slug =
|
|
1037
|
+
googleError === "access_denied" ? "access_denied" : "auth_failed";
|
|
1038
|
+
return c.redirect(`/admin/login?error=${slug}`, 302);
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// CSRF: validate state nonce. The oauth_state cookie is JSON: {nonce, returnTo?}.
|
|
1042
|
+
const validated = validateOAuthState(c, state);
|
|
1043
|
+
if (!validated) {
|
|
1044
|
+
return c.redirect("/admin/login?error=invalid_state", 302);
|
|
1045
|
+
}
|
|
1046
|
+
const { returnTo } = validated;
|
|
1047
|
+
|
|
1048
|
+
if (!googleClientId || !googleClientSecret) {
|
|
1049
|
+
return c.redirect("/admin/login?error=server_error", 302);
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
if (!code) {
|
|
1053
|
+
return c.redirect("/admin/login?error=auth_failed", 302);
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
// Exchange authorization code for tokens
|
|
1057
|
+
let accessToken: string;
|
|
1058
|
+
try {
|
|
1059
|
+
const tokens = await googleClient.exchangeCode({
|
|
1060
|
+
code,
|
|
1061
|
+
clientId: googleClientId,
|
|
1062
|
+
clientSecret: googleClientSecret,
|
|
1063
|
+
redirectUri: `${appBaseUrl}/admin/auth/callback`,
|
|
1064
|
+
});
|
|
1065
|
+
accessToken = tokens.accessToken;
|
|
1066
|
+
} catch {
|
|
1067
|
+
return c.redirect("/admin/login?error=auth_failed", 302);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
// Fetch user info from Google
|
|
1071
|
+
let userInfo: {
|
|
1072
|
+
sub: string;
|
|
1073
|
+
email?: string;
|
|
1074
|
+
email_verified?: boolean;
|
|
1075
|
+
name: string;
|
|
1076
|
+
};
|
|
1077
|
+
try {
|
|
1078
|
+
userInfo = await googleClient.getUserInfo(accessToken);
|
|
1079
|
+
} catch {
|
|
1080
|
+
return c.redirect("/admin/login?error=auth_failed", 302);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
return completeLogin(c, userInfo, returnTo, { selfServeSignup: true });
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
app.get("/admin/auth/okta", (c) => {
|
|
1087
|
+
if (!oktaClientId || !oktaIssuer || !oktaClient) {
|
|
1088
|
+
return c.redirect("/admin/login?error=server_error", 302);
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
const { nonce } = beginOAuthFlow(c);
|
|
1092
|
+
|
|
1093
|
+
const authUrl = oktaClient.getAuthorizationUrl({
|
|
1094
|
+
clientId: oktaClientId,
|
|
1095
|
+
redirectUri: `${appBaseUrl}/admin/auth/okta/callback`,
|
|
1096
|
+
state: nonce,
|
|
1097
|
+
scope: "openid profile email",
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
return c.redirect(authUrl, 302);
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1103
|
+
app.get("/admin/auth/okta/callback", async (c) => {
|
|
1104
|
+
const { code, state, error: oktaError } = c.req.query();
|
|
1105
|
+
|
|
1106
|
+
// Okta returned an error (e.g. access_denied)
|
|
1107
|
+
if (oktaError) {
|
|
1108
|
+
const slug =
|
|
1109
|
+
oktaError === "access_denied" ? "access_denied" : "auth_failed";
|
|
1110
|
+
return c.redirect(`/admin/login?error=${slug}`, 302);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
// CSRF: validate state nonce. The oauth_state cookie is JSON: {nonce, returnTo?}.
|
|
1114
|
+
const validated = validateOAuthState(c, state);
|
|
1115
|
+
if (!validated) {
|
|
1116
|
+
return c.redirect("/admin/login?error=invalid_state", 302);
|
|
1117
|
+
}
|
|
1118
|
+
const { returnTo } = validated;
|
|
1119
|
+
|
|
1120
|
+
if (!oktaClientId || !oktaClientSecret || !oktaIssuer || !oktaClient) {
|
|
1121
|
+
return c.redirect("/admin/login?error=server_error", 302);
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
if (!code) {
|
|
1125
|
+
return c.redirect("/admin/login?error=auth_failed", 302);
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// Exchange authorization code for tokens
|
|
1129
|
+
let accessToken: string;
|
|
1130
|
+
try {
|
|
1131
|
+
const tokens = await oktaClient.exchangeCode({
|
|
1132
|
+
code,
|
|
1133
|
+
clientId: oktaClientId,
|
|
1134
|
+
clientSecret: oktaClientSecret,
|
|
1135
|
+
redirectUri: `${appBaseUrl}/admin/auth/okta/callback`,
|
|
1136
|
+
});
|
|
1137
|
+
accessToken = tokens.accessToken;
|
|
1138
|
+
} catch {
|
|
1139
|
+
return c.redirect("/admin/login?error=auth_failed", 302);
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
// Fetch user info from Okta
|
|
1143
|
+
let userInfo: {
|
|
1144
|
+
sub: string;
|
|
1145
|
+
email?: string;
|
|
1146
|
+
email_verified?: boolean;
|
|
1147
|
+
name: string;
|
|
1148
|
+
picture?: string;
|
|
1149
|
+
};
|
|
1150
|
+
try {
|
|
1151
|
+
userInfo = await oktaClient.getUserInfo(accessToken);
|
|
1152
|
+
} catch {
|
|
1153
|
+
return c.redirect("/admin/login?error=auth_failed", 302);
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
return completeLogin(c, userInfo, returnTo);
|
|
1157
|
+
});
|
|
1158
|
+
|
|
1159
|
+
app.post("/admin/logout", (c) => {
|
|
1160
|
+
deleteCookie(c, SESSION_COOKIE, { path: "/" });
|
|
1161
|
+
return c.redirect("/admin/login", 302);
|
|
1162
|
+
});
|
|
1163
|
+
|
|
1164
|
+
// ─── Dev auto-login (non-prod only) ──────────────────────────────────────
|
|
1165
|
+
|
|
1166
|
+
app.get("/admin/dev-login", async (c) => {
|
|
1167
|
+
// Hard-blocked: devAuthEnabled must be true AND we must not be in production.
|
|
1168
|
+
// The devAuthEnabled flag is pre-computed from isDevAuthAllowed() at startup;
|
|
1169
|
+
// this route simply trusts the injected value.
|
|
1170
|
+
if (!devAuthEnabled) {
|
|
1171
|
+
return new Response("Not Found", { status: 404 });
|
|
1172
|
+
}
|
|
1173
|
+
const token = await createSessionToken(
|
|
1174
|
+
sessionSecret,
|
|
1175
|
+
"dev",
|
|
1176
|
+
"dev@localhost",
|
|
1177
|
+
true,
|
|
1178
|
+
);
|
|
1179
|
+
setCookie(c, SESSION_COOKIE, token, {
|
|
1180
|
+
httpOnly: true,
|
|
1181
|
+
secure: appBaseUrl.startsWith("https://"),
|
|
1182
|
+
sameSite: "Lax",
|
|
1183
|
+
maxAge: SESSION_TTL_SECONDS,
|
|
1184
|
+
path: "/",
|
|
1185
|
+
});
|
|
1186
|
+
return c.redirect("/admin/agents", 302);
|
|
1187
|
+
});
|
|
1188
|
+
|
|
1189
|
+
// ─── Agents list ──────────────────────────────────────────────────────────
|
|
1190
|
+
|
|
1191
|
+
// Access-scoping for the agents list (and extension views via the same
|
|
1192
|
+
// createAgentAccessChecks helper).
|
|
1193
|
+
const { resolveAccessibleAgents, assertAgentAccess } =
|
|
1194
|
+
createAgentAccessChecks({ agentService, callerScopeResolver });
|
|
1195
|
+
|
|
1196
|
+
app.get("/admin/agents", requireAuth, async (c) => {
|
|
1197
|
+
const agents = await resolveAccessibleAgents(
|
|
1198
|
+
c.var.userEmail,
|
|
1199
|
+
c.var.isAdmin,
|
|
1200
|
+
);
|
|
1201
|
+
const successMsg =
|
|
1202
|
+
c.req.query("success") === "deleted" ? "Agent deleted." : undefined;
|
|
1203
|
+
// Surfaced by POST /admin/agents/:id/delete after a successful
|
|
1204
|
+
// deleteAgentFully() call — operator-facing reminders that would
|
|
1205
|
+
// otherwise be silently dropped on the redirect to this list.
|
|
1206
|
+
const manualStepsRaw = c.req.query("manualSteps");
|
|
1207
|
+
let manualSteps: ManualStep[] | undefined;
|
|
1208
|
+
if (manualStepsRaw) {
|
|
1209
|
+
try {
|
|
1210
|
+
const parsed = JSON.parse(manualStepsRaw);
|
|
1211
|
+
if (Array.isArray(parsed)) manualSteps = parsed;
|
|
1212
|
+
} catch {
|
|
1213
|
+
// malformed/tampered query param — render without the panel
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
// SSP-3.2: a zero-quota account sees a request-a-trial message and a
|
|
1217
|
+
// disabled create button instead of the create CTA.
|
|
1218
|
+
let zeroQuotaContactEmail: string | undefined;
|
|
1219
|
+
if (accountNavEnabled && accountServices && selfServe) {
|
|
1220
|
+
const account = await accountServices.accounts.getByMemberEmail(
|
|
1221
|
+
c.var.userEmail,
|
|
1222
|
+
);
|
|
1223
|
+
if (account && account.maxAgents === 0) {
|
|
1224
|
+
zeroQuotaContactEmail = selfServe.contactEmail;
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
// SSP-5.2: admins get an Account column (flag on only).
|
|
1228
|
+
let accountNames: Map<string, string> | undefined;
|
|
1229
|
+
if (c.var.isAdmin && accountNavEnabled && accountServices) {
|
|
1230
|
+
accountNames = new Map(
|
|
1231
|
+
(await accountServices.accounts.listWithCounts()).map((a) => [
|
|
1232
|
+
a.id,
|
|
1233
|
+
a.name,
|
|
1234
|
+
]),
|
|
1235
|
+
);
|
|
1236
|
+
}
|
|
1237
|
+
return html(
|
|
1238
|
+
renderAgentsPage(agents, c.var.userEmail, c.var.isAdmin, timezone, {
|
|
1239
|
+
accountNames,
|
|
1240
|
+
queueActivityLinks: deps.queueActivityLinks,
|
|
1241
|
+
successMsg,
|
|
1242
|
+
manualSteps,
|
|
1243
|
+
zeroQuotaNotice: zeroQuotaContactEmail
|
|
1244
|
+
? renderZeroQuotaNotice(zeroQuotaContactEmail)
|
|
1245
|
+
: undefined,
|
|
1246
|
+
}),
|
|
1247
|
+
);
|
|
1248
|
+
});
|
|
1249
|
+
|
|
1250
|
+
// ─── Agent detail ─────────────────────────────────────────────────────────
|
|
1251
|
+
|
|
1252
|
+
/**
|
|
1253
|
+
* SSP-4.3: with self-serve on, anyone who passes assertAgentAccess for an
|
|
1254
|
+
* agent (owner or member) fully controls it — settings, members, delete.
|
|
1255
|
+
* Flag off: admin-only, so existing platform AgentMembers gain no power.
|
|
1256
|
+
*/
|
|
1257
|
+
const canManageAgents = (isAdmin: boolean): boolean =>
|
|
1258
|
+
isAdmin || deps.selfServe?.enabled === true;
|
|
1259
|
+
|
|
1260
|
+
async function assertCanManageAgent(
|
|
1261
|
+
agentId: string,
|
|
1262
|
+
userEmail: string,
|
|
1263
|
+
isAdmin: boolean,
|
|
1264
|
+
): Promise<boolean> {
|
|
1265
|
+
if (isAdmin) return true;
|
|
1266
|
+
if (!canManageAgents(false)) return false;
|
|
1267
|
+
return assertAgentAccess(agentId, userEmail, false);
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
const ERROR_MESSAGES: Record<string, string> = {
|
|
1271
|
+
missing_fields: "Required fields are missing.",
|
|
1272
|
+
create_failed: "Failed to create — please try again.",
|
|
1273
|
+
invalid_schedule: "Invalid cron schedule expression.",
|
|
1274
|
+
invalid_target:
|
|
1275
|
+
"Invalid delivery target — set channel or user (or enable silent mode).",
|
|
1276
|
+
invalid_repo_format:
|
|
1277
|
+
"Repo must be in org/repo format (e.g. my-org/my-repo).",
|
|
1278
|
+
invalid_author_allowlist_format:
|
|
1279
|
+
"Author allowlist entries must be valid GitHub logins.",
|
|
1280
|
+
invalid_type: "Select a valid specialty.",
|
|
1281
|
+
provisioning_disabled:
|
|
1282
|
+
"In-cluster provisioning is not enabled on this admin service — create a self-hosted agent instead.",
|
|
1283
|
+
provision_failed:
|
|
1284
|
+
"Failed to provision the agent's cluster resources — the agent was not created.",
|
|
1285
|
+
quota_exceeded: "This account has reached its agent limit.",
|
|
1286
|
+
account_inactive: "This account is not active.",
|
|
1287
|
+
...deps.agentDetailErrorMessages,
|
|
1288
|
+
};
|
|
1289
|
+
|
|
1290
|
+
// ─── New agent form (MUST be before /:id to avoid "new" being captured as param)
|
|
1291
|
+
|
|
1292
|
+
// Account the caller may create agents in (SSP-4.2): null for platform
|
|
1293
|
+
// admins (unassigned agents, today's behavior) and for non-admins without an
|
|
1294
|
+
// account (flag off / no membership) — the latter are denied by the callers.
|
|
1295
|
+
async function resolveCreateAccountId(
|
|
1296
|
+
userEmail: string,
|
|
1297
|
+
isAdmin: boolean,
|
|
1298
|
+
): Promise<string | null> {
|
|
1299
|
+
if (isAdmin) return null;
|
|
1300
|
+
return accountIdFromScope(await callerScopeResolver(userEmail, false));
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
app.get("/admin/agents/new", requireAuth, async (c) => {
|
|
1304
|
+
const accountId = await resolveCreateAccountId(
|
|
1305
|
+
c.var.userEmail,
|
|
1306
|
+
c.var.isAdmin,
|
|
1307
|
+
);
|
|
1308
|
+
if (!c.var.isAdmin && accountId === null) {
|
|
1309
|
+
return new Response("Forbidden", { status: 403 });
|
|
1310
|
+
}
|
|
1311
|
+
let selfServeOpts:
|
|
1312
|
+
| { remaining: number; max: number; contactEmail: string }
|
|
1313
|
+
| undefined;
|
|
1314
|
+
if (accountId !== null && accountService) {
|
|
1315
|
+
const [account, count] = await Promise.all([
|
|
1316
|
+
accountService.getById(accountId),
|
|
1317
|
+
accountService.countAgents(accountId),
|
|
1318
|
+
]);
|
|
1319
|
+
selfServeOpts = {
|
|
1320
|
+
remaining: Math.max(0, (account?.maxAgents ?? 0) - count),
|
|
1321
|
+
max: account?.maxAgents ?? 0,
|
|
1322
|
+
contactEmail:
|
|
1323
|
+
selfServe?.contactEmail ?? DEFAULT_SELF_SERVE_CONTACT_EMAIL,
|
|
1324
|
+
};
|
|
1325
|
+
}
|
|
1326
|
+
const rawError = c.req.query("error") ?? undefined;
|
|
1327
|
+
const error = rawError ? (ERROR_MESSAGES[rawError] ?? rawError) : undefined;
|
|
1328
|
+
const types = specialtyRegistry.listTypes();
|
|
1329
|
+
return html(
|
|
1330
|
+
renderNewLocalAgentPage(c.var.userEmail, types, {
|
|
1331
|
+
error,
|
|
1332
|
+
canProvision: provisioner.canProvision,
|
|
1333
|
+
anthropicFederationEnabled: deps.anthropicFederationEnabled,
|
|
1334
|
+
...(selfServeOpts ? { selfServe: selfServeOpts } : {}),
|
|
1335
|
+
}),
|
|
1336
|
+
);
|
|
1337
|
+
});
|
|
1338
|
+
|
|
1339
|
+
// ─── Create agent (self-hosted or provisioned in-cluster) ────────────────
|
|
1340
|
+
|
|
1341
|
+
app.post("/admin/agents", requireAuth, async (c) => {
|
|
1342
|
+
const accountId = await resolveCreateAccountId(
|
|
1343
|
+
c.var.userEmail,
|
|
1344
|
+
c.var.isAdmin,
|
|
1345
|
+
);
|
|
1346
|
+
if (!c.var.isAdmin && accountId === null) {
|
|
1347
|
+
return new Response("Forbidden", { status: 403 });
|
|
1348
|
+
}
|
|
1349
|
+
let name: string | undefined;
|
|
1350
|
+
let typeName: string | undefined;
|
|
1351
|
+
let reposRaw: string | undefined;
|
|
1352
|
+
let authorAllowlistRaw: string | undefined;
|
|
1353
|
+
let patchAuthorAllowlistRaw: string | undefined;
|
|
1354
|
+
let memberEmailsRaw: string | undefined;
|
|
1355
|
+
let restrictSlackToMembersRaw: string | undefined;
|
|
1356
|
+
let runtime: string | undefined;
|
|
1357
|
+
let claudeCodeOauthToken: string | undefined;
|
|
1358
|
+
let anthropicApiKey: string | undefined;
|
|
1359
|
+
let connectSlackRaw: string | undefined;
|
|
1360
|
+
let xoxpToken: string | undefined;
|
|
1361
|
+
let ghAuthMode: string | undefined;
|
|
1362
|
+
let ghPat: string | undefined;
|
|
1363
|
+
let githubOrg: string | undefined;
|
|
1364
|
+
let ghAppMode: string | undefined;
|
|
1365
|
+
let ghAppId: string | undefined;
|
|
1366
|
+
let ghAppInstallationId: string | undefined;
|
|
1367
|
+
let ghAppPrivateKey: string | undefined;
|
|
1368
|
+
try {
|
|
1369
|
+
const formData = await c.req.formData();
|
|
1370
|
+
name = formData.get("name")?.toString()?.trim();
|
|
1371
|
+
typeName = formData.get("type")?.toString()?.trim();
|
|
1372
|
+
reposRaw = formData.get("repos")?.toString()?.trim();
|
|
1373
|
+
authorAllowlistRaw = formData.get("authorAllowlist")?.toString()?.trim();
|
|
1374
|
+
patchAuthorAllowlistRaw = formData
|
|
1375
|
+
.get("patchAuthorAllowlist")
|
|
1376
|
+
?.toString()
|
|
1377
|
+
?.trim();
|
|
1378
|
+
memberEmailsRaw = formData.get("memberEmails")?.toString()?.trim();
|
|
1379
|
+
restrictSlackToMembersRaw = formData
|
|
1380
|
+
.get("restrictSlackToMembers")
|
|
1381
|
+
?.toString();
|
|
1382
|
+
runtime = formData.get("runtime")?.toString()?.trim();
|
|
1383
|
+
claudeCodeOauthToken = formData
|
|
1384
|
+
.get("claudeCodeOauthToken")
|
|
1385
|
+
?.toString()
|
|
1386
|
+
?.trim();
|
|
1387
|
+
anthropicApiKey = formData.get("anthropicApiKey")?.toString()?.trim();
|
|
1388
|
+
connectSlackRaw = formData.get("connectSlack")?.toString();
|
|
1389
|
+
xoxpToken = formData.get("xoxpToken")?.toString();
|
|
1390
|
+
ghAuthMode = formData.get("ghAuthMode")?.toString()?.trim();
|
|
1391
|
+
ghPat = formData.get("ghPat")?.toString();
|
|
1392
|
+
githubOrg = formData.get("githubOrg")?.toString()?.trim();
|
|
1393
|
+
ghAppMode = formData.get("ghAppMode")?.toString()?.trim();
|
|
1394
|
+
ghAppId = formData.get("ghAppId")?.toString();
|
|
1395
|
+
ghAppInstallationId = formData.get("ghAppInstallationId")?.toString();
|
|
1396
|
+
// UAP-5.3: the "Use existing GitHub App" option submits the private
|
|
1397
|
+
// key as a file upload rather than pasted text — read its contents
|
|
1398
|
+
// server-side, mirroring the per-agent connect-github route.
|
|
1399
|
+
const ghAppPrivateKeyFile = formData.get("ghAppPrivateKeyFile");
|
|
1400
|
+
ghAppPrivateKey =
|
|
1401
|
+
ghAppPrivateKeyFile instanceof File
|
|
1402
|
+
? await ghAppPrivateKeyFile.text()
|
|
1403
|
+
: undefined;
|
|
1404
|
+
} catch {
|
|
1405
|
+
return c.redirect("/admin/agents/new", 302);
|
|
1406
|
+
}
|
|
1407
|
+
const connectSlack = connectSlackRaw === "true";
|
|
1408
|
+
// SSP-4.2: account users always create "coding" agents in their own
|
|
1409
|
+
// account — any submitted type/accountId is ignored.
|
|
1410
|
+
if (accountId !== null) typeName = getSelfServeSpecialty();
|
|
1411
|
+
// Absent/unrecognized runtime means self-hosted — the historical behavior
|
|
1412
|
+
// of this form. createAgent() below re-derives this same value itself
|
|
1413
|
+
// (it owns the runtime/provisioning validation); it's recomputed here
|
|
1414
|
+
// too only to gate the UAP-2.1 Slack/GitHub connect block further down,
|
|
1415
|
+
// which runs after createAgent() returns and has no other way to learn
|
|
1416
|
+
// whether this agent is in-cluster.
|
|
1417
|
+
const inCluster = runtime === "in-cluster";
|
|
1418
|
+
|
|
1419
|
+
// APA-1.1: the entire create-agent sequence (validate name/typeName,
|
|
1420
|
+
// create the Agent row, seed AgentTool/AgentPlugin from the type
|
|
1421
|
+
// manifest, attach repos/allowlists/members, patch Claude credentials,
|
|
1422
|
+
// provision Kubernetes when requested, best-effort reconcile system
|
|
1423
|
+
// crons) lives in createAgent() (agents.ts) as the single implementation
|
|
1424
|
+
// of agent creation. Every failure mode redirects to the same
|
|
1425
|
+
// `/admin/agents/new?error=<code>` shape the inline handler used to
|
|
1426
|
+
// build itself, so `result.errorCode` maps directly into the query
|
|
1427
|
+
// param.
|
|
1428
|
+
const result = await createAgent(
|
|
1429
|
+
{
|
|
1430
|
+
// Bind each method createAgent() needs (CreateAgentDeps.agentService
|
|
1431
|
+
// = create | delete | updateFields | runTransaction) explicitly
|
|
1432
|
+
// rather than spreading `agentService`: in production this is a real
|
|
1433
|
+
// `new AgentService(prisma)` whose methods live on
|
|
1434
|
+
// AgentService.prototype, and object spread copies only own
|
|
1435
|
+
// enumerable properties — a spread would silently drop
|
|
1436
|
+
// create/delete/updateFields and throw at the first call.
|
|
1437
|
+
agentService: {
|
|
1438
|
+
create: agentService.create.bind(agentService),
|
|
1439
|
+
delete: agentService.delete.bind(agentService),
|
|
1440
|
+
updateFields: agentService.updateFields.bind(agentService),
|
|
1441
|
+
// Test doubles for routes unrelated to agent creation don't
|
|
1442
|
+
// implement runTransaction (see AdminUIDeps.agentService above) —
|
|
1443
|
+
// fall back to a non-transactional passthrough so createAgent()
|
|
1444
|
+
// always has a concrete implementation to call. Production always
|
|
1445
|
+
// supplies the real AgentService method.
|
|
1446
|
+
runTransaction:
|
|
1447
|
+
agentService.runTransaction?.bind(agentService) ??
|
|
1448
|
+
(<T>(fn: (tx: PrismaTransactionClient) => Promise<T>) =>
|
|
1449
|
+
fn(undefined as unknown as PrismaTransactionClient)),
|
|
1450
|
+
},
|
|
1451
|
+
agentToolService,
|
|
1452
|
+
agentPluginService,
|
|
1453
|
+
agentMemberService,
|
|
1454
|
+
agentEnvService,
|
|
1455
|
+
agentCronJobService,
|
|
1456
|
+
provisioner,
|
|
1457
|
+
specialtyRegistry,
|
|
1458
|
+
anthropicFederationEnabled: deps.anthropicFederationEnabled,
|
|
1459
|
+
},
|
|
1460
|
+
{
|
|
1461
|
+
name,
|
|
1462
|
+
typeName,
|
|
1463
|
+
runtime,
|
|
1464
|
+
reposRaw,
|
|
1465
|
+
authorAllowlistRaw,
|
|
1466
|
+
patchAuthorAllowlistRaw,
|
|
1467
|
+
memberEmailsRaw,
|
|
1468
|
+
restrictSlackToMembersRaw,
|
|
1469
|
+
claudeCodeOauthToken,
|
|
1470
|
+
anthropicApiKey,
|
|
1471
|
+
...(accountId !== null ? { accountId } : {}),
|
|
1472
|
+
// PAI-1.2: owner is the authenticated session user, never form data.
|
|
1473
|
+
ownerEmail: c.var.userEmail,
|
|
1474
|
+
},
|
|
1475
|
+
);
|
|
1476
|
+
if (!result.ok) {
|
|
1477
|
+
return c.redirect(
|
|
1478
|
+
`/admin/agents/new?error=${encodeURIComponent(result.errorMessage ?? result.errorCode)}`,
|
|
1479
|
+
302,
|
|
1480
|
+
);
|
|
1481
|
+
}
|
|
1482
|
+
const { agent, restrictSlackToMembers } = result;
|
|
1483
|
+
|
|
1484
|
+
// ─── UAP-2.1: inline Slack/GitHub connect branches ─────────────────────
|
|
1485
|
+
// The agent row already exists at this point, so any failure below
|
|
1486
|
+
// redirects to the agent DETAIL page with an error — never rolls back
|
|
1487
|
+
// the agent (mirrors how the standalone connect-slack/connect-github
|
|
1488
|
+
// routes behave against an already-existing agent).
|
|
1489
|
+
//
|
|
1490
|
+
// Slack's OAuth flow is an external browser redirect, so at most one of
|
|
1491
|
+
// Slack/GitHub can produce the final HTTP response. When both are
|
|
1492
|
+
// requested, GitHub's storage/redirect-prep runs first (PAT storage is
|
|
1493
|
+
// synchronous; the App-manifest flow can't run before Slack's redirect
|
|
1494
|
+
// either, so it's skipped when Slack is also requested) so GH_TOKEN is
|
|
1495
|
+
// already in place by the time the user lands back from Slack's OAuth
|
|
1496
|
+
// callback — then Slack's redirect is returned last.
|
|
1497
|
+
//
|
|
1498
|
+
// UAP-5.1: this whole block only applies to in-cluster agents. Self-hosted
|
|
1499
|
+
// agents use local git config for GitHub auth, not admin-managed
|
|
1500
|
+
// provisioning, so Slack/GitHub connect must never run for them — even if
|
|
1501
|
+
// connectSlack/ghAuthMode were submitted via a raw POST bypassing the UI
|
|
1502
|
+
// (the New Agent form hides these sections for self-hosted, but that's
|
|
1503
|
+
// client-side only).
|
|
1504
|
+
if (inCluster) {
|
|
1505
|
+
let ghPatResult:
|
|
1506
|
+
| Awaited<ReturnType<typeof githubProvisioningService.startPatConnect>>
|
|
1507
|
+
| undefined;
|
|
1508
|
+
if (ghAuthMode === "pat") {
|
|
1509
|
+
ghPatResult = await githubProvisioningService.startPatConnect(
|
|
1510
|
+
agent.id,
|
|
1511
|
+
ghPat,
|
|
1512
|
+
);
|
|
1513
|
+
if (!ghPatResult.ok && !connectSlack) {
|
|
1514
|
+
return c.redirect(
|
|
1515
|
+
`/admin/agents/${agent.id}?error=${encodeURIComponent(ghPatResult.error)}`,
|
|
1516
|
+
302,
|
|
1517
|
+
);
|
|
1518
|
+
}
|
|
1519
|
+
if (!ghPatResult.ok) {
|
|
1520
|
+
console.error(
|
|
1521
|
+
"[admin-ui] GitHub PAT storage failed during combined Slack+GitHub connect:",
|
|
1522
|
+
ghPatResult.error,
|
|
1523
|
+
);
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
if (connectSlack) {
|
|
1528
|
+
const redirectUri = `${appBaseUrl}/admin/agents/${agent.id}/connect-slack/callback`;
|
|
1529
|
+
// Carry a failed-PAT-storage warning (UAP-2.1) through Slack's OAuth
|
|
1530
|
+
// round trip via the signed provision-state cookie so it resurfaces on
|
|
1531
|
+
// the post-callback landing page. Without this, a GitHub PAT that failed
|
|
1532
|
+
// to store above would be silently swallowed and the operator misled
|
|
1533
|
+
// into believing GH_TOKEN was connected.
|
|
1534
|
+
const ghConnectError =
|
|
1535
|
+
ghPatResult && !ghPatResult.ok ? ghPatResult.error : undefined;
|
|
1536
|
+
const slackResult = await slackProvisioningService.startConnect(
|
|
1537
|
+
agent.id,
|
|
1538
|
+
xoxpToken,
|
|
1539
|
+
redirectUri,
|
|
1540
|
+
ghConnectError,
|
|
1541
|
+
);
|
|
1542
|
+
if (!slackResult.ok) {
|
|
1543
|
+
return c.redirect(
|
|
1544
|
+
`/admin/agents/${agent.id}?error=${encodeURIComponent(slackResult.error)}`,
|
|
1545
|
+
302,
|
|
1546
|
+
);
|
|
1547
|
+
}
|
|
1548
|
+
setCookie(c, PROVISION_STATE_COOKIE, slackResult.provisionStateToken, {
|
|
1549
|
+
httpOnly: true,
|
|
1550
|
+
maxAge: PROVISION_STATE_TTL_SECONDS,
|
|
1551
|
+
sameSite: "Lax",
|
|
1552
|
+
path: "/",
|
|
1553
|
+
secure: appBaseUrl.startsWith("https://"),
|
|
1554
|
+
});
|
|
1555
|
+
return c.redirect(slackResult.oauthRedirectUrl, 302);
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
if (ghAuthMode === "app" && ghAppMode === "manual") {
|
|
1559
|
+
// UAP-5.3: "Use existing GitHub App" — validates and persists
|
|
1560
|
+
// GH_APP_ID/GH_APP_INSTALLATION_ID/GH_APP_PRIVATE_KEY directly, no
|
|
1561
|
+
// GitHub API call. On success falls through to the shared
|
|
1562
|
+
// redirectWithMembersWarning below, matching the ghAuthMode=pat
|
|
1563
|
+
// branch's success path.
|
|
1564
|
+
const manualResult =
|
|
1565
|
+
await githubProvisioningService.startAppManualConnect(agent.id, {
|
|
1566
|
+
ghAppId,
|
|
1567
|
+
ghAppInstallationId,
|
|
1568
|
+
ghAppPrivateKey,
|
|
1569
|
+
});
|
|
1570
|
+
if (!manualResult.ok) {
|
|
1571
|
+
return c.redirect(
|
|
1572
|
+
`/admin/agents/${agent.id}?error=${encodeURIComponent(manualResult.error)}`,
|
|
1573
|
+
302,
|
|
1574
|
+
);
|
|
1575
|
+
}
|
|
1576
|
+
} else if (ghAuthMode === "app") {
|
|
1577
|
+
const appResult = await githubProvisioningService.startAppAutoConnect(
|
|
1578
|
+
agent.id,
|
|
1579
|
+
githubOrg,
|
|
1580
|
+
{
|
|
1581
|
+
redirectUri: `${appBaseUrl}/admin/agents/${agent.id}/connect-github/callback`,
|
|
1582
|
+
setupUrl: `${appBaseUrl}/admin/agents/${agent.id}/connect-github/installed`,
|
|
1583
|
+
},
|
|
1584
|
+
);
|
|
1585
|
+
if (!appResult.ok) {
|
|
1586
|
+
return c.redirect(
|
|
1587
|
+
`/admin/agents/${agent.id}?error=${encodeURIComponent(appResult.error)}`,
|
|
1588
|
+
302,
|
|
1589
|
+
);
|
|
1590
|
+
}
|
|
1591
|
+
setCookie(
|
|
1592
|
+
c,
|
|
1593
|
+
GITHUB_PROVISION_STATE_COOKIE,
|
|
1594
|
+
appResult.provisionStateToken,
|
|
1595
|
+
{
|
|
1596
|
+
httpOnly: true,
|
|
1597
|
+
maxAge: GITHUB_PROVISION_STATE_TTL_SECONDS,
|
|
1598
|
+
sameSite: "Lax",
|
|
1599
|
+
path: "/",
|
|
1600
|
+
secure: appBaseUrl.startsWith("https://"),
|
|
1601
|
+
},
|
|
1602
|
+
);
|
|
1603
|
+
return c.html(
|
|
1604
|
+
renderGithubAppManifestRedirectPage(c.var.userEmail, {
|
|
1605
|
+
githubOrg: appResult.githubOrg,
|
|
1606
|
+
manifest: appResult.manifest,
|
|
1607
|
+
}),
|
|
1608
|
+
);
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
return redirectWithMembersWarning(
|
|
1613
|
+
c,
|
|
1614
|
+
agentMemberService,
|
|
1615
|
+
agent.id,
|
|
1616
|
+
restrictSlackToMembers,
|
|
1617
|
+
);
|
|
1618
|
+
});
|
|
1619
|
+
|
|
1620
|
+
app.get("/admin/agents/:id", requireAuth, async (c) => {
|
|
1621
|
+
const agentId = c.req.param("id");
|
|
1622
|
+
const agent = await agentService.getDetail(agentId);
|
|
1623
|
+
if (!agent) {
|
|
1624
|
+
return new Response("Agent not found", { status: 404 });
|
|
1625
|
+
}
|
|
1626
|
+
const agentDetail: AgentDetail = {
|
|
1627
|
+
id: agent.id,
|
|
1628
|
+
name: agent.name,
|
|
1629
|
+
slackId: agent.slackId ?? null,
|
|
1630
|
+
selfHosted: agent.selfHosted,
|
|
1631
|
+
createdAt: agent.createdAt,
|
|
1632
|
+
updatedAt: agent.updatedAt,
|
|
1633
|
+
repos: agent.repos,
|
|
1634
|
+
authorAllowlist: agent.reviewAuthorAllowlist,
|
|
1635
|
+
patchAuthorAllowlist: agent.patchAuthorAllowlist,
|
|
1636
|
+
restrictSlackToMembers: agent.restrictSlackToMembers,
|
|
1637
|
+
typeName: agent.typeName,
|
|
1638
|
+
missingRequiredEnv: agent.missingRequiredEnv,
|
|
1639
|
+
autoPostReviews: agent.autoPostReviews ?? true,
|
|
1640
|
+
allowSelfReview: agent.allowSelfReview ?? false,
|
|
1641
|
+
minConfidence: agent.minConfidence ?? 75,
|
|
1642
|
+
maxFindings: agent.maxFindings ?? 5,
|
|
1643
|
+
cleanupMergedWorktrees: agent.cleanupMergedWorktrees ?? true,
|
|
1644
|
+
cleanupAfterDays: agent.cleanupAfterDays ?? 14,
|
|
1645
|
+
ownerEmail: agent.ownerEmail ?? null,
|
|
1646
|
+
anthropicIdentityName: agent.anthropicIdentityName ?? null,
|
|
1647
|
+
anthropicServiceAccountId: agent.anthropicServiceAccountId ?? null,
|
|
1648
|
+
anthropicFederationRuleId: agent.anthropicFederationRuleId ?? null,
|
|
1649
|
+
};
|
|
1650
|
+
|
|
1651
|
+
if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
|
|
1652
|
+
return new Response("Forbidden", { status: 403 });
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
const rawError = c.req.query("error") ?? undefined;
|
|
1656
|
+
const error = rawError ? (ERROR_MESSAGES[rawError] ?? rawError) : undefined;
|
|
1657
|
+
const newToken = c.req.query("newToken") ?? undefined;
|
|
1658
|
+
const successParam = c.req.query("success");
|
|
1659
|
+
const successMsg =
|
|
1660
|
+
successParam === "manifest_synced"
|
|
1661
|
+
? "Manifest synced successfully."
|
|
1662
|
+
: successParam === "reinstalled"
|
|
1663
|
+
? "Slack app reinstalled successfully."
|
|
1664
|
+
: undefined;
|
|
1665
|
+
const warningParam = c.req.query("warning");
|
|
1666
|
+
const warning =
|
|
1667
|
+
warningParam === "restrict_slack_no_members"
|
|
1668
|
+
? "This agent has no members — enabling restrictSlackToMembers will block all Slack senders."
|
|
1669
|
+
: // Free-form warning text (e.g. UAP-2.1's combined-flow GitHub PAT
|
|
1670
|
+
// failure carried through the Slack OAuth round trip). Passed through
|
|
1671
|
+
// verbatim rather than mapped to a fixed key.
|
|
1672
|
+
(warningParam ?? undefined);
|
|
1673
|
+
|
|
1674
|
+
const [envResult, crons, tools, tokens, plugins, members] =
|
|
1675
|
+
await Promise.all([
|
|
1676
|
+
agentEnvService
|
|
1677
|
+
.getByAgentId(agentId)
|
|
1678
|
+
.then((e) => e ?? { env: {}, secretKeys: [] }),
|
|
1679
|
+
agentCronJobService.listWithRunSummary(agentId),
|
|
1680
|
+
agentToolService.list(agentId),
|
|
1681
|
+
agentTokenService.listForAgent(agentId),
|
|
1682
|
+
agentPluginService.list(agentId),
|
|
1683
|
+
canManageAgents(c.var.isAdmin)
|
|
1684
|
+
? agentMemberService.listByAgentId(agentId)
|
|
1685
|
+
: Promise.resolve([]),
|
|
1686
|
+
]);
|
|
1687
|
+
|
|
1688
|
+
// Extension card slots (AEP-2.1/AEP-3.2), grouped by placement; a failing
|
|
1689
|
+
// slot is omitted.
|
|
1690
|
+
const cards = await renderAgentDetailCardSlots(
|
|
1691
|
+
deps.agentDetailCards ?? [],
|
|
1692
|
+
{
|
|
1693
|
+
agentId,
|
|
1694
|
+
userEmail: c.var.userEmail,
|
|
1695
|
+
isAdmin: c.var.isAdmin,
|
|
1696
|
+
accountId: agent.accountId ?? null,
|
|
1697
|
+
timezone,
|
|
1698
|
+
},
|
|
1699
|
+
);
|
|
1700
|
+
|
|
1701
|
+
return html(
|
|
1702
|
+
renderAgentDetailPage(
|
|
1703
|
+
agentDetail,
|
|
1704
|
+
envResult,
|
|
1705
|
+
crons,
|
|
1706
|
+
tools,
|
|
1707
|
+
tokens,
|
|
1708
|
+
plugins,
|
|
1709
|
+
members,
|
|
1710
|
+
c.var.userEmail,
|
|
1711
|
+
c.var.isAdmin,
|
|
1712
|
+
{
|
|
1713
|
+
canManage: canManageAgents(c.var.isAdmin),
|
|
1714
|
+
error,
|
|
1715
|
+
newToken,
|
|
1716
|
+
successMsg,
|
|
1717
|
+
warning,
|
|
1718
|
+
timezone,
|
|
1719
|
+
extensionCardsHtml: cards.header,
|
|
1720
|
+
configurationCardsHtml: cards.configuration,
|
|
1721
|
+
queueActivityLinks: deps.queueActivityLinks,
|
|
1722
|
+
},
|
|
1723
|
+
),
|
|
1724
|
+
);
|
|
1725
|
+
});
|
|
1726
|
+
|
|
1727
|
+
// ─── Env var mutations ────────────────────────────────────────────────────
|
|
1728
|
+
|
|
1729
|
+
app.post("/admin/agents/:id/envs", requireAuth, async (c) => {
|
|
1730
|
+
const agentId = c.req.param("id");
|
|
1731
|
+
if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
|
|
1732
|
+
return new Response("Forbidden", { status: 403 });
|
|
1733
|
+
}
|
|
1734
|
+
let key: string | undefined;
|
|
1735
|
+
let value: string | undefined;
|
|
1736
|
+
let secretStr: string | undefined;
|
|
1737
|
+
try {
|
|
1738
|
+
const formData = await c.req.formData();
|
|
1739
|
+
key = formData.get("key")?.toString();
|
|
1740
|
+
value = formData.get("value")?.toString();
|
|
1741
|
+
secretStr = formData.get("secret")?.toString();
|
|
1742
|
+
} catch {
|
|
1743
|
+
return c.redirect(`/admin/agents/${agentId}`, 302);
|
|
1744
|
+
}
|
|
1745
|
+
if (key && value !== undefined) {
|
|
1746
|
+
const isSecret = secretStr === "true";
|
|
1747
|
+
try {
|
|
1748
|
+
await agentEnvService.patch(
|
|
1749
|
+
agentId,
|
|
1750
|
+
{ [key]: value },
|
|
1751
|
+
isSecret ? new Set([key]) : new Set(),
|
|
1752
|
+
);
|
|
1753
|
+
} catch (err) {
|
|
1754
|
+
// WIF-2.1: surface the typed rejection (key names only, never the
|
|
1755
|
+
// value) on the agent page instead of a 500.
|
|
1756
|
+
if (err instanceof AnthropicCredentialsBlockedError) {
|
|
1757
|
+
return c.redirect(
|
|
1758
|
+
`/admin/agents/${agentId}?error=${encodeURIComponent(err.message)}`,
|
|
1759
|
+
302,
|
|
1760
|
+
);
|
|
1761
|
+
}
|
|
1762
|
+
throw err;
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
return c.redirect(`/admin/agents/${agentId}`, 302);
|
|
1766
|
+
});
|
|
1767
|
+
|
|
1768
|
+
app.post("/admin/agents/:id/envs/delete", requireAuth, async (c) => {
|
|
1769
|
+
const agentId = c.req.param("id");
|
|
1770
|
+
if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
|
|
1771
|
+
return new Response("Forbidden", { status: 403 });
|
|
1772
|
+
}
|
|
1773
|
+
let key: string | undefined;
|
|
1774
|
+
try {
|
|
1775
|
+
const formData = await c.req.formData();
|
|
1776
|
+
key = formData.get("key")?.toString();
|
|
1777
|
+
} catch {
|
|
1778
|
+
return c.redirect(`/admin/agents/${agentId}`, 302);
|
|
1779
|
+
}
|
|
1780
|
+
if (key) {
|
|
1781
|
+
await agentEnvService.deleteKey(agentId, key);
|
|
1782
|
+
}
|
|
1783
|
+
return c.redirect(`/admin/agents/${agentId}`, 302);
|
|
1784
|
+
});
|
|
1785
|
+
|
|
1786
|
+
// ─── Repo mutations ───────────────────────────────────────────────────────
|
|
1787
|
+
|
|
1788
|
+
app.post("/admin/agents/:id/repos/add", requireAuth, async (c) => {
|
|
1789
|
+
const agentId = c.req.param("id");
|
|
1790
|
+
if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
|
|
1791
|
+
return new Response("Forbidden", { status: 403 });
|
|
1792
|
+
}
|
|
1793
|
+
let repo: string | undefined;
|
|
1794
|
+
try {
|
|
1795
|
+
const formData = await c.req.formData();
|
|
1796
|
+
repo = formData.get("repo")?.toString()?.trim();
|
|
1797
|
+
} catch {
|
|
1798
|
+
return c.redirect(`/admin/agents/${agentId}`, 302);
|
|
1799
|
+
}
|
|
1800
|
+
if (!repo || !isOrgRepo(repo)) {
|
|
1801
|
+
return c.redirect(
|
|
1802
|
+
`/admin/agents/${agentId}?error=invalid_repo_format`,
|
|
1803
|
+
302,
|
|
1804
|
+
);
|
|
1805
|
+
}
|
|
1806
|
+
const agent = await agentService.getDetail(agentId);
|
|
1807
|
+
if (!agent) {
|
|
1808
|
+
return new Response("Agent not found", { status: 404 });
|
|
1809
|
+
}
|
|
1810
|
+
const existing = agent.repos ?? [];
|
|
1811
|
+
const deduped = existing.includes(repo) ? existing : [...existing, repo];
|
|
1812
|
+
const collision = describeRepoNameCollision(deduped);
|
|
1813
|
+
if (collision) {
|
|
1814
|
+
return c.redirect(
|
|
1815
|
+
`/admin/agents/${agentId}?error=${encodeURIComponent(collision)}`,
|
|
1816
|
+
302,
|
|
1817
|
+
);
|
|
1818
|
+
}
|
|
1819
|
+
await agentService.updateFields(agentId, { repos: deduped });
|
|
1820
|
+
return c.redirect(`/admin/agents/${agentId}`, 302);
|
|
1821
|
+
});
|
|
1822
|
+
|
|
1823
|
+
app.post("/admin/agents/:id/repos/delete", requireAuth, async (c) => {
|
|
1824
|
+
const agentId = c.req.param("id");
|
|
1825
|
+
if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
|
|
1826
|
+
return new Response("Forbidden", { status: 403 });
|
|
1827
|
+
}
|
|
1828
|
+
let repo: string | undefined;
|
|
1829
|
+
try {
|
|
1830
|
+
const formData = await c.req.formData();
|
|
1831
|
+
repo = formData.get("repo")?.toString()?.trim();
|
|
1832
|
+
} catch {
|
|
1833
|
+
return c.redirect(`/admin/agents/${agentId}`, 302);
|
|
1834
|
+
}
|
|
1835
|
+
if (repo) {
|
|
1836
|
+
const agent = await agentService.getDetail(agentId);
|
|
1837
|
+
if (!agent) {
|
|
1838
|
+
return new Response("Agent not found", { status: 404 });
|
|
1839
|
+
}
|
|
1840
|
+
const updated = (agent.repos ?? []).filter((r) => r !== repo);
|
|
1841
|
+
await agentService.updateFields(agentId, { repos: updated });
|
|
1842
|
+
}
|
|
1843
|
+
return c.redirect(`/admin/agents/${agentId}`, 302);
|
|
1844
|
+
});
|
|
1845
|
+
|
|
1846
|
+
// ─── Author allowlist (review) mutations ───────────────────────────────────
|
|
1847
|
+
|
|
1848
|
+
app.post(
|
|
1849
|
+
"/admin/agents/:id/review-author-allowlist/add",
|
|
1850
|
+
requireAuth,
|
|
1851
|
+
async (c) => {
|
|
1852
|
+
const agentId = c.req.param("id");
|
|
1853
|
+
if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
|
|
1854
|
+
return new Response("Forbidden", { status: 403 });
|
|
1855
|
+
}
|
|
1856
|
+
let login: string | undefined;
|
|
1857
|
+
try {
|
|
1858
|
+
const formData = await c.req.formData();
|
|
1859
|
+
login = formData.get("login")?.toString()?.trim();
|
|
1860
|
+
} catch {
|
|
1861
|
+
return c.redirect(`/admin/agents/${agentId}`, 302);
|
|
1862
|
+
}
|
|
1863
|
+
if (!login || !isGithubLogin(login)) {
|
|
1864
|
+
return c.redirect(
|
|
1865
|
+
`/admin/agents/${agentId}?error=invalid_author_allowlist_format`,
|
|
1866
|
+
302,
|
|
1867
|
+
);
|
|
1868
|
+
}
|
|
1869
|
+
const agent = await agentService.getDetail(agentId);
|
|
1870
|
+
if (!agent) {
|
|
1871
|
+
return new Response("Agent not found", { status: 404 });
|
|
1872
|
+
}
|
|
1873
|
+
const existing = agent.reviewAuthorAllowlist ?? [];
|
|
1874
|
+
const deduped = existing.includes(login)
|
|
1875
|
+
? existing
|
|
1876
|
+
: [...existing, login];
|
|
1877
|
+
await agentService.updateFields(agentId, {
|
|
1878
|
+
reviewAuthorAllowlist: deduped,
|
|
1879
|
+
});
|
|
1880
|
+
return c.redirect(`/admin/agents/${agentId}`, 302);
|
|
1881
|
+
},
|
|
1882
|
+
);
|
|
1883
|
+
|
|
1884
|
+
app.post(
|
|
1885
|
+
"/admin/agents/:id/review-author-allowlist/delete",
|
|
1886
|
+
requireAuth,
|
|
1887
|
+
async (c) => {
|
|
1888
|
+
const agentId = c.req.param("id");
|
|
1889
|
+
if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
|
|
1890
|
+
return new Response("Forbidden", { status: 403 });
|
|
1891
|
+
}
|
|
1892
|
+
let login: string | undefined;
|
|
1893
|
+
try {
|
|
1894
|
+
const formData = await c.req.formData();
|
|
1895
|
+
login = formData.get("login")?.toString()?.trim();
|
|
1896
|
+
} catch {
|
|
1897
|
+
return c.redirect(`/admin/agents/${agentId}`, 302);
|
|
1898
|
+
}
|
|
1899
|
+
if (login) {
|
|
1900
|
+
const agent = await agentService.getDetail(agentId);
|
|
1901
|
+
if (!agent) {
|
|
1902
|
+
return new Response("Agent not found", { status: 404 });
|
|
1903
|
+
}
|
|
1904
|
+
const updated = (agent.reviewAuthorAllowlist ?? []).filter(
|
|
1905
|
+
(l) => l !== login,
|
|
1906
|
+
);
|
|
1907
|
+
await agentService.updateFields(agentId, {
|
|
1908
|
+
reviewAuthorAllowlist: updated,
|
|
1909
|
+
});
|
|
1910
|
+
}
|
|
1911
|
+
return c.redirect(`/admin/agents/${agentId}`, 302);
|
|
1912
|
+
},
|
|
1913
|
+
);
|
|
1914
|
+
|
|
1915
|
+
// ─── Author allowlist (patch) mutations ────────────────────────────────────
|
|
1916
|
+
|
|
1917
|
+
app.post(
|
|
1918
|
+
"/admin/agents/:id/patch-author-allowlist/add",
|
|
1919
|
+
requireAuth,
|
|
1920
|
+
async (c) => {
|
|
1921
|
+
const agentId = c.req.param("id");
|
|
1922
|
+
if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
|
|
1923
|
+
return new Response("Forbidden", { status: 403 });
|
|
1924
|
+
}
|
|
1925
|
+
let login: string | undefined;
|
|
1926
|
+
try {
|
|
1927
|
+
const formData = await c.req.formData();
|
|
1928
|
+
login = formData.get("login")?.toString()?.trim();
|
|
1929
|
+
} catch {
|
|
1930
|
+
return c.redirect(`/admin/agents/${agentId}`, 302);
|
|
1931
|
+
}
|
|
1932
|
+
if (!login || !isGithubLogin(login)) {
|
|
1933
|
+
return c.redirect(
|
|
1934
|
+
`/admin/agents/${agentId}?error=invalid_author_allowlist_format`,
|
|
1935
|
+
302,
|
|
1936
|
+
);
|
|
1937
|
+
}
|
|
1938
|
+
const agent = await agentService.getDetail(agentId);
|
|
1939
|
+
if (!agent) {
|
|
1940
|
+
return new Response("Agent not found", { status: 404 });
|
|
1941
|
+
}
|
|
1942
|
+
const existing = agent.patchAuthorAllowlist ?? [];
|
|
1943
|
+
const deduped = existing.includes(login)
|
|
1944
|
+
? existing
|
|
1945
|
+
: [...existing, login];
|
|
1946
|
+
await agentService.updateFields(agentId, {
|
|
1947
|
+
patchAuthorAllowlist: deduped,
|
|
1948
|
+
});
|
|
1949
|
+
return c.redirect(`/admin/agents/${agentId}`, 302);
|
|
1950
|
+
},
|
|
1951
|
+
);
|
|
1952
|
+
|
|
1953
|
+
app.post(
|
|
1954
|
+
"/admin/agents/:id/patch-author-allowlist/delete",
|
|
1955
|
+
requireAuth,
|
|
1956
|
+
async (c) => {
|
|
1957
|
+
const agentId = c.req.param("id");
|
|
1958
|
+
if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
|
|
1959
|
+
return new Response("Forbidden", { status: 403 });
|
|
1960
|
+
}
|
|
1961
|
+
let login: string | undefined;
|
|
1962
|
+
try {
|
|
1963
|
+
const formData = await c.req.formData();
|
|
1964
|
+
login = formData.get("login")?.toString()?.trim();
|
|
1965
|
+
} catch {
|
|
1966
|
+
return c.redirect(`/admin/agents/${agentId}`, 302);
|
|
1967
|
+
}
|
|
1968
|
+
if (login) {
|
|
1969
|
+
const agent = await agentService.getDetail(agentId);
|
|
1970
|
+
if (!agent) {
|
|
1971
|
+
return new Response("Agent not found", { status: 404 });
|
|
1972
|
+
}
|
|
1973
|
+
const updated = (agent.patchAuthorAllowlist ?? []).filter(
|
|
1974
|
+
(l) => l !== login,
|
|
1975
|
+
);
|
|
1976
|
+
await agentService.updateFields(agentId, {
|
|
1977
|
+
patchAuthorAllowlist: updated,
|
|
1978
|
+
});
|
|
1979
|
+
}
|
|
1980
|
+
return c.redirect(`/admin/agents/${agentId}`, 302);
|
|
1981
|
+
},
|
|
1982
|
+
);
|
|
1983
|
+
|
|
1984
|
+
// ─── Slack access + Agent Policy settings ──────────────────────────────────
|
|
1985
|
+
//
|
|
1986
|
+
// Shared by two forms on the agent detail page: "Slack access"
|
|
1987
|
+
// (restrictSlackToMembers) and "Agent Policy" (APM-1.3's six DB-backed
|
|
1988
|
+
// policy fields). Each field is read independently and only included in
|
|
1989
|
+
// the updateFields() call when present in THIS submission's form data —
|
|
1990
|
+
// this lets the two forms share one route without a save from one form
|
|
1991
|
+
// silently clobbering the other's fields back to false/defaults. Every
|
|
1992
|
+
// checkbox in both forms renders a hidden "false" fallback alongside it
|
|
1993
|
+
// (see admin-ui-pages.ts) so an unchecked box still submits a real value
|
|
1994
|
+
// rather than being omitted entirely (the standard HTML checkbox gotcha) —
|
|
1995
|
+
// that hidden fallback is what makes "this form's box is unchecked"
|
|
1996
|
+
// distinguishable from "a different form was submitted, this field is
|
|
1997
|
+
// simply absent".
|
|
1998
|
+
|
|
1999
|
+
app.post("/admin/agents/:id/settings", requireAuth, async (c) => {
|
|
2000
|
+
const agentId = c.req.param("id");
|
|
2001
|
+
if (
|
|
2002
|
+
!(await assertCanManageAgent(agentId, c.var.userEmail, c.var.isAdmin))
|
|
2003
|
+
) {
|
|
2004
|
+
return new Response("Forbidden", { status: 403 });
|
|
2005
|
+
}
|
|
2006
|
+
let formData: FormData;
|
|
2007
|
+
try {
|
|
2008
|
+
formData = await c.req.formData();
|
|
2009
|
+
} catch {
|
|
2010
|
+
return c.redirect(`/admin/agents/${agentId}`, 302);
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
const readBool = (name: string): boolean | undefined => {
|
|
2014
|
+
const raw = formData.get(name)?.toString();
|
|
2015
|
+
return raw === undefined ? undefined : raw === "true";
|
|
2016
|
+
};
|
|
2017
|
+
// Defensive: reject non-finite/negative values rather than writing NaN
|
|
2018
|
+
// (or a nonsensical negative count) to Postgres — falls back to
|
|
2019
|
+
// undefined (leave the field unchanged) instead of throwing or silently
|
|
2020
|
+
// persisting garbage. An emptied <input type="number"> submits "" (not
|
|
2021
|
+
// undefined), and Number("") is 0 — so blank/whitespace-only input is
|
|
2022
|
+
// treated as absent too, otherwise clearing a field would silently zero it.
|
|
2023
|
+
const readInt = (name: string): number | undefined => {
|
|
2024
|
+
const raw = formData.get(name)?.toString();
|
|
2025
|
+
if (raw === undefined || raw.trim() === "") return undefined;
|
|
2026
|
+
const parsed = Number(raw);
|
|
2027
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
|
|
2028
|
+
};
|
|
2029
|
+
|
|
2030
|
+
const restrictSlackToMembers = readBool("restrictSlackToMembers");
|
|
2031
|
+
const autoPostReviews = readBool("autoPostReviews");
|
|
2032
|
+
const allowSelfReview = readBool("allowSelfReview");
|
|
2033
|
+
const cleanupMergedWorktrees = readBool("cleanupMergedWorktrees");
|
|
2034
|
+
const minConfidence = readInt("minConfidence");
|
|
2035
|
+
const maxFindings = readInt("maxFindings");
|
|
2036
|
+
const cleanupAfterDays = readInt("cleanupAfterDays");
|
|
2037
|
+
|
|
2038
|
+
// Signed commits live in the AgentEnv table, not on the Agent row. Absent
|
|
2039
|
+
// (box hidden for agents without GH_APP_ID, or another form posted) leaves
|
|
2040
|
+
// the row untouched.
|
|
2041
|
+
const signedCommits = readBool("signedCommits");
|
|
2042
|
+
if (signedCommits === true) {
|
|
2043
|
+
await agentEnvService.patch(agentId, { SHIPWRIGHT_SIGNED_COMMITS: "1" });
|
|
2044
|
+
} else if (signedCommits === false) {
|
|
2045
|
+
await agentEnvService.deleteKey(agentId, "SHIPWRIGHT_SIGNED_COMMITS");
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
const agent = await agentService.updateFields(agentId, {
|
|
2049
|
+
...(restrictSlackToMembers !== undefined && { restrictSlackToMembers }),
|
|
2050
|
+
...(autoPostReviews !== undefined && { autoPostReviews }),
|
|
2051
|
+
...(allowSelfReview !== undefined && { allowSelfReview }),
|
|
2052
|
+
...(cleanupMergedWorktrees !== undefined && { cleanupMergedWorktrees }),
|
|
2053
|
+
...(minConfidence !== undefined && { minConfidence }),
|
|
2054
|
+
...(maxFindings !== undefined && { maxFindings }),
|
|
2055
|
+
...(cleanupAfterDays !== undefined && { cleanupAfterDays }),
|
|
2056
|
+
});
|
|
2057
|
+
return redirectWithMembersWarning(
|
|
2058
|
+
c,
|
|
2059
|
+
agentMemberService,
|
|
2060
|
+
agent.id,
|
|
2061
|
+
agent.restrictSlackToMembers,
|
|
2062
|
+
);
|
|
2063
|
+
});
|
|
2064
|
+
|
|
2065
|
+
// ─── Cron job mutations ───────────────────────────────────────────────────
|
|
2066
|
+
|
|
2067
|
+
app.post("/admin/agents/:id/crons", requireAuth, async (c) => {
|
|
2068
|
+
const agentId = c.req.param("id");
|
|
2069
|
+
if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
|
|
2070
|
+
return new Response("Forbidden", { status: 403 });
|
|
2071
|
+
}
|
|
2072
|
+
let schedule: string | undefined;
|
|
2073
|
+
let prompt: string | undefined;
|
|
2074
|
+
let channel: string | null = null;
|
|
2075
|
+
let user: string | null = null;
|
|
2076
|
+
let silent = false;
|
|
2077
|
+
let enabled = true;
|
|
2078
|
+
let name: string | null = null;
|
|
2079
|
+
try {
|
|
2080
|
+
const formData = await c.req.formData();
|
|
2081
|
+
schedule = formData.get("schedule")?.toString();
|
|
2082
|
+
prompt = formData.get("prompt")?.toString();
|
|
2083
|
+
channel = formData.get("channel")?.toString() || null;
|
|
2084
|
+
user = formData.get("user")?.toString() || null;
|
|
2085
|
+
silent =
|
|
2086
|
+
formData.get("silent") === "on" || formData.get("silent") === "true";
|
|
2087
|
+
const enabledVal = formData.get("enabled");
|
|
2088
|
+
// Checkbox: present as "on" when checked, absent (null) when unchecked.
|
|
2089
|
+
// Programmatic callers may send "true"/"false" explicitly.
|
|
2090
|
+
enabled = enabledVal === "on" || enabledVal === "true";
|
|
2091
|
+
name = formData.get("name")?.toString() || null;
|
|
2092
|
+
} catch {
|
|
2093
|
+
return c.redirect(`/admin/agents/${agentId}`, 302);
|
|
2094
|
+
}
|
|
2095
|
+
if (!schedule || !prompt) {
|
|
2096
|
+
return c.redirect(`/admin/agents/${agentId}?error=missing_fields`, 302);
|
|
2097
|
+
}
|
|
2098
|
+
try {
|
|
2099
|
+
await agentCronJobService.create(agentId, {
|
|
2100
|
+
schedule,
|
|
2101
|
+
prompt,
|
|
2102
|
+
channel,
|
|
2103
|
+
user,
|
|
2104
|
+
silent,
|
|
2105
|
+
enabled,
|
|
2106
|
+
name,
|
|
2107
|
+
});
|
|
2108
|
+
} catch (err) {
|
|
2109
|
+
if (err instanceof UnprocessableEntityError) {
|
|
2110
|
+
const msg = err.message.toLowerCase();
|
|
2111
|
+
if (msg.includes("invalid cron")) {
|
|
2112
|
+
return c.redirect(
|
|
2113
|
+
`/admin/agents/${agentId}?error=invalid_schedule`,
|
|
2114
|
+
302,
|
|
2115
|
+
);
|
|
2116
|
+
}
|
|
2117
|
+
if (
|
|
2118
|
+
msg.includes("channel") ||
|
|
2119
|
+
msg.includes("user") ||
|
|
2120
|
+
msg.includes("target")
|
|
2121
|
+
) {
|
|
2122
|
+
return c.redirect(
|
|
2123
|
+
`/admin/agents/${agentId}?error=invalid_target`,
|
|
2124
|
+
302,
|
|
2125
|
+
);
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
return c.redirect(`/admin/agents/${agentId}?error=create_failed`, 302);
|
|
2129
|
+
}
|
|
2130
|
+
return c.redirect(`/admin/agents/${agentId}`, 302);
|
|
2131
|
+
});
|
|
2132
|
+
|
|
2133
|
+
app.post("/admin/agents/:id/crons/:cronId/toggle", requireAuth, async (c) => {
|
|
2134
|
+
const agentId = c.req.param("id");
|
|
2135
|
+
const cronId = c.req.param("cronId");
|
|
2136
|
+
if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
|
|
2137
|
+
return new Response("Forbidden", { status: 403 });
|
|
2138
|
+
}
|
|
2139
|
+
let enabled = true;
|
|
2140
|
+
try {
|
|
2141
|
+
const formData = await c.req.formData();
|
|
2142
|
+
enabled = formData.get("enabled") !== "false";
|
|
2143
|
+
} catch {
|
|
2144
|
+
// use default
|
|
2145
|
+
}
|
|
2146
|
+
try {
|
|
2147
|
+
await agentCronJobService.setEnabled(agentId, cronId, enabled);
|
|
2148
|
+
} catch {
|
|
2149
|
+
// ignore errors — redirect back regardless
|
|
2150
|
+
}
|
|
2151
|
+
return c.redirect(`/admin/agents/${agentId}`, 302);
|
|
2152
|
+
});
|
|
2153
|
+
|
|
2154
|
+
app.post("/admin/agents/:id/crons/:cronId/update", requireAuth, async (c) => {
|
|
2155
|
+
const agentId = c.req.param("id");
|
|
2156
|
+
const cronId = c.req.param("cronId");
|
|
2157
|
+
if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
|
|
2158
|
+
return new Response("Forbidden", { status: 403 });
|
|
2159
|
+
}
|
|
2160
|
+
let schedule = "";
|
|
2161
|
+
let prompt = "";
|
|
2162
|
+
let channel: string | null = null;
|
|
2163
|
+
let preCheck: string | null = null;
|
|
2164
|
+
try {
|
|
2165
|
+
const formData = await c.req.formData();
|
|
2166
|
+
schedule = ((formData.get("schedule") as string | null) ?? "").trim();
|
|
2167
|
+
prompt = ((formData.get("prompt") as string | null) ?? "").trim();
|
|
2168
|
+
const ch = ((formData.get("channel") as string | null) ?? "").trim();
|
|
2169
|
+
channel = ch === "" ? null : ch;
|
|
2170
|
+
const pc = ((formData.get("preCheck") as string | null) ?? "").trim();
|
|
2171
|
+
preCheck = pc === "" ? null : pc; // empty clears the preCheck
|
|
2172
|
+
} catch {
|
|
2173
|
+
// fall through to validation
|
|
2174
|
+
}
|
|
2175
|
+
if (!schedule || !prompt) {
|
|
2176
|
+
return c.redirect(
|
|
2177
|
+
`/admin/agents/${agentId}?error=${encodeURIComponent("schedule and prompt are required")}`,
|
|
2178
|
+
302,
|
|
2179
|
+
);
|
|
2180
|
+
}
|
|
2181
|
+
try {
|
|
2182
|
+
// Fetch the existing cron so we (a) block edits to system crons — their
|
|
2183
|
+
// contents are owned by reconcileSystemCrons and would be reverted — and
|
|
2184
|
+
// (b) forward `user`/`silent`. Without them the service resolves user→null,
|
|
2185
|
+
// silent→false and validateDeliveryTarget throws for any DM-routed cron.
|
|
2186
|
+
const existing = await agentCronJobService.get(agentId, cronId);
|
|
2187
|
+
if (existing.system) {
|
|
2188
|
+
return c.redirect(
|
|
2189
|
+
`/admin/agents/${agentId}?error=${encodeURIComponent("system crons cannot be edited")}`,
|
|
2190
|
+
302,
|
|
2191
|
+
);
|
|
2192
|
+
}
|
|
2193
|
+
await agentCronJobService.update(agentId, cronId, {
|
|
2194
|
+
schedule,
|
|
2195
|
+
prompt,
|
|
2196
|
+
channel,
|
|
2197
|
+
preCheck,
|
|
2198
|
+
user: existing.user,
|
|
2199
|
+
silent: existing.silent,
|
|
2200
|
+
});
|
|
2201
|
+
} catch (err) {
|
|
2202
|
+
// Surface validation errors (e.g. invalid cron expression) back to the page.
|
|
2203
|
+
return c.redirect(
|
|
2204
|
+
`/admin/agents/${agentId}?error=${encodeURIComponent(
|
|
2205
|
+
err instanceof Error ? err.message : "cron update failed",
|
|
2206
|
+
)}`,
|
|
2207
|
+
302,
|
|
2208
|
+
);
|
|
2209
|
+
}
|
|
2210
|
+
return c.redirect(`/admin/agents/${agentId}`, 302);
|
|
2211
|
+
});
|
|
2212
|
+
|
|
2213
|
+
app.post("/admin/agents/:id/crons/:cronId/delete", requireAuth, async (c) => {
|
|
2214
|
+
const agentId = c.req.param("id");
|
|
2215
|
+
const cronId = c.req.param("cronId");
|
|
2216
|
+
if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
|
|
2217
|
+
return new Response("Forbidden", { status: 403 });
|
|
2218
|
+
}
|
|
2219
|
+
try {
|
|
2220
|
+
const cron = await agentCronJobService.get(agentId, cronId);
|
|
2221
|
+
if (cron.system) {
|
|
2222
|
+
throw new ForbiddenError("system crons cannot be deleted");
|
|
2223
|
+
}
|
|
2224
|
+
await agentCronJobService.delete(agentId, cronId);
|
|
2225
|
+
} catch (err) {
|
|
2226
|
+
if (err instanceof ForbiddenError) {
|
|
2227
|
+
return c.redirect(
|
|
2228
|
+
`/admin/agents/${agentId}?error=${encodeURIComponent(err.message)}`,
|
|
2229
|
+
302,
|
|
2230
|
+
);
|
|
2231
|
+
}
|
|
2232
|
+
// other errors (NotFoundError, etc.) — redirect back silently
|
|
2233
|
+
}
|
|
2234
|
+
return c.redirect(`/admin/agents/${agentId}`, 302);
|
|
2235
|
+
});
|
|
2236
|
+
|
|
2237
|
+
// ─── Tool mutations ───────────────────────────────────────────────────────
|
|
2238
|
+
|
|
2239
|
+
app.post("/admin/agents/:id/tools", requireAuth, async (c) => {
|
|
2240
|
+
const agentId = c.req.param("id");
|
|
2241
|
+
if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
|
|
2242
|
+
return new Response("Forbidden", { status: 403 });
|
|
2243
|
+
}
|
|
2244
|
+
let pattern: string | undefined;
|
|
2245
|
+
try {
|
|
2246
|
+
const formData = await c.req.formData();
|
|
2247
|
+
pattern = formData.get("pattern")?.toString();
|
|
2248
|
+
} catch {
|
|
2249
|
+
return c.redirect(`/admin/agents/${agentId}`, 302);
|
|
2250
|
+
}
|
|
2251
|
+
if (!pattern) {
|
|
2252
|
+
return c.redirect(`/admin/agents/${agentId}?error=missing_fields`, 302);
|
|
2253
|
+
}
|
|
2254
|
+
try {
|
|
2255
|
+
await agentToolService.add(agentId, pattern);
|
|
2256
|
+
} catch {
|
|
2257
|
+
// ignore errors — redirect back regardless
|
|
2258
|
+
}
|
|
2259
|
+
return c.redirect(`/admin/agents/${agentId}`, 302);
|
|
2260
|
+
});
|
|
2261
|
+
|
|
2262
|
+
app.post("/admin/agents/:id/tools/:toolId/toggle", requireAuth, async (c) => {
|
|
2263
|
+
const agentId = c.req.param("id");
|
|
2264
|
+
const toolId = c.req.param("toolId");
|
|
2265
|
+
if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
|
|
2266
|
+
return new Response("Forbidden", { status: 403 });
|
|
2267
|
+
}
|
|
2268
|
+
let enabled = true;
|
|
2269
|
+
try {
|
|
2270
|
+
const formData = await c.req.formData();
|
|
2271
|
+
enabled = formData.get("enabled") !== "false";
|
|
2272
|
+
} catch {
|
|
2273
|
+
// use default
|
|
2274
|
+
}
|
|
2275
|
+
try {
|
|
2276
|
+
await agentToolService.toggle(agentId, toolId, enabled);
|
|
2277
|
+
} catch {
|
|
2278
|
+
// ignore errors — redirect back regardless
|
|
2279
|
+
}
|
|
2280
|
+
return c.redirect(`/admin/agents/${agentId}`, 302);
|
|
2281
|
+
});
|
|
2282
|
+
|
|
2283
|
+
app.post("/admin/agents/:id/tools/:toolId/delete", requireAuth, async (c) => {
|
|
2284
|
+
const agentId = c.req.param("id");
|
|
2285
|
+
const toolId = c.req.param("toolId");
|
|
2286
|
+
if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
|
|
2287
|
+
return new Response("Forbidden", { status: 403 });
|
|
2288
|
+
}
|
|
2289
|
+
try {
|
|
2290
|
+
await agentToolService.remove(agentId, toolId);
|
|
2291
|
+
} catch {
|
|
2292
|
+
// ignore errors — redirect back regardless
|
|
2293
|
+
}
|
|
2294
|
+
return c.redirect(`/admin/agents/${agentId}`, 302);
|
|
2295
|
+
});
|
|
2296
|
+
|
|
2297
|
+
// ─── Token mutations ──────────────────────────────────────────────────────
|
|
2298
|
+
|
|
2299
|
+
app.post("/admin/agents/:id/tokens", requireAuth, async (c) => {
|
|
2300
|
+
const agentId = c.req.param("id");
|
|
2301
|
+
if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
|
|
2302
|
+
return new Response("Forbidden", { status: 403 });
|
|
2303
|
+
}
|
|
2304
|
+
let label: string | undefined;
|
|
2305
|
+
try {
|
|
2306
|
+
const formData = await c.req.formData();
|
|
2307
|
+
label = formData.get("label")?.toString() || undefined;
|
|
2308
|
+
} catch {
|
|
2309
|
+
return c.redirect(`/admin/agents/${agentId}`, 302);
|
|
2310
|
+
}
|
|
2311
|
+
const agent = await agentService.getDetail(agentId);
|
|
2312
|
+
if (!agent) {
|
|
2313
|
+
return new Response("Agent not found", { status: 404 });
|
|
2314
|
+
}
|
|
2315
|
+
const agentDetail: AgentDetail = {
|
|
2316
|
+
id: agent.id,
|
|
2317
|
+
name: agent.name,
|
|
2318
|
+
slackId: agent.slackId ?? null,
|
|
2319
|
+
selfHosted: agent.selfHosted,
|
|
2320
|
+
createdAt: agent.createdAt,
|
|
2321
|
+
updatedAt: agent.updatedAt,
|
|
2322
|
+
repos: agent.repos,
|
|
2323
|
+
authorAllowlist: agent.reviewAuthorAllowlist,
|
|
2324
|
+
patchAuthorAllowlist: agent.patchAuthorAllowlist,
|
|
2325
|
+
restrictSlackToMembers: agent.restrictSlackToMembers,
|
|
2326
|
+
typeName: agent.typeName,
|
|
2327
|
+
missingRequiredEnv: agent.missingRequiredEnv,
|
|
2328
|
+
autoPostReviews: agent.autoPostReviews ?? true,
|
|
2329
|
+
allowSelfReview: agent.allowSelfReview ?? false,
|
|
2330
|
+
minConfidence: agent.minConfidence ?? 75,
|
|
2331
|
+
maxFindings: agent.maxFindings ?? 5,
|
|
2332
|
+
cleanupMergedWorktrees: agent.cleanupMergedWorktrees ?? true,
|
|
2333
|
+
cleanupAfterDays: agent.cleanupAfterDays ?? 14,
|
|
2334
|
+
ownerEmail: agent.ownerEmail ?? null,
|
|
2335
|
+
anthropicIdentityName: agent.anthropicIdentityName ?? null,
|
|
2336
|
+
anthropicServiceAccountId: agent.anthropicServiceAccountId ?? null,
|
|
2337
|
+
anthropicFederationRuleId: agent.anthropicFederationRuleId ?? null,
|
|
2338
|
+
};
|
|
2339
|
+
try {
|
|
2340
|
+
const { rawToken } = await agentTokenService.create(agentId, label);
|
|
2341
|
+
// Render the page directly (200) rather than redirecting with the token in the URL.
|
|
2342
|
+
// A redirect would expose the raw token in server access logs and browser history.
|
|
2343
|
+
const [envResult, crons, tools, tokens, plugins, members] =
|
|
2344
|
+
await Promise.all([
|
|
2345
|
+
agentEnvService
|
|
2346
|
+
.getByAgentId(agentId)
|
|
2347
|
+
.then((e) => e ?? { env: {}, secretKeys: [] }),
|
|
2348
|
+
agentCronJobService.listWithRunSummary(agentId),
|
|
2349
|
+
agentToolService.list(agentId),
|
|
2350
|
+
agentTokenService.listForAgent(agentId),
|
|
2351
|
+
agentPluginService.list(agentId),
|
|
2352
|
+
canManageAgents(c.var.isAdmin)
|
|
2353
|
+
? agentMemberService.listByAgentId(agentId)
|
|
2354
|
+
: Promise.resolve([]),
|
|
2355
|
+
]);
|
|
2356
|
+
// The token page renders only the Configuration-group extension cards;
|
|
2357
|
+
// header cards keep their place empty, as before the slot split.
|
|
2358
|
+
const cards = await renderAgentDetailCardSlots(
|
|
2359
|
+
deps.agentDetailCards ?? [],
|
|
2360
|
+
{
|
|
2361
|
+
agentId,
|
|
2362
|
+
userEmail: c.var.userEmail,
|
|
2363
|
+
isAdmin: c.var.isAdmin,
|
|
2364
|
+
accountId: agent.accountId ?? null,
|
|
2365
|
+
timezone,
|
|
2366
|
+
},
|
|
2367
|
+
["configuration"],
|
|
2368
|
+
);
|
|
2369
|
+
return html(
|
|
2370
|
+
renderAgentDetailPage(
|
|
2371
|
+
agentDetail,
|
|
2372
|
+
envResult,
|
|
2373
|
+
crons,
|
|
2374
|
+
tools,
|
|
2375
|
+
tokens,
|
|
2376
|
+
plugins,
|
|
2377
|
+
members,
|
|
2378
|
+
c.var.userEmail,
|
|
2379
|
+
c.var.isAdmin,
|
|
2380
|
+
{
|
|
2381
|
+
canManage: canManageAgents(c.var.isAdmin),
|
|
2382
|
+
newToken: rawToken,
|
|
2383
|
+
timezone,
|
|
2384
|
+
extensionCardsHtml: cards.header,
|
|
2385
|
+
configurationCardsHtml: cards.configuration,
|
|
2386
|
+
queueActivityLinks: deps.queueActivityLinks,
|
|
2387
|
+
},
|
|
2388
|
+
),
|
|
2389
|
+
);
|
|
2390
|
+
} catch {
|
|
2391
|
+
return c.redirect(`/admin/agents/${agentId}?error=create_failed`, 302);
|
|
2392
|
+
}
|
|
2393
|
+
});
|
|
2394
|
+
|
|
2395
|
+
app.post(
|
|
2396
|
+
"/admin/agents/:id/tokens/:tokenId/revoke",
|
|
2397
|
+
requireAuth,
|
|
2398
|
+
async (c) => {
|
|
2399
|
+
const agentId = c.req.param("id");
|
|
2400
|
+
const tokenId = c.req.param("tokenId");
|
|
2401
|
+
if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
|
|
2402
|
+
return new Response("Forbidden", { status: 403 });
|
|
2403
|
+
}
|
|
2404
|
+
try {
|
|
2405
|
+
await agentTokenService.revoke(tokenId);
|
|
2406
|
+
} catch {
|
|
2407
|
+
// ignore errors — redirect back regardless
|
|
2408
|
+
}
|
|
2409
|
+
return c.redirect(`/admin/agents/${agentId}`, 302);
|
|
2410
|
+
},
|
|
2411
|
+
);
|
|
2412
|
+
|
|
2413
|
+
// ─── Provisioning state constants ────────────────────────────────────────
|
|
2414
|
+
// PROVISION_STATE_COOKIE / PROVISION_STATE_TTL_SECONDS now live in
|
|
2415
|
+
// slack-provisioning-service.ts (UAP-1.1); GITHUB_PROVISION_STATE_COOKIE /
|
|
2416
|
+
// GITHUB_PROVISION_STATE_TTL_SECONDS / GITHUB_ORG_PATTERN now live in
|
|
2417
|
+
// github-provisioning-service.ts (UAP-1.2) — re-imported above so every
|
|
2418
|
+
// route in this file keeps using the same names.
|
|
2419
|
+
|
|
2420
|
+
// ─── Manifest sync ────────────────────────────────────────────────────────
|
|
2421
|
+
|
|
2422
|
+
app.post("/admin/agents/:id/sync-manifest", requireAuth, async (c) => {
|
|
2423
|
+
const agentId = c.req.param("id");
|
|
2424
|
+
if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
|
|
2425
|
+
return new Response("Forbidden", { status: 403 });
|
|
2426
|
+
}
|
|
2427
|
+
const agent = await agentService.getDetail(agentId);
|
|
2428
|
+
if (!agent) return new Response("Agent not found", { status: 404 });
|
|
2429
|
+
|
|
2430
|
+
let xoxpToken: string | undefined;
|
|
2431
|
+
try {
|
|
2432
|
+
const formData = await c.req.formData();
|
|
2433
|
+
xoxpToken = formData.get("xoxpToken")?.toString();
|
|
2434
|
+
} catch {
|
|
2435
|
+
return c.redirect(`/admin/agents/${agentId}?error=missing_fields`, 302);
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
if (!xoxpToken?.startsWith("xoxe.xoxp-")) {
|
|
2439
|
+
return c.redirect(
|
|
2440
|
+
`/admin/agents/${agentId}?error=${encodeURIComponent("Slack app configuration token must start with xoxe.xoxp-")}`,
|
|
2441
|
+
302,
|
|
2442
|
+
);
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
const envBundle = await agentEnvService.getConfigBundle(agentId);
|
|
2446
|
+
const appId = envBundle?.env.SLACK_APP_ID;
|
|
2447
|
+
if (!appId) {
|
|
2448
|
+
return c.redirect(
|
|
2449
|
+
`/admin/agents/${agentId}?error=${encodeURIComponent("SLACK_APP_ID is not set — provision the agent first.")}`,
|
|
2450
|
+
302,
|
|
2451
|
+
);
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
try {
|
|
2455
|
+
const redirectUri = `${appBaseUrl}/admin/agents/${agentId}/connect-slack/callback`;
|
|
2456
|
+
const manifest = buildAgentManifest(agent.name, redirectUri);
|
|
2457
|
+
await slackClient.updateAppManifest(xoxpToken, appId, manifest);
|
|
2458
|
+
} catch (err) {
|
|
2459
|
+
const msg =
|
|
2460
|
+
err instanceof Error ? err.message : "Unknown error syncing manifest.";
|
|
2461
|
+
return c.redirect(
|
|
2462
|
+
`/admin/agents/${agentId}?error=${encodeURIComponent(msg)}`,
|
|
2463
|
+
302,
|
|
2464
|
+
);
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2467
|
+
// If the agent has OAuth credentials stored, trigger a reinstall via Slack OAuth
|
|
2468
|
+
const clientId = envBundle?.env.SLACK_CLIENT_ID;
|
|
2469
|
+
const clientSecret = envBundle?.env.SLACK_CLIENT_SECRET;
|
|
2470
|
+
const signingSecret = envBundle?.env.SLACK_SIGNING_SECRET;
|
|
2471
|
+
|
|
2472
|
+
if (clientId && clientSecret && signingSecret) {
|
|
2473
|
+
// Sign a provision-state cookie so the connect-slack callback can exchange the code
|
|
2474
|
+
const now = Math.floor(Date.now() / 1000);
|
|
2475
|
+
const provisionToken = await sign(
|
|
2476
|
+
{
|
|
2477
|
+
agentId,
|
|
2478
|
+
clientId,
|
|
2479
|
+
clientSecret,
|
|
2480
|
+
signingSecret,
|
|
2481
|
+
appId,
|
|
2482
|
+
iat: now,
|
|
2483
|
+
exp: now + PROVISION_STATE_TTL_SECONDS,
|
|
2484
|
+
},
|
|
2485
|
+
sessionSecret,
|
|
2486
|
+
"HS256",
|
|
2487
|
+
);
|
|
2488
|
+
setCookie(c, PROVISION_STATE_COOKIE, provisionToken, {
|
|
2489
|
+
httpOnly: true,
|
|
2490
|
+
maxAge: PROVISION_STATE_TTL_SECONDS,
|
|
2491
|
+
sameSite: "Lax",
|
|
2492
|
+
path: "/",
|
|
2493
|
+
secure: appBaseUrl.startsWith("https://"),
|
|
2494
|
+
});
|
|
2495
|
+
|
|
2496
|
+
// Build the Slack OAuth v2 authorize URL — use the canonical scope list
|
|
2497
|
+
// exported from slack-provisioning-client.ts so this stays in sync with
|
|
2498
|
+
// what buildAgentManifest declares.
|
|
2499
|
+
const scopes = AGENT_BOT_SCOPES.join(",");
|
|
2500
|
+
const redirectUri = `${appBaseUrl}/admin/agents/${agentId}/connect-slack/callback`;
|
|
2501
|
+
const oauthParams = new URLSearchParams({
|
|
2502
|
+
client_id: clientId,
|
|
2503
|
+
scope: scopes,
|
|
2504
|
+
redirect_uri: redirectUri,
|
|
2505
|
+
});
|
|
2506
|
+
return c.redirect(
|
|
2507
|
+
`https://slack.com/oauth/v2/authorize?${oauthParams.toString()}`,
|
|
2508
|
+
302,
|
|
2509
|
+
);
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
return c.redirect(`/admin/agents/${agentId}?success=manifest_synced`, 302);
|
|
2513
|
+
});
|
|
2514
|
+
|
|
2515
|
+
// ─── connect-slack (UAP-1.1) ───────────────────────────────────────────────
|
|
2516
|
+
// Per-agent equivalents of the legacy /admin/provision/* Slack wizard,
|
|
2517
|
+
// scoped to an already-existing agent id. All three delegate to the same
|
|
2518
|
+
// SlackProvisioningService the wizard now calls — pure routing/rendering
|
|
2519
|
+
// here, no business logic.
|
|
2520
|
+
|
|
2521
|
+
app.post("/admin/agents/:id/connect-slack", requireAuth, async (c) => {
|
|
2522
|
+
const agentId = c.req.param("id");
|
|
2523
|
+
if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
|
|
2524
|
+
return new Response("Forbidden", { status: 403 });
|
|
2525
|
+
}
|
|
2526
|
+
|
|
2527
|
+
let xoxpToken: string | undefined;
|
|
2528
|
+
try {
|
|
2529
|
+
const formData = await c.req.formData();
|
|
2530
|
+
xoxpToken = formData.get("xoxpToken")?.toString();
|
|
2531
|
+
} catch {
|
|
2532
|
+
return c.redirect(`/admin/agents/${agentId}?error=missing_fields`, 302);
|
|
2533
|
+
}
|
|
2534
|
+
|
|
2535
|
+
const result = await slackProvisioningService.startConnect(
|
|
2536
|
+
agentId,
|
|
2537
|
+
xoxpToken,
|
|
2538
|
+
`${appBaseUrl}/admin/agents/${agentId}/connect-slack/callback`,
|
|
2539
|
+
);
|
|
2540
|
+
|
|
2541
|
+
if (!result.ok) {
|
|
2542
|
+
return c.redirect(
|
|
2543
|
+
`/admin/agents/${agentId}?error=${encodeURIComponent(result.error)}`,
|
|
2544
|
+
302,
|
|
2545
|
+
);
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2548
|
+
setCookie(c, PROVISION_STATE_COOKIE, result.provisionStateToken, {
|
|
2549
|
+
httpOnly: true,
|
|
2550
|
+
maxAge: PROVISION_STATE_TTL_SECONDS,
|
|
2551
|
+
sameSite: "Lax",
|
|
2552
|
+
path: "/",
|
|
2553
|
+
secure: appBaseUrl.startsWith("https://"),
|
|
2554
|
+
});
|
|
2555
|
+
|
|
2556
|
+
return c.redirect(result.oauthRedirectUrl, 302);
|
|
2557
|
+
});
|
|
2558
|
+
|
|
2559
|
+
app.get(
|
|
2560
|
+
"/admin/agents/:id/connect-slack/callback",
|
|
2561
|
+
requireAuth,
|
|
2562
|
+
async (c) => {
|
|
2563
|
+
const agentId = c.req.param("id");
|
|
2564
|
+
if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
|
|
2565
|
+
return new Response("Forbidden", { status: 403 });
|
|
2566
|
+
}
|
|
2567
|
+
const userEmail = c.var.userEmail;
|
|
2568
|
+
|
|
2569
|
+
const rawStateCookie = getCookie(c, PROVISION_STATE_COOKIE);
|
|
2570
|
+
const code = c.req.query("code");
|
|
2571
|
+
const result = await slackProvisioningService.completeConnect(
|
|
2572
|
+
rawStateCookie,
|
|
2573
|
+
code,
|
|
2574
|
+
`${appBaseUrl}/admin/agents/${agentId}/connect-slack/callback`,
|
|
2575
|
+
);
|
|
2576
|
+
|
|
2577
|
+
if (result.outcome === "invalid_state") {
|
|
2578
|
+
deleteCookie(c, PROVISION_STATE_COOKIE);
|
|
2579
|
+
return html(
|
|
2580
|
+
renderProvisionCompletePage(userEmail, {
|
|
2581
|
+
success: false,
|
|
2582
|
+
error: result.error,
|
|
2583
|
+
}),
|
|
2584
|
+
);
|
|
2585
|
+
}
|
|
2586
|
+
|
|
2587
|
+
if (result.outcome === "missing_code") {
|
|
2588
|
+
// Cookie must remain intact so the user can restart the provision flow.
|
|
2589
|
+
return html(
|
|
2590
|
+
renderProvisionCompletePage(userEmail, {
|
|
2591
|
+
success: false,
|
|
2592
|
+
error: result.error,
|
|
2593
|
+
}),
|
|
2594
|
+
);
|
|
2595
|
+
}
|
|
2596
|
+
|
|
2597
|
+
// Every other outcome consumed the cookie's OAuth code — clear it now.
|
|
2598
|
+
deleteCookie(c, PROVISION_STATE_COOKIE);
|
|
2599
|
+
|
|
2600
|
+
if (result.outcome === "exchange_failed") {
|
|
2601
|
+
return html(
|
|
2602
|
+
renderProvisionCompletePage(userEmail, {
|
|
2603
|
+
success: false,
|
|
2604
|
+
error: result.error,
|
|
2605
|
+
}),
|
|
2606
|
+
);
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2609
|
+
// A non-fatal GitHub PAT storage failure from the combined create+connect
|
|
2610
|
+
// flow (UAP-2.1) rode through Slack's OAuth round trip in the signed
|
|
2611
|
+
// cookie — surface it now so the operator isn't misled into believing
|
|
2612
|
+
// GH_TOKEN was stored.
|
|
2613
|
+
const ghConnectWarning = result.ghConnectError
|
|
2614
|
+
? `GitHub was not connected — storing the PAT failed: ${result.ghConnectError}`
|
|
2615
|
+
: undefined;
|
|
2616
|
+
|
|
2617
|
+
if (result.outcome === "reinstalled") {
|
|
2618
|
+
const query = ghConnectWarning
|
|
2619
|
+
? `?success=reinstalled&warning=${encodeURIComponent(ghConnectWarning)}`
|
|
2620
|
+
: "?success=reinstalled";
|
|
2621
|
+
return c.redirect(`/admin/agents/${result.agentId}${query}`, 302);
|
|
2622
|
+
}
|
|
2623
|
+
|
|
2624
|
+
// result.outcome === "needs_app_token"
|
|
2625
|
+
return html(
|
|
2626
|
+
renderProvisionXappTokenPage(userEmail, {
|
|
2627
|
+
agentId: result.agentId,
|
|
2628
|
+
warning: ghConnectWarning,
|
|
2629
|
+
}),
|
|
2630
|
+
);
|
|
2631
|
+
},
|
|
2632
|
+
);
|
|
2633
|
+
|
|
2634
|
+
app.post(
|
|
2635
|
+
"/admin/agents/:id/connect-slack/app-token",
|
|
2636
|
+
requireAuth,
|
|
2637
|
+
async (c) => {
|
|
2638
|
+
const agentId = c.req.param("id");
|
|
2639
|
+
if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
|
|
2640
|
+
return new Response("Forbidden", { status: 403 });
|
|
2641
|
+
}
|
|
2642
|
+
const userEmail = c.var.userEmail;
|
|
2643
|
+
|
|
2644
|
+
let xappToken: string | undefined;
|
|
2645
|
+
try {
|
|
2646
|
+
const formData = await c.req.formData();
|
|
2647
|
+
xappToken = formData.get("xappToken")?.toString();
|
|
2648
|
+
} catch {
|
|
2649
|
+
return html(
|
|
2650
|
+
renderProvisionXappTokenPage(userEmail, {
|
|
2651
|
+
agentId,
|
|
2652
|
+
error: "Invalid form submission.",
|
|
2653
|
+
}),
|
|
2654
|
+
);
|
|
2655
|
+
}
|
|
2656
|
+
|
|
2657
|
+
const result = await slackProvisioningService.saveAppToken(
|
|
2658
|
+
agentId,
|
|
2659
|
+
xappToken,
|
|
2660
|
+
);
|
|
2661
|
+
|
|
2662
|
+
if (!result.ok) {
|
|
2663
|
+
return html(
|
|
2664
|
+
renderProvisionXappTokenPage(userEmail, {
|
|
2665
|
+
agentId: result.agentId,
|
|
2666
|
+
error: result.error,
|
|
2667
|
+
}),
|
|
2668
|
+
);
|
|
2669
|
+
}
|
|
2670
|
+
|
|
2671
|
+
return html(
|
|
2672
|
+
renderProvisionCompletePage(userEmail, {
|
|
2673
|
+
success: true,
|
|
2674
|
+
agentId: result.agentId,
|
|
2675
|
+
}),
|
|
2676
|
+
);
|
|
2677
|
+
},
|
|
2678
|
+
);
|
|
2679
|
+
|
|
2680
|
+
// ─── connect-github (UAP-1.2) ───────────────────────────────────────────────
|
|
2681
|
+
// Per-agent equivalents of the legacy /admin/provision/* GitHub App/PAT
|
|
2682
|
+
// wizard branches, scoped to an already-existing agent id. All three
|
|
2683
|
+
// delegate to the same GithubProvisioningService the wizard now calls —
|
|
2684
|
+
// pure routing/rendering here, no business logic. Supports all three modes
|
|
2685
|
+
// selected via `ghAuthMode`/`ghAppMode` form fields, mirroring the legacy
|
|
2686
|
+
// wizard's form shape: pat, app+manual, app+auto.
|
|
2687
|
+
|
|
2688
|
+
app.post("/admin/agents/:id/connect-github", requireAuth, async (c) => {
|
|
2689
|
+
const agentId = c.req.param("id");
|
|
2690
|
+
if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
|
|
2691
|
+
return new Response("Forbidden", { status: 403 });
|
|
2692
|
+
}
|
|
2693
|
+
const userEmail = c.var.userEmail;
|
|
2694
|
+
|
|
2695
|
+
let ghAuthMode: string | undefined;
|
|
2696
|
+
let ghPat: string | undefined;
|
|
2697
|
+
let ghAppMode: string | undefined;
|
|
2698
|
+
let ghAppId: string | undefined;
|
|
2699
|
+
let ghAppInstallationId: string | undefined;
|
|
2700
|
+
let ghAppPrivateKey: string | undefined;
|
|
2701
|
+
let githubOrg: string | undefined;
|
|
2702
|
+
let ghAppPublic = false;
|
|
2703
|
+
try {
|
|
2704
|
+
const formData = await c.req.formData();
|
|
2705
|
+
ghAuthMode = formData.get("ghAuthMode")?.toString() ?? "pat";
|
|
2706
|
+
ghPat = formData.get("ghPat")?.toString();
|
|
2707
|
+
ghAppMode = formData.get("ghAppMode")?.toString() ?? "manual";
|
|
2708
|
+
ghAppId = formData.get("ghAppId")?.toString();
|
|
2709
|
+
ghAppInstallationId = formData.get("ghAppInstallationId")?.toString();
|
|
2710
|
+
// The detail page's "Use existing GitHub App" action (UAP-5.4) submits
|
|
2711
|
+
// the private key as a file upload rather than pasted text — read its
|
|
2712
|
+
// contents server-side. Falls back to a plain ghAppPrivateKey text
|
|
2713
|
+
// field for back-compat with any other caller still posting it that
|
|
2714
|
+
// way (e.g. API clients).
|
|
2715
|
+
const ghAppPrivateKeyFile = formData.get("ghAppPrivateKeyFile");
|
|
2716
|
+
ghAppPrivateKey =
|
|
2717
|
+
ghAppPrivateKeyFile instanceof File
|
|
2718
|
+
? await ghAppPrivateKeyFile.text()
|
|
2719
|
+
: formData.get("ghAppPrivateKey")?.toString();
|
|
2720
|
+
githubOrg = formData.get("githubOrg")?.toString()?.trim();
|
|
2721
|
+
ghAppPublic = formData.get("ghAppPublic")?.toString() === "true";
|
|
2722
|
+
} catch {
|
|
2723
|
+
return html(
|
|
2724
|
+
renderProvisionCompletePage(userEmail, {
|
|
2725
|
+
success: false,
|
|
2726
|
+
agentId,
|
|
2727
|
+
error: "Invalid form submission.",
|
|
2728
|
+
}),
|
|
2729
|
+
);
|
|
2730
|
+
}
|
|
2731
|
+
|
|
2732
|
+
if (ghAuthMode === "pat") {
|
|
2733
|
+
const result = await githubProvisioningService.startPatConnect(
|
|
2734
|
+
agentId,
|
|
2735
|
+
ghPat,
|
|
2736
|
+
);
|
|
2737
|
+
if (!result.ok) {
|
|
2738
|
+
return html(
|
|
2739
|
+
renderProvisionCompletePage(userEmail, {
|
|
2740
|
+
success: false,
|
|
2741
|
+
agentId: result.agentId,
|
|
2742
|
+
error: result.error,
|
|
2743
|
+
}),
|
|
2744
|
+
);
|
|
2745
|
+
}
|
|
2746
|
+
return html(
|
|
2747
|
+
renderProvisionCompletePage(userEmail, {
|
|
2748
|
+
success: true,
|
|
2749
|
+
agentId: result.agentId,
|
|
2750
|
+
}),
|
|
2751
|
+
);
|
|
2752
|
+
}
|
|
2753
|
+
|
|
2754
|
+
if (ghAppMode === "auto") {
|
|
2755
|
+
const result = await githubProvisioningService.startAppAutoConnect(
|
|
2756
|
+
agentId,
|
|
2757
|
+
githubOrg,
|
|
2758
|
+
{
|
|
2759
|
+
redirectUri: `${appBaseUrl}/admin/agents/${agentId}/connect-github/callback`,
|
|
2760
|
+
setupUrl: `${appBaseUrl}/admin/agents/${agentId}/connect-github/installed`,
|
|
2761
|
+
},
|
|
2762
|
+
{ public: ghAppPublic },
|
|
2763
|
+
);
|
|
2764
|
+
if (!result.ok) {
|
|
2765
|
+
return html(
|
|
2766
|
+
renderProvisionCompletePage(userEmail, {
|
|
2767
|
+
success: false,
|
|
2768
|
+
agentId,
|
|
2769
|
+
error: result.error,
|
|
2770
|
+
}),
|
|
2771
|
+
);
|
|
2772
|
+
}
|
|
2773
|
+
setCookie(c, GITHUB_PROVISION_STATE_COOKIE, result.provisionStateToken, {
|
|
2774
|
+
httpOnly: true,
|
|
2775
|
+
maxAge: GITHUB_PROVISION_STATE_TTL_SECONDS,
|
|
2776
|
+
sameSite: "Lax",
|
|
2777
|
+
path: "/",
|
|
2778
|
+
secure: appBaseUrl.startsWith("https://"),
|
|
2779
|
+
});
|
|
2780
|
+
// Use c.html() so the Set-Cookie header from setCookie() is included
|
|
2781
|
+
return c.html(
|
|
2782
|
+
renderGithubAppManifestRedirectPage(userEmail, {
|
|
2783
|
+
githubOrg: result.githubOrg,
|
|
2784
|
+
manifest: result.manifest,
|
|
2785
|
+
}),
|
|
2786
|
+
);
|
|
2787
|
+
}
|
|
2788
|
+
|
|
2789
|
+
// ghAppMode === "manual" (default)
|
|
2790
|
+
const result = await githubProvisioningService.startAppManualConnect(
|
|
2791
|
+
agentId,
|
|
2792
|
+
{ ghAppId, ghAppInstallationId, ghAppPrivateKey },
|
|
2793
|
+
);
|
|
2794
|
+
if (!result.ok) {
|
|
2795
|
+
return html(
|
|
2796
|
+
renderProvisionCompletePage(userEmail, {
|
|
2797
|
+
success: false,
|
|
2798
|
+
agentId: result.agentId,
|
|
2799
|
+
error: result.error,
|
|
2800
|
+
}),
|
|
2801
|
+
);
|
|
2802
|
+
}
|
|
2803
|
+
return html(
|
|
2804
|
+
renderProvisionCompletePage(userEmail, {
|
|
2805
|
+
success: true,
|
|
2806
|
+
agentId: result.agentId,
|
|
2807
|
+
}),
|
|
2808
|
+
);
|
|
2809
|
+
});
|
|
2810
|
+
|
|
2811
|
+
app.get(
|
|
2812
|
+
"/admin/agents/:id/connect-github/callback",
|
|
2813
|
+
requireAuth,
|
|
2814
|
+
async (c) => {
|
|
2815
|
+
const agentId = c.req.param("id");
|
|
2816
|
+
if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
|
|
2817
|
+
return new Response("Forbidden", { status: 403 });
|
|
2818
|
+
}
|
|
2819
|
+
const userEmail = c.var.userEmail;
|
|
2820
|
+
|
|
2821
|
+
const rawStateCookie = getCookie(c, GITHUB_PROVISION_STATE_COOKIE);
|
|
2822
|
+
const code = c.req.query("code");
|
|
2823
|
+
const result = await githubProvisioningService.completeConnect(
|
|
2824
|
+
rawStateCookie,
|
|
2825
|
+
code,
|
|
2826
|
+
agentId,
|
|
2827
|
+
);
|
|
2828
|
+
|
|
2829
|
+
if (result.outcome === "invalid_state") {
|
|
2830
|
+
deleteCookie(c, GITHUB_PROVISION_STATE_COOKIE);
|
|
2831
|
+
return html(
|
|
2832
|
+
renderGithubAppInstalledPage(userEmail, {
|
|
2833
|
+
success: false,
|
|
2834
|
+
error: result.error,
|
|
2835
|
+
}),
|
|
2836
|
+
);
|
|
2837
|
+
}
|
|
2838
|
+
|
|
2839
|
+
if (result.outcome === "agent_mismatch") {
|
|
2840
|
+
deleteCookie(c, GITHUB_PROVISION_STATE_COOKIE);
|
|
2841
|
+
return new Response("Forbidden", { status: 403 });
|
|
2842
|
+
}
|
|
2843
|
+
|
|
2844
|
+
if (result.outcome === "missing_code") {
|
|
2845
|
+
// Cookie must remain intact so the user can restart the provision flow.
|
|
2846
|
+
return html(
|
|
2847
|
+
renderGithubAppInstalledPage(userEmail, {
|
|
2848
|
+
success: false,
|
|
2849
|
+
error: result.error,
|
|
2850
|
+
}),
|
|
2851
|
+
);
|
|
2852
|
+
}
|
|
2853
|
+
|
|
2854
|
+
// Every other outcome consumed the cookie's manifest code — clear it now.
|
|
2855
|
+
deleteCookie(c, GITHUB_PROVISION_STATE_COOKIE);
|
|
2856
|
+
|
|
2857
|
+
if (result.outcome === "exchange_failed") {
|
|
2858
|
+
return html(
|
|
2859
|
+
renderGithubAppInstalledPage(userEmail, {
|
|
2860
|
+
success: false,
|
|
2861
|
+
error: result.error,
|
|
2862
|
+
}),
|
|
2863
|
+
);
|
|
2864
|
+
}
|
|
2865
|
+
|
|
2866
|
+
// result.outcome === "success"
|
|
2867
|
+
return html(
|
|
2868
|
+
renderGithubAppInstallPage(userEmail, {
|
|
2869
|
+
installUrl: result.installUrl,
|
|
2870
|
+
}),
|
|
2871
|
+
);
|
|
2872
|
+
},
|
|
2873
|
+
);
|
|
2874
|
+
|
|
2875
|
+
app.get(
|
|
2876
|
+
"/admin/agents/:id/connect-github/installed",
|
|
2877
|
+
requireAuth,
|
|
2878
|
+
async (c) => {
|
|
2879
|
+
const agentId = c.req.param("id");
|
|
2880
|
+
if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
|
|
2881
|
+
return new Response("Forbidden", { status: 403 });
|
|
2882
|
+
}
|
|
2883
|
+
const userEmail = c.var.userEmail;
|
|
2884
|
+
|
|
2885
|
+
const rawStateCookie = getCookie(c, GITHUB_PROVISION_STATE_COOKIE);
|
|
2886
|
+
const installationId = c.req.query("installation_id");
|
|
2887
|
+
const result = await githubProvisioningService.completeInstalled(
|
|
2888
|
+
rawStateCookie,
|
|
2889
|
+
installationId,
|
|
2890
|
+
agentId,
|
|
2891
|
+
);
|
|
2892
|
+
|
|
2893
|
+
if (result.outcome === "invalid_state") {
|
|
2894
|
+
deleteCookie(c, GITHUB_PROVISION_STATE_COOKIE);
|
|
2895
|
+
return html(
|
|
2896
|
+
renderGithubAppInstalledPage(userEmail, {
|
|
2897
|
+
success: false,
|
|
2898
|
+
error: result.error,
|
|
2899
|
+
}),
|
|
2900
|
+
);
|
|
2901
|
+
}
|
|
2902
|
+
|
|
2903
|
+
if (result.outcome === "agent_mismatch") {
|
|
2904
|
+
deleteCookie(c, GITHUB_PROVISION_STATE_COOKIE);
|
|
2905
|
+
return new Response("Forbidden", { status: 403 });
|
|
2906
|
+
}
|
|
2907
|
+
|
|
2908
|
+
if (result.outcome === "invalid_installation_id") {
|
|
2909
|
+
deleteCookie(c, GITHUB_PROVISION_STATE_COOKIE);
|
|
2910
|
+
return html(
|
|
2911
|
+
renderGithubAppInstalledPage(userEmail, {
|
|
2912
|
+
success: false,
|
|
2913
|
+
error: result.error,
|
|
2914
|
+
}),
|
|
2915
|
+
);
|
|
2916
|
+
}
|
|
2917
|
+
|
|
2918
|
+
// result.outcome === "success"
|
|
2919
|
+
deleteCookie(c, GITHUB_PROVISION_STATE_COOKIE);
|
|
2920
|
+
return html(
|
|
2921
|
+
renderGithubAppInstalledPage(userEmail, {
|
|
2922
|
+
success: true,
|
|
2923
|
+
alreadyStored: result.alreadyStored,
|
|
2924
|
+
}),
|
|
2925
|
+
);
|
|
2926
|
+
},
|
|
2927
|
+
);
|
|
2928
|
+
|
|
2929
|
+
app.get(
|
|
2930
|
+
"/admin/agents/:id/connect-github/add-org",
|
|
2931
|
+
requireAuth,
|
|
2932
|
+
async (c) => {
|
|
2933
|
+
const agentId = c.req.param("id");
|
|
2934
|
+
if (!(await assertAgentAccess(agentId, c.var.userEmail, c.var.isAdmin))) {
|
|
2935
|
+
return new Response("Forbidden", { status: 403 });
|
|
2936
|
+
}
|
|
2937
|
+
const result = await githubProvisioningService.getAddOrgUrl(agentId);
|
|
2938
|
+
if (!result.ok) {
|
|
2939
|
+
return c.redirect(
|
|
2940
|
+
`/admin/agents/${agentId}?error=${encodeURIComponent(result.error)}`,
|
|
2941
|
+
302,
|
|
2942
|
+
);
|
|
2943
|
+
}
|
|
2944
|
+
return c.redirect(result.url, 302);
|
|
2945
|
+
},
|
|
2946
|
+
);
|
|
2947
|
+
|
|
2948
|
+
// ─── Provisioning flow ────────────────────────────────────────────────────
|
|
2949
|
+
|
|
2950
|
+
app.get("/admin/provision", (c) => c.redirect("/admin/agents/new", 302));
|
|
2951
|
+
|
|
2952
|
+
// ─── Member management (admin, or any agent-access holder when self-serve is on) ──────────────────────────────────────
|
|
2953
|
+
|
|
2954
|
+
app.post("/admin/agents/:id/members", requireAuth, async (c) => {
|
|
2955
|
+
const agentId = c.req.param("id");
|
|
2956
|
+
if (
|
|
2957
|
+
!(await assertCanManageAgent(agentId, c.var.userEmail, c.var.isAdmin))
|
|
2958
|
+
) {
|
|
2959
|
+
return new Response("Forbidden", { status: 403 });
|
|
2960
|
+
}
|
|
2961
|
+
let email: string | undefined;
|
|
2962
|
+
try {
|
|
2963
|
+
const formData = await c.req.formData();
|
|
2964
|
+
email = formData.get("email")?.toString()?.toLowerCase();
|
|
2965
|
+
} catch {
|
|
2966
|
+
return c.redirect(`/admin/agents/${agentId}`, 302);
|
|
2967
|
+
}
|
|
2968
|
+
if (email) {
|
|
2969
|
+
try {
|
|
2970
|
+
await agentMemberService.add(agentId, email);
|
|
2971
|
+
} catch {
|
|
2972
|
+
// unique constraint violation — already a member, ignore
|
|
2973
|
+
}
|
|
2974
|
+
}
|
|
2975
|
+
return c.redirect(`/admin/agents/${agentId}`, 302);
|
|
2976
|
+
});
|
|
2977
|
+
|
|
2978
|
+
app.post("/admin/agents/:id/members/delete", requireAuth, async (c) => {
|
|
2979
|
+
const agentId = c.req.param("id");
|
|
2980
|
+
if (
|
|
2981
|
+
!(await assertCanManageAgent(agentId, c.var.userEmail, c.var.isAdmin))
|
|
2982
|
+
) {
|
|
2983
|
+
return new Response("Forbidden", { status: 403 });
|
|
2984
|
+
}
|
|
2985
|
+
let memberId: string | undefined;
|
|
2986
|
+
try {
|
|
2987
|
+
const formData = await c.req.formData();
|
|
2988
|
+
memberId = formData.get("memberId")?.toString();
|
|
2989
|
+
} catch {
|
|
2990
|
+
return c.redirect(`/admin/agents/${agentId}`, 302);
|
|
2991
|
+
}
|
|
2992
|
+
if (memberId) {
|
|
2993
|
+
try {
|
|
2994
|
+
await agentMemberService.remove(agentId, memberId);
|
|
2995
|
+
} catch {
|
|
2996
|
+
// already gone, ignore
|
|
2997
|
+
}
|
|
2998
|
+
}
|
|
2999
|
+
return c.redirect(`/admin/agents/${agentId}`, 302);
|
|
3000
|
+
});
|
|
3001
|
+
|
|
3002
|
+
// ─── Account page (SSP-3.2) ───────────────────────────────────────────────
|
|
3003
|
+
|
|
3004
|
+
if (accountServices) {
|
|
3005
|
+
registerAccountRoutes(app, {
|
|
3006
|
+
enabled: accountNavEnabled,
|
|
3007
|
+
requireAuth,
|
|
3008
|
+
...accountServices,
|
|
3009
|
+
html,
|
|
3010
|
+
});
|
|
3011
|
+
registerAdminAccountsRoutes(app, {
|
|
3012
|
+
enabled: accountNavEnabled,
|
|
3013
|
+
requireAuth,
|
|
3014
|
+
...accountServices,
|
|
3015
|
+
agents: agentService,
|
|
3016
|
+
provisioner,
|
|
3017
|
+
html,
|
|
3018
|
+
});
|
|
3019
|
+
}
|
|
3020
|
+
|
|
3021
|
+
// ─── Chat routes ─────────────────────────────────────────────────────────
|
|
3022
|
+
|
|
3023
|
+
/**
|
|
3024
|
+
* Chat access gate (admins: always; members: only agents they belong to).
|
|
3025
|
+
* The chat service is reached with a single admin token, so for non-admins
|
|
3026
|
+
* the thread must also be verified to belong to the URL's agent — otherwise a
|
|
3027
|
+
* member could pass their own agentId with another agent's threadId.
|
|
3028
|
+
*/
|
|
3029
|
+
async function canAccessChat(
|
|
3030
|
+
userEmail: string,
|
|
3031
|
+
isAdmin: boolean,
|
|
3032
|
+
agentId: string,
|
|
3033
|
+
threadId?: string,
|
|
3034
|
+
): Promise<boolean> {
|
|
3035
|
+
if (isAdmin) return true;
|
|
3036
|
+
if (!agentId || !(await assertAgentAccess(agentId, userEmail, false))) {
|
|
3037
|
+
return false;
|
|
3038
|
+
}
|
|
3039
|
+
if (!threadId) return true;
|
|
3040
|
+
if (!chatClient) return true;
|
|
3041
|
+
try {
|
|
3042
|
+
const thread = await chatClient.getThread(threadId);
|
|
3043
|
+
return thread.agentId === agentId;
|
|
3044
|
+
} catch {
|
|
3045
|
+
return false;
|
|
3046
|
+
}
|
|
3047
|
+
}
|
|
3048
|
+
|
|
3049
|
+
/**
|
|
3050
|
+
* SSP-8.4: message-send gate. A suspended / trial-expired account's agents
|
|
3051
|
+
* stop accepting chat messages (read-only history stays available). Platform
|
|
3052
|
+
* admins are exempt; agents with no account are unaffected.
|
|
3053
|
+
*/
|
|
3054
|
+
async function chatSendBlockedMessage(
|
|
3055
|
+
agentId: string,
|
|
3056
|
+
isAdmin: boolean,
|
|
3057
|
+
): Promise<string | null> {
|
|
3058
|
+
if (isAdmin || !accountService) return null;
|
|
3059
|
+
const agent = await agentService.getDetail(agentId);
|
|
3060
|
+
if (!agent?.accountId) return null;
|
|
3061
|
+
const account = await accountService.getById(agent.accountId);
|
|
3062
|
+
if (account?.status === "suspended") {
|
|
3063
|
+
return "This account is suspended — chat messages can't be sent. Contact support to reactivate it.";
|
|
3064
|
+
}
|
|
3065
|
+
if (account?.status === "trial_expired") {
|
|
3066
|
+
return "This account's trial has expired — chat messages can't be sent. Upgrade to continue.";
|
|
3067
|
+
}
|
|
3068
|
+
return null;
|
|
3069
|
+
}
|
|
3070
|
+
|
|
3071
|
+
app.get("/admin/chat", requireAuth, async (c) => {
|
|
3072
|
+
const selectedAgentId = c.req.query("agentId") || undefined;
|
|
3073
|
+
const q = c.req.query("q") || undefined;
|
|
3074
|
+
const agents: AgentOption[] = c.var.isAdmin
|
|
3075
|
+
? await agentService.listOptions()
|
|
3076
|
+
: (await resolveAccessibleAgents(c.var.userEmail, false)).map((a) => ({
|
|
3077
|
+
id: a.id,
|
|
3078
|
+
name: a.name,
|
|
3079
|
+
}));
|
|
3080
|
+
if (
|
|
3081
|
+
selectedAgentId &&
|
|
3082
|
+
!c.var.isAdmin &&
|
|
3083
|
+
!agents.some((a) => a.id === selectedAgentId)
|
|
3084
|
+
) {
|
|
3085
|
+
return new Response("Forbidden", { status: 403 });
|
|
3086
|
+
}
|
|
3087
|
+
|
|
3088
|
+
if (!chatClient) {
|
|
3089
|
+
return html(
|
|
3090
|
+
renderChatPage(agents, selectedAgentId, null, c.var.userEmail, q),
|
|
3091
|
+
);
|
|
3092
|
+
}
|
|
3093
|
+
|
|
3094
|
+
let threads: ChatThread[] = [];
|
|
3095
|
+
if (selectedAgentId) {
|
|
3096
|
+
try {
|
|
3097
|
+
const result = await chatClient.listThreads(selectedAgentId);
|
|
3098
|
+
threads = result.threads;
|
|
3099
|
+
// Server-side filter by search query
|
|
3100
|
+
if (q) {
|
|
3101
|
+
const lowerQ = q.toLowerCase();
|
|
3102
|
+
threads = threads.filter((t) =>
|
|
3103
|
+
(t.title ?? "").toLowerCase().includes(lowerQ),
|
|
3104
|
+
);
|
|
3105
|
+
}
|
|
3106
|
+
} catch {
|
|
3107
|
+
threads = [];
|
|
3108
|
+
}
|
|
3109
|
+
}
|
|
3110
|
+
|
|
3111
|
+
return html(
|
|
3112
|
+
renderChatPage(agents, selectedAgentId, threads, c.var.userEmail, q),
|
|
3113
|
+
);
|
|
3114
|
+
});
|
|
3115
|
+
|
|
3116
|
+
app.get("/admin/chat/:agentId/threads/:threadId", requireAuth, async (c) => {
|
|
3117
|
+
if (
|
|
3118
|
+
!(await canAccessChat(
|
|
3119
|
+
c.var.userEmail,
|
|
3120
|
+
c.var.isAdmin,
|
|
3121
|
+
c.req.param("agentId") ?? "",
|
|
3122
|
+
c.req.param("threadId"),
|
|
3123
|
+
))
|
|
3124
|
+
) {
|
|
3125
|
+
return new Response("Forbidden", { status: 403 });
|
|
3126
|
+
}
|
|
3127
|
+
|
|
3128
|
+
const agentId = c.req.param("agentId");
|
|
3129
|
+
const threadId = c.req.param("threadId");
|
|
3130
|
+
|
|
3131
|
+
// Test-only override for the live-progress stall-warning threshold so e2e
|
|
3132
|
+
// doesn't need a real 2-minute wait. Clamped to a sane range; production
|
|
3133
|
+
// never passes it, so the 120s default stands.
|
|
3134
|
+
const rawStall = c.req.query("stallWarnAfterMs");
|
|
3135
|
+
const stallWarnAfterMs =
|
|
3136
|
+
rawStall !== undefined &&
|
|
3137
|
+
Number.isFinite(Number(rawStall)) &&
|
|
3138
|
+
Number(rawStall) > 0
|
|
3139
|
+
? Number(rawStall)
|
|
3140
|
+
: undefined;
|
|
3141
|
+
|
|
3142
|
+
if (!chatClient) {
|
|
3143
|
+
return html(
|
|
3144
|
+
renderChatThreadPage(agentId, null, null, null, c.var.userEmail),
|
|
3145
|
+
);
|
|
3146
|
+
}
|
|
3147
|
+
|
|
3148
|
+
try {
|
|
3149
|
+
const [thread, messagesResult, threadListResult, statsResult] =
|
|
3150
|
+
await Promise.all([
|
|
3151
|
+
chatClient.getThread(threadId),
|
|
3152
|
+
chatClient.listMessages(threadId),
|
|
3153
|
+
chatClient.listThreads(agentId).catch(() => null),
|
|
3154
|
+
chatClient.getThreadStats(threadId).catch(() => null),
|
|
3155
|
+
]);
|
|
3156
|
+
const threadList = threadListResult ? threadListResult.threads : null;
|
|
3157
|
+
return html(
|
|
3158
|
+
renderChatThreadPage(
|
|
3159
|
+
agentId,
|
|
3160
|
+
thread,
|
|
3161
|
+
messagesResult.messages,
|
|
3162
|
+
threadList,
|
|
3163
|
+
c.var.userEmail,
|
|
3164
|
+
statsResult,
|
|
3165
|
+
{ stallWarnAfterMs, pushEnabled, vapidPublicKey },
|
|
3166
|
+
),
|
|
3167
|
+
);
|
|
3168
|
+
} catch {
|
|
3169
|
+
return html(
|
|
3170
|
+
renderChatThreadPage(agentId, null, null, null, c.var.userEmail),
|
|
3171
|
+
);
|
|
3172
|
+
}
|
|
3173
|
+
});
|
|
3174
|
+
|
|
3175
|
+
app.post("/admin/chat/:agentId/threads", requireAuth, async (c) => {
|
|
3176
|
+
if (
|
|
3177
|
+
!(await canAccessChat(
|
|
3178
|
+
c.var.userEmail,
|
|
3179
|
+
c.var.isAdmin,
|
|
3180
|
+
c.req.param("agentId") ?? "",
|
|
3181
|
+
c.req.param("threadId"),
|
|
3182
|
+
))
|
|
3183
|
+
) {
|
|
3184
|
+
return new Response("Forbidden", { status: 403 });
|
|
3185
|
+
}
|
|
3186
|
+
|
|
3187
|
+
const agentId = c.req.param("agentId");
|
|
3188
|
+
|
|
3189
|
+
if (!chatClient) {
|
|
3190
|
+
return c.redirect(
|
|
3191
|
+
`/admin/chat?agentId=${encodeURIComponent(agentId)}`,
|
|
3192
|
+
302,
|
|
3193
|
+
);
|
|
3194
|
+
}
|
|
3195
|
+
|
|
3196
|
+
let title: string | undefined;
|
|
3197
|
+
try {
|
|
3198
|
+
const formData = await c.req.formData();
|
|
3199
|
+
title = formData.get("title")?.toString()?.trim() || undefined;
|
|
3200
|
+
} catch {
|
|
3201
|
+
return c.redirect(
|
|
3202
|
+
`/admin/chat?agentId=${encodeURIComponent(agentId)}`,
|
|
3203
|
+
302,
|
|
3204
|
+
);
|
|
3205
|
+
}
|
|
3206
|
+
|
|
3207
|
+
try {
|
|
3208
|
+
const thread = await chatClient.createThread(agentId, { title });
|
|
3209
|
+
return c.redirect(
|
|
3210
|
+
`/admin/chat/${encodeURIComponent(agentId)}/threads/${encodeURIComponent(thread.id)}`,
|
|
3211
|
+
302,
|
|
3212
|
+
);
|
|
3213
|
+
} catch {
|
|
3214
|
+
return c.redirect(
|
|
3215
|
+
`/admin/chat?agentId=${encodeURIComponent(agentId)}`,
|
|
3216
|
+
302,
|
|
3217
|
+
);
|
|
3218
|
+
}
|
|
3219
|
+
});
|
|
3220
|
+
|
|
3221
|
+
// Upload route — registered BEFORE the form POST /messages route so the more
|
|
3222
|
+
// specific `/messages/upload` segment matches first. Returns JSON so the inline
|
|
3223
|
+
// send flow can surface validation errors without a full-page redirect.
|
|
3224
|
+
app.post(
|
|
3225
|
+
"/admin/chat/:agentId/threads/:threadId/messages/upload",
|
|
3226
|
+
requireAuth,
|
|
3227
|
+
async (c) => {
|
|
3228
|
+
if (
|
|
3229
|
+
!(await canAccessChat(
|
|
3230
|
+
c.var.userEmail,
|
|
3231
|
+
c.var.isAdmin,
|
|
3232
|
+
c.req.param("agentId") ?? "",
|
|
3233
|
+
c.req.param("threadId"),
|
|
3234
|
+
))
|
|
3235
|
+
) {
|
|
3236
|
+
return new Response("Forbidden", { status: 403 });
|
|
3237
|
+
}
|
|
3238
|
+
|
|
3239
|
+
const sendBlocked = await chatSendBlockedMessage(
|
|
3240
|
+
c.req.param("agentId") ?? "",
|
|
3241
|
+
c.var.isAdmin,
|
|
3242
|
+
);
|
|
3243
|
+
if (sendBlocked) {
|
|
3244
|
+
return c.json(
|
|
3245
|
+
{ error: "account_suspended", message: sendBlocked },
|
|
3246
|
+
403,
|
|
3247
|
+
);
|
|
3248
|
+
}
|
|
3249
|
+
|
|
3250
|
+
const agentId = c.req.param("agentId");
|
|
3251
|
+
const threadId = c.req.param("threadId");
|
|
3252
|
+
|
|
3253
|
+
if (!chatClient) {
|
|
3254
|
+
return c.json({ error: "chat service not configured" }, 503);
|
|
3255
|
+
}
|
|
3256
|
+
|
|
3257
|
+
let body: string | undefined;
|
|
3258
|
+
let file: File | null = null;
|
|
3259
|
+
try {
|
|
3260
|
+
const formData = await c.req.formData();
|
|
3261
|
+
body = formData.get("body")?.toString()?.trim();
|
|
3262
|
+
const rawFile = formData.get("file");
|
|
3263
|
+
file = rawFile instanceof File && rawFile.size > 0 ? rawFile : null;
|
|
3264
|
+
} catch {
|
|
3265
|
+
return c.json({ error: "invalid form data" }, 400);
|
|
3266
|
+
}
|
|
3267
|
+
|
|
3268
|
+
let attachment:
|
|
3269
|
+
| { filename: string; size: number; bytes: Uint8Array }
|
|
3270
|
+
| undefined;
|
|
3271
|
+
if (file) {
|
|
3272
|
+
const validation = validateAttachment(file.name, file.size, file.type);
|
|
3273
|
+
if (!validation.ok) {
|
|
3274
|
+
return c.json({ error: validation.error }, validation.status);
|
|
3275
|
+
}
|
|
3276
|
+
attachment = {
|
|
3277
|
+
filename: file.name,
|
|
3278
|
+
size: file.size,
|
|
3279
|
+
bytes: new Uint8Array(await file.arrayBuffer()),
|
|
3280
|
+
};
|
|
3281
|
+
}
|
|
3282
|
+
|
|
3283
|
+
// A message needs at least a body or a file.
|
|
3284
|
+
if (!body && !attachment) {
|
|
3285
|
+
return c.json({ error: "message body or file is required" }, 400);
|
|
3286
|
+
}
|
|
3287
|
+
|
|
3288
|
+
try {
|
|
3289
|
+
const message = await chatClient.createMessage(
|
|
3290
|
+
threadId,
|
|
3291
|
+
"user",
|
|
3292
|
+
body ?? "",
|
|
3293
|
+
attachment,
|
|
3294
|
+
);
|
|
3295
|
+
// Record that this user is watching the thread so the agent's reply
|
|
3296
|
+
// can be targeted back to them via push (CFB-4.2). This is the route
|
|
3297
|
+
// the live chat UI's send box actually posts to (see
|
|
3298
|
+
// admin-ui-pages.ts's uploadUrl) — the form POST /messages and
|
|
3299
|
+
// /messages.json routes above are alternate entry points with their
|
|
3300
|
+
// own identical call; all three must stay in sync. Best-effort —
|
|
3301
|
+
// never block the response.
|
|
3302
|
+
if (pushEnabled) {
|
|
3303
|
+
await pushService?.recordWatch(c.var.userEmail, agentId, threadId);
|
|
3304
|
+
}
|
|
3305
|
+
return c.json({ message }, 201);
|
|
3306
|
+
} catch {
|
|
3307
|
+
return c.json({ error: "failed to create message" }, 500);
|
|
3308
|
+
}
|
|
3309
|
+
},
|
|
3310
|
+
);
|
|
3311
|
+
|
|
3312
|
+
// Attachment proxy (VM-3.1) — streams a message's attachment bytes back to
|
|
3313
|
+
// the browser so audio attachments can play inline via `<audio src>`. The
|
|
3314
|
+
// browser never holds the chat service's admin bearer token, so it can't
|
|
3315
|
+
// call the chat service's GET /:id/attachment directly; this route fetches
|
|
3316
|
+
// on its behalf and re-serves with a real audio/* Content-Type (the chat
|
|
3317
|
+
// service itself always streams application/octet-stream, which most
|
|
3318
|
+
// browsers won't play inline in an <audio> element).
|
|
3319
|
+
app.get(
|
|
3320
|
+
"/admin/chat/:agentId/threads/:threadId/messages/:messageId/attachment",
|
|
3321
|
+
requireAuth,
|
|
3322
|
+
async (c) => {
|
|
3323
|
+
if (
|
|
3324
|
+
!(await canAccessChat(
|
|
3325
|
+
c.var.userEmail,
|
|
3326
|
+
c.var.isAdmin,
|
|
3327
|
+
c.req.param("agentId") ?? "",
|
|
3328
|
+
c.req.param("threadId"),
|
|
3329
|
+
))
|
|
3330
|
+
) {
|
|
3331
|
+
return new Response("Forbidden", { status: 403 });
|
|
3332
|
+
}
|
|
3333
|
+
|
|
3334
|
+
if (!chatClient) {
|
|
3335
|
+
return c.json({ error: "chat service not configured" }, 503);
|
|
3336
|
+
}
|
|
3337
|
+
|
|
3338
|
+
const threadId = c.req.param("threadId");
|
|
3339
|
+
const messageId = c.req.param("messageId");
|
|
3340
|
+
|
|
3341
|
+
try {
|
|
3342
|
+
const attachment = await chatClient.getAttachment(threadId, messageId);
|
|
3343
|
+
if (!attachment) {
|
|
3344
|
+
return c.json({ error: "attachment not found" }, 404);
|
|
3345
|
+
}
|
|
3346
|
+
const contentType =
|
|
3347
|
+
audioContentTypeForFilename(attachment.filename) ??
|
|
3348
|
+
"application/octet-stream";
|
|
3349
|
+
// .slice() normalizes to Uint8Array<ArrayBuffer> — Hono's Data type
|
|
3350
|
+
// requires that exact generic, not the wider Uint8Array<ArrayBufferLike>
|
|
3351
|
+
// that new Uint8Array(await res.arrayBuffer()) produces.
|
|
3352
|
+
return c.body(attachment.bytes.slice(), 200, {
|
|
3353
|
+
"Content-Type": contentType,
|
|
3354
|
+
"Content-Disposition": `inline; filename="${attachment.filename.replace(/"/g, "")}"`,
|
|
3355
|
+
});
|
|
3356
|
+
} catch {
|
|
3357
|
+
return c.json({ error: "failed to fetch attachment" }, 500);
|
|
3358
|
+
}
|
|
3359
|
+
},
|
|
3360
|
+
);
|
|
3361
|
+
|
|
3362
|
+
app.post(
|
|
3363
|
+
"/admin/chat/:agentId/threads/:threadId/messages",
|
|
3364
|
+
requireAuth,
|
|
3365
|
+
async (c) => {
|
|
3366
|
+
if (
|
|
3367
|
+
!(await canAccessChat(
|
|
3368
|
+
c.var.userEmail,
|
|
3369
|
+
c.var.isAdmin,
|
|
3370
|
+
c.req.param("agentId") ?? "",
|
|
3371
|
+
c.req.param("threadId"),
|
|
3372
|
+
))
|
|
3373
|
+
) {
|
|
3374
|
+
return new Response("Forbidden", { status: 403 });
|
|
3375
|
+
}
|
|
3376
|
+
|
|
3377
|
+
const sendBlocked = await chatSendBlockedMessage(
|
|
3378
|
+
c.req.param("agentId") ?? "",
|
|
3379
|
+
c.var.isAdmin,
|
|
3380
|
+
);
|
|
3381
|
+
if (sendBlocked) {
|
|
3382
|
+
return new Response(sendBlocked, { status: 403 });
|
|
3383
|
+
}
|
|
3384
|
+
|
|
3385
|
+
const agentId = c.req.param("agentId");
|
|
3386
|
+
const threadId = c.req.param("threadId");
|
|
3387
|
+
|
|
3388
|
+
const backUrl = `/admin/chat/${encodeURIComponent(agentId)}/threads/${encodeURIComponent(threadId)}`;
|
|
3389
|
+
|
|
3390
|
+
if (!chatClient) {
|
|
3391
|
+
return c.redirect(backUrl, 302);
|
|
3392
|
+
}
|
|
3393
|
+
|
|
3394
|
+
const ALLOWED_ROLES = ["user", "assistant"] as const;
|
|
3395
|
+
type MessageRole = (typeof ALLOWED_ROLES)[number];
|
|
3396
|
+
|
|
3397
|
+
let body: string | undefined;
|
|
3398
|
+
let role: MessageRole = "user";
|
|
3399
|
+
let file: File | null = null;
|
|
3400
|
+
try {
|
|
3401
|
+
const formData = await c.req.formData();
|
|
3402
|
+
body = formData.get("body")?.toString()?.trim();
|
|
3403
|
+
const rawRole = formData.get("role")?.toString() || "user";
|
|
3404
|
+
role = (ALLOWED_ROLES as readonly string[]).includes(rawRole)
|
|
3405
|
+
? (rawRole as MessageRole)
|
|
3406
|
+
: "user";
|
|
3407
|
+
const rawFile = formData.get("file");
|
|
3408
|
+
file = rawFile instanceof File && rawFile.size > 0 ? rawFile : null;
|
|
3409
|
+
} catch {
|
|
3410
|
+
return c.redirect(backUrl, 302);
|
|
3411
|
+
}
|
|
3412
|
+
|
|
3413
|
+
let attachment:
|
|
3414
|
+
| { filename: string; size: number; bytes: Uint8Array }
|
|
3415
|
+
| undefined;
|
|
3416
|
+
if (file) {
|
|
3417
|
+
const validation = validateAttachment(file.name, file.size, file.type);
|
|
3418
|
+
if (!validation.ok) {
|
|
3419
|
+
// Invalid attachment — bounce back without queuing anything.
|
|
3420
|
+
return c.redirect(backUrl, 302);
|
|
3421
|
+
}
|
|
3422
|
+
attachment = {
|
|
3423
|
+
filename: file.name,
|
|
3424
|
+
size: file.size,
|
|
3425
|
+
bytes: new Uint8Array(await file.arrayBuffer()),
|
|
3426
|
+
};
|
|
3427
|
+
}
|
|
3428
|
+
|
|
3429
|
+
if (!body && !attachment) {
|
|
3430
|
+
return c.redirect(backUrl, 302);
|
|
3431
|
+
}
|
|
3432
|
+
|
|
3433
|
+
try {
|
|
3434
|
+
await chatClient.createMessage(threadId, role, body ?? "", attachment);
|
|
3435
|
+
} catch {
|
|
3436
|
+
// swallow — redirect back regardless
|
|
3437
|
+
}
|
|
3438
|
+
|
|
3439
|
+
// Record that this user is watching the thread so the agent's reply can
|
|
3440
|
+
// be targeted back to them via push (CFB-4.2). Only on user messages, and
|
|
3441
|
+
// only when push is configured. Best-effort — never block the redirect.
|
|
3442
|
+
if (pushEnabled && role === "user") {
|
|
3443
|
+
await pushService?.recordWatch(c.var.userEmail, agentId, threadId);
|
|
3444
|
+
}
|
|
3445
|
+
|
|
3446
|
+
return c.redirect(backUrl, 302);
|
|
3447
|
+
},
|
|
3448
|
+
);
|
|
3449
|
+
|
|
3450
|
+
app.post(
|
|
3451
|
+
"/admin/chat/:agentId/threads/:threadId/rename",
|
|
3452
|
+
requireAuth,
|
|
3453
|
+
async (c) => {
|
|
3454
|
+
if (
|
|
3455
|
+
!(await canAccessChat(
|
|
3456
|
+
c.var.userEmail,
|
|
3457
|
+
c.var.isAdmin,
|
|
3458
|
+
c.req.param("agentId") ?? "",
|
|
3459
|
+
c.req.param("threadId"),
|
|
3460
|
+
))
|
|
3461
|
+
) {
|
|
3462
|
+
return new Response("Forbidden", { status: 403 });
|
|
3463
|
+
}
|
|
3464
|
+
|
|
3465
|
+
const agentId = c.req.param("agentId");
|
|
3466
|
+
const threadId = c.req.param("threadId");
|
|
3467
|
+
const backUrl = `/admin/chat/${encodeURIComponent(agentId)}/threads/${encodeURIComponent(threadId)}`;
|
|
3468
|
+
|
|
3469
|
+
if (!chatClient) {
|
|
3470
|
+
return c.redirect(backUrl, 302);
|
|
3471
|
+
}
|
|
3472
|
+
|
|
3473
|
+
let title: string | undefined;
|
|
3474
|
+
try {
|
|
3475
|
+
const formData = await c.req.formData();
|
|
3476
|
+
title = formData.get("title")?.toString()?.trim() || undefined;
|
|
3477
|
+
} catch {
|
|
3478
|
+
return c.redirect(backUrl, 302);
|
|
3479
|
+
}
|
|
3480
|
+
|
|
3481
|
+
// Guard: skip the API call when title is blank to avoid a silent no-op PATCH
|
|
3482
|
+
if (!title) {
|
|
3483
|
+
return c.redirect(backUrl, 302);
|
|
3484
|
+
}
|
|
3485
|
+
|
|
3486
|
+
try {
|
|
3487
|
+
await chatClient.updateThread(threadId, { title });
|
|
3488
|
+
} catch {
|
|
3489
|
+
// swallow — redirect back regardless
|
|
3490
|
+
}
|
|
3491
|
+
|
|
3492
|
+
return c.redirect(backUrl, 302);
|
|
3493
|
+
},
|
|
3494
|
+
);
|
|
3495
|
+
|
|
3496
|
+
app.post(
|
|
3497
|
+
"/admin/chat/:agentId/threads/:threadId/delete",
|
|
3498
|
+
requireAuth,
|
|
3499
|
+
async (c) => {
|
|
3500
|
+
if (
|
|
3501
|
+
!(await canAccessChat(
|
|
3502
|
+
c.var.userEmail,
|
|
3503
|
+
c.var.isAdmin,
|
|
3504
|
+
c.req.param("agentId") ?? "",
|
|
3505
|
+
c.req.param("threadId"),
|
|
3506
|
+
))
|
|
3507
|
+
) {
|
|
3508
|
+
return new Response("Forbidden", { status: 403 });
|
|
3509
|
+
}
|
|
3510
|
+
|
|
3511
|
+
const agentId = c.req.param("agentId");
|
|
3512
|
+
const threadId = c.req.param("threadId");
|
|
3513
|
+
|
|
3514
|
+
if (!chatClient) {
|
|
3515
|
+
return c.redirect(
|
|
3516
|
+
`/admin/chat?agentId=${encodeURIComponent(agentId)}`,
|
|
3517
|
+
302,
|
|
3518
|
+
);
|
|
3519
|
+
}
|
|
3520
|
+
|
|
3521
|
+
try {
|
|
3522
|
+
await chatClient.deleteThread(threadId);
|
|
3523
|
+
} catch {
|
|
3524
|
+
// swallow — redirect back regardless
|
|
3525
|
+
}
|
|
3526
|
+
|
|
3527
|
+
return c.redirect(
|
|
3528
|
+
`/admin/chat?agentId=${encodeURIComponent(agentId)}`,
|
|
3529
|
+
302,
|
|
3530
|
+
);
|
|
3531
|
+
},
|
|
3532
|
+
);
|
|
3533
|
+
|
|
3534
|
+
// ─── Chat JSON API routes ─────────────────────────────────────────────────
|
|
3535
|
+
|
|
3536
|
+
app.get(
|
|
3537
|
+
"/admin/chat/:agentId/threads/:threadId/messages.json",
|
|
3538
|
+
requireAuth,
|
|
3539
|
+
async (c) => {
|
|
3540
|
+
if (
|
|
3541
|
+
!(await canAccessChat(
|
|
3542
|
+
c.var.userEmail,
|
|
3543
|
+
c.var.isAdmin,
|
|
3544
|
+
c.req.param("agentId") ?? "",
|
|
3545
|
+
c.req.param("threadId"),
|
|
3546
|
+
))
|
|
3547
|
+
) {
|
|
3548
|
+
return new Response("Forbidden", { status: 403 });
|
|
3549
|
+
}
|
|
3550
|
+
|
|
3551
|
+
const agentId = c.req.param("agentId");
|
|
3552
|
+
const threadId = c.req.param("threadId");
|
|
3553
|
+
// ?since=<messageId> → incremental poll: only messages after that id.
|
|
3554
|
+
const since = c.req.query("since") || undefined;
|
|
3555
|
+
|
|
3556
|
+
if (!chatClient) {
|
|
3557
|
+
return c.json({ messages: [] });
|
|
3558
|
+
}
|
|
3559
|
+
|
|
3560
|
+
try {
|
|
3561
|
+
const result = await chatClient.listMessages(threadId);
|
|
3562
|
+
// Track retryBody (CFB-2.4) over the FULL ordered list — not the
|
|
3563
|
+
// ?since-filtered slice — so a retry button on a polled-in error
|
|
3564
|
+
// reply still resolves to its originating user message even when
|
|
3565
|
+
// that user message was delivered in an earlier poll response.
|
|
3566
|
+
const retryBodyByMessageId = new Map<string, string | null>();
|
|
3567
|
+
let lastUserBody: string | null = null;
|
|
3568
|
+
for (const m of result.messages) {
|
|
3569
|
+
retryBodyByMessageId.set(m.id, lastUserBody);
|
|
3570
|
+
if (m.role === "user") lastUserBody = m.body;
|
|
3571
|
+
}
|
|
3572
|
+
// ?since filtering is applied here at the admin (server) layer over the
|
|
3573
|
+
// full ordered list, so it works regardless of the chat client impl.
|
|
3574
|
+
const ordered = since
|
|
3575
|
+
? filterSince(result.messages, since)
|
|
3576
|
+
: result.messages;
|
|
3577
|
+
// Emit an explicit field set rather than spreading the upstream
|
|
3578
|
+
// message: the chat service once leaked its raw `attachmentBytes`
|
|
3579
|
+
// column here, which JSON-expanded a few MB of audio into ~36 MB per
|
|
3580
|
+
// poll and OOM-killed this service. The browser's poll loop reads
|
|
3581
|
+
// id/role/createdAt/repliedAt/progressPhase/progressSeq/bubbleHtml.
|
|
3582
|
+
// bubbleHtml is the server-rendered bubble so polled bubbles are
|
|
3583
|
+
// byte-identical to a full reload's.
|
|
3584
|
+
const messages = ordered.map((m) =>
|
|
3585
|
+
toPolledMessage(m, {
|
|
3586
|
+
bubbleHtml: renderChatMessageBubble(
|
|
3587
|
+
m,
|
|
3588
|
+
retryBodyByMessageId.get(m.id) ?? null,
|
|
3589
|
+
agentId,
|
|
3590
|
+
),
|
|
3591
|
+
}),
|
|
3592
|
+
);
|
|
3593
|
+
return c.json({ messages });
|
|
3594
|
+
} catch {
|
|
3595
|
+
return c.json({ messages: [] });
|
|
3596
|
+
}
|
|
3597
|
+
},
|
|
3598
|
+
);
|
|
3599
|
+
|
|
3600
|
+
app.post(
|
|
3601
|
+
"/admin/chat/:agentId/threads/:threadId/messages.json",
|
|
3602
|
+
requireAuth,
|
|
3603
|
+
async (c) => {
|
|
3604
|
+
if (
|
|
3605
|
+
!(await canAccessChat(
|
|
3606
|
+
c.var.userEmail,
|
|
3607
|
+
c.var.isAdmin,
|
|
3608
|
+
c.req.param("agentId") ?? "",
|
|
3609
|
+
c.req.param("threadId"),
|
|
3610
|
+
))
|
|
3611
|
+
) {
|
|
3612
|
+
return new Response("Forbidden", { status: 403 });
|
|
3613
|
+
}
|
|
3614
|
+
|
|
3615
|
+
const sendBlocked = await chatSendBlockedMessage(
|
|
3616
|
+
c.req.param("agentId") ?? "",
|
|
3617
|
+
c.var.isAdmin,
|
|
3618
|
+
);
|
|
3619
|
+
if (sendBlocked) {
|
|
3620
|
+
return c.json(
|
|
3621
|
+
{ error: "account_suspended", message: sendBlocked },
|
|
3622
|
+
403,
|
|
3623
|
+
);
|
|
3624
|
+
}
|
|
3625
|
+
|
|
3626
|
+
const threadId = c.req.param("threadId");
|
|
3627
|
+
|
|
3628
|
+
if (!chatClient) {
|
|
3629
|
+
return c.json({ message: null });
|
|
3630
|
+
}
|
|
3631
|
+
|
|
3632
|
+
let body: string | undefined;
|
|
3633
|
+
try {
|
|
3634
|
+
const jsonBody = await c.req.json<{ body?: string }>();
|
|
3635
|
+
body = jsonBody.body?.trim();
|
|
3636
|
+
} catch {
|
|
3637
|
+
return c.json({ message: null }, 400);
|
|
3638
|
+
}
|
|
3639
|
+
|
|
3640
|
+
if (!body) {
|
|
3641
|
+
return c.json({ message: null }, 400);
|
|
3642
|
+
}
|
|
3643
|
+
|
|
3644
|
+
try {
|
|
3645
|
+
const message = await chatClient.createMessage(threadId, "user", body);
|
|
3646
|
+
if (pushEnabled) {
|
|
3647
|
+
await pushService?.recordWatch(
|
|
3648
|
+
c.var.userEmail,
|
|
3649
|
+
c.req.param("agentId"),
|
|
3650
|
+
threadId,
|
|
3651
|
+
);
|
|
3652
|
+
}
|
|
3653
|
+
return c.json({ message });
|
|
3654
|
+
} catch {
|
|
3655
|
+
return c.json({ message: null }, 500);
|
|
3656
|
+
}
|
|
3657
|
+
},
|
|
3658
|
+
);
|
|
3659
|
+
|
|
3660
|
+
// ─── Web Push routes (CFB-4.2) ────────────────────────────────────────────
|
|
3661
|
+
// All 503 when push is not fully configured server-side, so the client-side
|
|
3662
|
+
// toggle (which is itself absent in that case) degrades cleanly.
|
|
3663
|
+
|
|
3664
|
+
app.post("/admin/chat/:agentId/push/subscribe", requireAuth, async (c) => {
|
|
3665
|
+
if (
|
|
3666
|
+
!(await canAccessChat(
|
|
3667
|
+
c.var.userEmail,
|
|
3668
|
+
c.var.isAdmin,
|
|
3669
|
+
c.req.param("agentId") ?? "",
|
|
3670
|
+
c.req.param("threadId"),
|
|
3671
|
+
))
|
|
3672
|
+
) {
|
|
3673
|
+
return new Response("Forbidden", { status: 403 });
|
|
3674
|
+
}
|
|
3675
|
+
if (!pushEnabled) return c.json({ error: "push_disabled" }, 503);
|
|
3676
|
+
|
|
3677
|
+
let payload: { endpoint?: string; p256dh?: string; auth?: string };
|
|
3678
|
+
try {
|
|
3679
|
+
payload = await c.req.json();
|
|
3680
|
+
} catch {
|
|
3681
|
+
return c.json({ error: "bad_request" }, 400);
|
|
3682
|
+
}
|
|
3683
|
+
const { endpoint, p256dh, auth } = payload;
|
|
3684
|
+
if (!endpoint || !p256dh || !auth) {
|
|
3685
|
+
return c.json({ error: "bad_request" }, 400);
|
|
3686
|
+
}
|
|
3687
|
+
const result = await pushService?.subscribe(
|
|
3688
|
+
c.var.userEmail,
|
|
3689
|
+
endpoint,
|
|
3690
|
+
p256dh,
|
|
3691
|
+
auth,
|
|
3692
|
+
);
|
|
3693
|
+
if (!result?.ok) {
|
|
3694
|
+
if (result?.reason === "store_failed") {
|
|
3695
|
+
return c.json({ error: "store_failed" }, 500);
|
|
3696
|
+
}
|
|
3697
|
+
return c.json({ error: "push_disabled" }, 503);
|
|
3698
|
+
}
|
|
3699
|
+
return c.json({ ok: true });
|
|
3700
|
+
});
|
|
3701
|
+
|
|
3702
|
+
app.post("/admin/chat/:agentId/push/unsubscribe", requireAuth, async (c) => {
|
|
3703
|
+
if (
|
|
3704
|
+
!(await canAccessChat(
|
|
3705
|
+
c.var.userEmail,
|
|
3706
|
+
c.var.isAdmin,
|
|
3707
|
+
c.req.param("agentId") ?? "",
|
|
3708
|
+
c.req.param("threadId"),
|
|
3709
|
+
))
|
|
3710
|
+
) {
|
|
3711
|
+
return new Response("Forbidden", { status: 403 });
|
|
3712
|
+
}
|
|
3713
|
+
if (!pushEnabled) return c.json({ error: "push_disabled" }, 503);
|
|
3714
|
+
|
|
3715
|
+
let endpoint: string | undefined;
|
|
3716
|
+
try {
|
|
3717
|
+
endpoint = (await c.req.json<{ endpoint?: string }>()).endpoint;
|
|
3718
|
+
} catch {
|
|
3719
|
+
return c.json({ error: "bad_request" }, 400);
|
|
3720
|
+
}
|
|
3721
|
+
if (!endpoint) return c.json({ error: "bad_request" }, 400);
|
|
3722
|
+
// Scoped to the caller (userEmail) inside PushService.unsubscribe so a
|
|
3723
|
+
// stale endpoint can't be used to prune another user's subscription.
|
|
3724
|
+
const result = await pushService?.unsubscribe(c.var.userEmail, endpoint);
|
|
3725
|
+
if (!result?.ok) {
|
|
3726
|
+
return c.json({ error: "push_disabled" }, 503);
|
|
3727
|
+
}
|
|
3728
|
+
return c.json({ ok: true });
|
|
3729
|
+
});
|
|
3730
|
+
|
|
3731
|
+
// Inbound webhook the chat service calls when an agent posts a reply, so the
|
|
3732
|
+
// watching user(s) get notified. Authenticated by a shared bearer token
|
|
3733
|
+
// (SHIPWRIGHT_ADMIN_PUSH_WEBHOOK_TOKEN) — NOT a user session — since the
|
|
3734
|
+
// caller is a service, not a browser.
|
|
3735
|
+
app.post("/admin/push/notify", async (c) => {
|
|
3736
|
+
if (!pushEnabled || !pushService || !pushWebhookToken) {
|
|
3737
|
+
return c.json({ error: "push_disabled" }, 503);
|
|
3738
|
+
}
|
|
3739
|
+
const auth = c.req.header("authorization") ?? "";
|
|
3740
|
+
const presented = auth.startsWith("Bearer ") ? auth.slice(7) : "";
|
|
3741
|
+
if (!presented || presented !== pushWebhookToken) {
|
|
3742
|
+
return c.json({ error: "unauthorized" }, 401);
|
|
3743
|
+
}
|
|
3744
|
+
let body: {
|
|
3745
|
+
threadId?: string;
|
|
3746
|
+
agentId?: string;
|
|
3747
|
+
title?: string | null;
|
|
3748
|
+
preview?: string | null;
|
|
3749
|
+
};
|
|
3750
|
+
try {
|
|
3751
|
+
body = await c.req.json();
|
|
3752
|
+
} catch {
|
|
3753
|
+
return c.json({ error: "bad_request" }, 400);
|
|
3754
|
+
}
|
|
3755
|
+
if (!body.threadId || !body.agentId) {
|
|
3756
|
+
return c.json({ error: "bad_request" }, 400);
|
|
3757
|
+
}
|
|
3758
|
+
const result = await pushService.notifyThreadReply({
|
|
3759
|
+
threadId: body.threadId,
|
|
3760
|
+
agentId: body.agentId,
|
|
3761
|
+
title: body.title ?? null,
|
|
3762
|
+
preview: body.preview ?? null,
|
|
3763
|
+
});
|
|
3764
|
+
return c.json({ ok: true, ...result });
|
|
3765
|
+
});
|
|
3766
|
+
|
|
3767
|
+
// ─── Agent delete (danger zone) ───────────────────────────────────────────
|
|
3768
|
+
|
|
3769
|
+
app.post("/admin/agents/:id/delete", requireAuth, async (c) => {
|
|
3770
|
+
const agentId = c.req.param("id");
|
|
3771
|
+
if (
|
|
3772
|
+
!(await assertCanManageAgent(agentId, c.var.userEmail, c.var.isAdmin))
|
|
3773
|
+
) {
|
|
3774
|
+
return new Response("Forbidden", { status: 403 });
|
|
3775
|
+
}
|
|
3776
|
+
try {
|
|
3777
|
+
// Full teardown: K8s workload, task-store + chat-service tokens/threads,
|
|
3778
|
+
// optional Slack app deletion, then the Agent row itself (deleted last,
|
|
3779
|
+
// only if every automatable step succeeded). Same orchestration DELETE
|
|
3780
|
+
// /agents/:id uses (agents-api.ts) — this was previously a separate
|
|
3781
|
+
// inline provisioner.deprovision() + direct-prisma-delete path that had
|
|
3782
|
+
// drifted from it. deleteAgentFully() still takes prisma directly (its
|
|
3783
|
+
// own dependency shape, not routed through AgentService).
|
|
3784
|
+
const result = await deleteAgentFully(agentId, {
|
|
3785
|
+
prisma,
|
|
3786
|
+
provisioner,
|
|
3787
|
+
serviceIntegrations,
|
|
3788
|
+
chatService,
|
|
3789
|
+
slack,
|
|
3790
|
+
decrypt,
|
|
3791
|
+
});
|
|
3792
|
+
if (!result.agentDeleted) {
|
|
3793
|
+
// Cleanup is incomplete — keep the operator on the agent's own page
|
|
3794
|
+
// (NOT the agents list) so the agent doesn't appear deleted while
|
|
3795
|
+
// steps still need a retry.
|
|
3796
|
+
const failedSteps = result.failed.map((f) => f.step).join(", ");
|
|
3797
|
+
return c.redirect(
|
|
3798
|
+
`/admin/agents/${agentId}?error=${encodeURIComponent(
|
|
3799
|
+
`Cleanup incomplete — retry. Failed steps: ${failedSteps}`,
|
|
3800
|
+
)}`,
|
|
3801
|
+
302,
|
|
3802
|
+
);
|
|
3803
|
+
}
|
|
3804
|
+
const manualStepsParam =
|
|
3805
|
+
result.manualStepsRequired.length > 0
|
|
3806
|
+
? `&manualSteps=${encodeURIComponent(
|
|
3807
|
+
JSON.stringify(result.manualStepsRequired),
|
|
3808
|
+
)}`
|
|
3809
|
+
: "";
|
|
3810
|
+
return c.redirect(
|
|
3811
|
+
`/admin/agents?success=deleted${manualStepsParam}`,
|
|
3812
|
+
302,
|
|
3813
|
+
);
|
|
3814
|
+
} catch (err) {
|
|
3815
|
+
const msg =
|
|
3816
|
+
err instanceof Error
|
|
3817
|
+
? encodeURIComponent(err.message)
|
|
3818
|
+
: "delete_failed";
|
|
3819
|
+
return c.redirect(`/admin/agents/${agentId}?error=${msg}`, 302);
|
|
3820
|
+
}
|
|
3821
|
+
});
|
|
3822
|
+
|
|
3823
|
+
return app;
|
|
3824
|
+
}
|