From 36ab6db57a62a9bd8defa9b968c4fb5ca4ed39da Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Fri, 25 Sep 2026 13:29:04 +0000 Subject: [PATCH 1/6] Sign in to Web with the Core key and rename its configuration Web now has one management credential, the Core key. The console accepts POST /console/auth/login with {"core_key": ...}, compares it with its configured key in constant time and issues the existing in-memory session cookie. Console accounts, first-run setup, bcrypt password state, Basic authentication and the legacy mode are removed. GET /console/auth reports only login or authenticated, and /console/config no longer reports the sandbox_admin and api_keys flags. Management requests forwarded by the console carry the fixed audit actor label "console". Configuration uses Core key names, with no fallback: admin/core.key, admin/core-key-digests.json, AGENTS_API_CORE_KEY_DIGESTS_FILE and CORE_CONSOLE_CORE_KEY_FILE. Core and Web refuse to start when an old name is set and name its replacement; Web also refuses the retired CORE_CONSOLE_AUTH_MODE, CORE_CONSOLE_STATE_DIR and CORE_CONSOLE_PASSWORD_FILE. The installer writes the new files and settings, drops console account state and renames --admin-token-file to --core-key-file. The operations guide gains a Core key section covering its location, scope, privacy, rotation and the upgrade steps for existing installations. --- CONTRIBUTING.md | 69 +++--- README.md | 6 +- contracts/agents-api/admin-api.md | 15 +- deploy/install/configuration.py | 21 +- deploy/install/install.py | 71 ++---- deploy/install/local_node.py | 2 +- deploy/install/test_install.py | 94 +++----- deploy/install/test_local_node.py | 2 +- docs/api/README.md | 12 +- docs/api/web-management.md | 34 +-- docs/configuration.md | 3 +- docs/design-principles.md | 4 +- docs/getting-started/install.md | 68 +++--- docs/getting-started/operations.md | 95 +++++++- services/agents-api/CONTAINER.md | 8 +- services/agents-api/HOSTED-RELEASE.md | 2 +- services/agents-api/HOSTED-SANDBOX-MANAGER.md | 12 +- services/agents-api/README.md | 8 +- services/agents-api/RELEASE.md | 7 +- .../agents-api/cmd/server/managed_nodes.go | 8 +- .../cmd/server/managed_setup_test.go | 6 +- .../cmd/server/process_configuration.go | 4 + .../cmd/server/process_configuration_test.go | 8 + .../api/project_api_key_configuration.go | 4 +- .../internal/api/sandbox_manager_auth.go | 11 +- .../internal/store/project_api_keys.go | 2 +- services/agents-api/tests/container_server.py | 8 +- services/agents-api/tests/official_client.py | 8 +- services/core-console/account_store.go | 153 ------------- services/core-console/account_store_test.go | 112 ---------- services/core-console/api_keys_test.go | 18 +- services/core-console/auth.go | 132 ++++------- services/core-console/auth_test.go | 206 ++++++++---------- services/core-console/config.go | 42 ++-- services/core-console/config_test.go | 45 +++- services/core-console/distribution_test.go | 3 +- services/core-console/node_installation.go | 4 +- .../core-console/node_installation_test.go | 26 +-- services/core-console/project_proxy_test.go | 14 +- services/core-console/sandbox_admin_test.go | 6 +- services/core-console/server.go | 51 +---- services/core-console/server_test.go | 38 ++-- site/index.html | 2 +- 43 files changed, 557 insertions(+), 887 deletions(-) delete mode 100644 services/core-console/account_store.go delete mode 100644 services/core-console/account_store_test.go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 401c48a27..a47c1787b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,8 +52,8 @@ Reuse existing resource deletion and serialization code. Cross-Project copying w removed. `admin_resource_owners` alone feeds the historical `source:"admin_copy"` provenance read; `admin_asset_copies` is retained only because tables are not dropped. Unknown historical provenance remains unknown. No secrets or request -bodies enter logs. A forwarded console actor name is only a label, never an -authorization input. +bodies enter logs. The console's fixed actor label (`console`) is only an audit +display label, never an authorization input. When requirements conflict, object ownership is unclear, or a design would need parallel compatibility paths, raise the issue with a concrete recommendation and @@ -731,10 +731,10 @@ does not qualify its isolation or enable public creation. ### Hosted sandbox nodes and optional suspension Default installation includes Core, Web and PostgreSQL but no execution node. -It always creates a separate deployment administrator credential. Core receives -only its digest; the paired console server receives the private token and injects -it only on approved management routes after console login and same-origin checks. -The browser never receives that token. Node and daemon connections use `/api/v1` +It always creates the Core key. Core receives only its digest; the paired console +server receives the private key, uses it for sign-in and injects it only on +approved management routes after console login and same-origin checks. The +browser never receives that key. Node and daemon connections use `/api/v1` with their own credentials; the reverse proxy sends them directly to Core, never through Web. Zero-node Core receives neither the Docker socket nor KVM. The Web and deployment administrator API select one provider, public Core origin, @@ -825,7 +825,7 @@ as a user service; it performs no SSH installation, Session creation or model ca The [Hosted Sandbox Manager](services/agents-api/HOSTED-SANDBOX-MANAGER.md) is a deployment-level admin surface, separate from Project credentials. The paired -console's administrator token stays on its server. Enrollment credentials authorize +console's Core key stays on its server. Enrollment credentials authorize initial node configuration reads and registration; durable node credentials authorize retained configuration reads and node transport. Project keys can read a narrow node directory and their own Session placement, never global allocations. @@ -1830,7 +1830,7 @@ installer. Its initial default is 2 CPUs and 4096 MiB; microsandbox also request installer defaults. Node configuration and identity live under `~/.parsar/nodes//`; microsandbox uses its separate short private Runtime home. Zero-node installs create no node identity state but retain the paired -administrator credential for first setup. +Core key for first setup. One Runtime image contains the existing daemon, shared helpers and three native harness packages. Their differences remain in the adapters. Core keeps exclusive @@ -1843,21 +1843,22 @@ credential encryption key. Provider identity/backend namespace and native histor must not change on a repeated install. `services/core-console` serves the production Web build and proxies only finite -administrator and sandbox-management routes after console login. It requires a -private `CORE_CONSOLE_ADMIN_TOKEN_FILE` and holds no project caller credential. -Every `/v1` and `/api/v1` request returns 404, including explicit Bearer and -WebSocket requests; Web forwards no node or daemon transport. The installer -mounts only the administrator key into Web and only its digest into Core. The -browser receives safe capability flags, never that key. The deployment's TLS -reverse proxy routes `/v1` (applications) and `/api/v1` (nodes and Runtime -daemons, with their own credentials) directly to Core and everything else to Web, -except the Project-authenticated `/core/v1/environments/*/executor-credentials` -routes, which also go straight to Core. +administrator and sandbox-management routes after console login. It requires the +private Core key file named by `CORE_CONSOLE_CORE_KEY_FILE` and holds no project +caller credential. Every `/v1` and `/api/v1` request returns 404, including +explicit Bearer and WebSocket requests; Web forwards no node or daemon transport. +The installer mounts only the Core key into Web and only its digest +(`AGENTS_API_CORE_KEY_DIGESTS_FILE`) into Core. The browser receives safe +configuration, never that key. The deployment's TLS reverse proxy routes `/v1` +(applications) and `/api/v1` (nodes and Runtime daemons, with their own +credentials) directly to Core and everything else to Web, except the +Project-authenticated `/core/v1/environments/*/executor-credentials` routes, which +also go straight to Core. Nodes and Core come from one distribution; older nodes using the removed `/core/v1/sandbox` node paths cannot connect and are replaced through the drained upgrade and re-enrollment workflow. -The Web manager offers no manual administrator-key fallback. A console without +The Web manager offers no manual Core key entry outside sign-in. A console without paired management configuration shows setup guidance; direct remote project API connections do not silently administer the console's configured deployment. Chinese/English sandbox text, status and diagnostic formatting live in the shared @@ -1883,20 +1884,22 @@ verification, and passes the enrollment credential only to the installer process Management proxy paths retain fixed-origin, cross-site, safe-path, redirect and Upgrade restrictions through the standard Go reverse proxy with streaming/cancellation. The console implements no product identity, resource semantics, Runtime discovery -or execution loop. Its local administrator account grants the complete console -surface; do not introduce Web roles, invitations or per-project Web identities. -Agent API caller keys remain independent of the administrator password and cookie. - -Account mode is explicit (`CORE_CONSOLE_AUTH_MODE=account`) and requires a private -writable state directory. The first visitor registers the sole administrator with -a username and password; no initialization key is required. The atomic durable -account record stores a password hash; corruption or missing registered account -state must never reopen registration. Account creation is race-safe. Cookie sessions are -bounded, HttpOnly, SameSite Strict and Secure for HTTPS origins; a restart requires -sign-in again, without deleting the account. Unauthenticated access is limited to -the static login UI, finite console authentication routes and the static node -installation payload. Authentication requests use same-origin JSON POSTs with -bounded bodies and bounded password-hash work. +or execution loop. Signing in with the Core key grants the complete console +surface; do not introduce Web accounts, roles, invitations or per-project Web +identities. Agent API caller keys remain independent of the Core key and cookie. + +Web signs in only with the Core key (`POST /console/auth/login` with +`{"core_key":"…"}`), compared in constant time with the console's configured key +and never logged or echoed. There are no accounts, passwords, first-run setup or +Basic authentication; `CORE_CONSOLE_AUTH_MODE`, `CORE_CONSOLE_STATE_DIR`, +`CORE_CONSOLE_PASSWORD_FILE` and the renamed `CORE_CONSOLE_ADMIN_TOKEN_FILE` fail +startup. Cookie sessions are in memory, bounded, HttpOnly, SameSite Strict and +Secure for HTTPS origins; a restart or Core key rotation requires sign-in again. +Unauthenticated access is limited to the static login UI, finite console +authentication routes and the static node installation payload. Sign-in uses +same-origin JSON POSTs with bounded bodies, attempt limiting and bounded +concurrent work. See the +[Core key operations guide](docs/getting-started/operations.md#core-key). Projects and application API keys live in Core PostgreSQL. Project creation owns its scope and shared principal; key issuance, revocation and Project archive share diff --git a/README.md b/README.md index d1b313a0b..1e3906653 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,9 @@ existing Sessions retain their node across disconnects and resume. current management changes. See the [installation guide](docs/getting-started/install.md) for obtaining/building a matching bundle and the host/network prerequisites. 2. **Sign in to Web.** Open the console address printed by the installer and - register your administrator account with a username and password. Keep them safe. - Existing installations retain their `admin` / `console.password` login. - The console connects to Core automatically. Create a Project in Web, then + sign in with the [Core key](docs/getting-started/operations.md#core-key) from + `~/.parsar/core/admin/core.key`. Keep it private. The console connects to Core + automatically. Create a Project in Web, then issue a key within it for your application. Save the one-time plaintext response privately; Core stores its digest. Rotate by issuing another key in the same Project and revoking the old one. diff --git a/contracts/agents-api/admin-api.md b/contracts/agents-api/admin-api.md index 7cec9d430..d9e838534 100644 --- a/contracts/agents-api/admin-api.md +++ b/contracts/agents-api/admin-api.md @@ -1,18 +1,18 @@ # Administrator API -This Core extension uses deployment Bearer authentication under `/core/v1/admin`. +This Core extension uses Core key Bearer authentication under `/core/v1/admin`. It does not change `/v1`, the fixed Python SDK, or native Runtime interfaces. -API keys cannot authenticate these routes; the administrator credential cannot -authenticate `/v1`. The console server supplies `X-Core-Console-Actor` from its -signed-in account. Core records it only as an unverified display label. +API keys cannot authenticate these routes; the Core key cannot authenticate `/v1`. +The console server sends the fixed `X-Core-Console-Actor: console`; direct Core +key requests send none. Core records it only as an unverified display label. ## Projects and keys A Project owns one tenant and shared principal. Its keys have equal access to all its assets. Projects and keys are database-owned; deployment configuration defines neither. There are no API users, roles or configuration-managed business keys. -Core requires a separate deployment administrator credential at startup for -bootstrap and management. +Core requires the Core key digest file (`AGENTS_API_CORE_KEY_DIGESTS_FILE`) at +startup for bootstrap and management. | Operation | Path | Result | | --- | --- | --- | @@ -143,7 +143,8 @@ routes, including the per-Session administrator read, keep their shape. `resource_type`, `resource_id`, `action`, inclusive `created_after`, exclusive `created_before`, `limit=1..100` (default 50), and opaque `after` filters. Response is `{data, has_more, next_cursor}`. Each row has `id`, `created_at`, -`admin_credential_id` (credential digest prefix), `actor_label`, `action`, +`admin_credential_id` (credential digest prefix), `actor_label` (`console` for Web +operations, empty for direct Core key requests), `action`, `project_id`, `resource_type`, `resource_id`, `result_ids`, `request_id`, `trace_id`. `result_ids` is an empty array except on historical `copy` entries. No credential values or request bodies are recorded. Logs and historical copy diff --git a/deploy/install/configuration.py b/deploy/install/configuration.py index d8ad5caee..aa09530ff 100644 --- a/deploy/install/configuration.py +++ b/deploy/install/configuration.py @@ -77,8 +77,8 @@ def core_environment(root, state, database_password): if native else "/opt/parsar/e2b/agents-api-e2b-provider"), "AGENTS_API_E2B_STATE_DIR": str(Path(root) / "state/e2b") if native else "/state/e2b", } - result["AGENTS_API_SANDBOX_ADMIN_DIGESTS_FILE"] = ( - str(Path(root) / "admin/digests.json") if native else "/admin/digests.json") + result["AGENTS_API_CORE_KEY_DIGESTS_FILE"] = ( + str(Path(root) / "admin/core-key-digests.json") if native else "/admin/core-key-digests.json") result["AGENTS_API_SANDBOX_INSTALLATION_ID"] = state["installation_id"] result["AGENTS_API_CONFIG_FILE"] = config + "/core.env" return result @@ -113,7 +113,7 @@ def compose_config(root, state, manifest, database_password): services["database"]["ports"] = [f'127.0.0.1:{state["database_port"]}:5432'] services.pop("migrate") else: - core["volumes"].append(bind(root / "admin/digests.json", "/admin/digests.json")) + core["volumes"].append(bind(root / "admin/core-key-digests.json", "/admin/core-key-digests.json")) core["volumes"].append(bind(root / "state/e2b", "/state/e2b", False)) services["core"] = core doc["volumes"] = {"database": {}} @@ -122,23 +122,12 @@ def compose_config(root, state, manifest, database_password): "image": manifest["images"]["web"], "user": identity, "restart": "unless-stopped", "ports": [f'127.0.0.1:{state["web_port"]}:8080'], "read_only": True, "security_opt": ["no-new-privileges:true"], - "volumes": [bind(root / "admin/sandbox-admin.key", "/admin/sandbox-admin.key")], + "volumes": [bind(root / "admin/core.key", "/admin/core.key")], "environment": {"CORE_CONSOLE_ORIGIN": state.get("public_url") or f'http://127.0.0.1:{state["web_port"]}', "CORE_CONSOLE_UPSTREAM": (f'http://127.0.0.1:{state["core_port"]}' if native else state.get("core_url") or "http://core:8091"), - "CORE_CONSOLE_ADMIN_TOKEN_FILE": "/admin/sandbox-admin.key"}, + "CORE_CONSOLE_CORE_KEY_FILE": "/admin/core.key"}, } - if state.get("console_auth") == "account": - services["web"]["volumes"].extend([ - bind(root / "state/console", "/state/console", False), - ]) - services["web"]["environment"].update( - CORE_CONSOLE_AUTH_MODE="account", - CORE_CONSOLE_STATE_DIR="/state/console", - ) - else: - services["web"]["volumes"].append(bind(config / "console.password", "/config/console.password")) - services["web"]["environment"]["CORE_CONSOLE_PASSWORD_FILE"] = "/config/console.password" services["web"]["volumes"].append(bind(root / "node-payload", "/node-payload")) services["web"]["environment"]["CORE_CONSOLE_NODE_PAYLOAD_DIR"] = "/node-payload" if state["mode"] == "web-only" or native: diff --git a/deploy/install/install.py b/deploy/install/install.py index ae7ad2384..2aa287166 100644 --- a/deploy/install/install.py +++ b/deploy/install/install.py @@ -123,10 +123,13 @@ def arguments(argv=None): parser.add_argument("--web-port", type=int, default=8080) parser.add_argument("--core-url", type=core_target) parser.add_argument("--public-url", type=core_target, help="Public HTTPS Core/Web origin behind your TLS reverse proxy") - parser.add_argument("--admin-token-file", type=Path) + parser.add_argument("--core-key-file", type=Path, help="Web-only: private file containing the existing Core's Core key") + parser.add_argument("--admin-token-file", type=Path, help=argparse.SUPPRESS) parser.add_argument("--status", action="store_true", help="Read installation health; never invoke a model") parser.add_argument("--stop", action="store_true", help="Stop installed services; retain all data") args = parser.parse_args(argv) + if args.admin_token_file: + parser.error("--admin-token-file was renamed; use --core-key-file") if args.web_only and args.native_core: parser.error("--web-only cannot install native Core") args.sandbox_provider = args.sandbox_provider == "true" @@ -151,9 +154,9 @@ def arguments(argv=None): parser.error("Ports must be between 1024 and 65535") if not args.core_only and not args.web_only and args.core_port == args.web_port: parser.error("Core and Web need different ports") - if args.web_only and not (args.core_url and args.admin_token_file): - parser.error("--web-only requires --core-url and --admin-token-file") - if not args.web_only and (args.core_url or args.admin_token_file): + if args.web_only and not (args.core_url and args.core_key_file): + parser.error("--web-only requires --core-url and --core-key-file") + if not args.web_only and (args.core_url or args.core_key_file): parser.error("Existing Core connection flags require --web-only") return args @@ -239,27 +242,18 @@ def initialize(root, args, manifest): if (not directory.is_dir() or directory.is_symlink() or stat.S_IMODE(directory.stat().st_mode) & 0o077): raise InstallError("Private provider receipts are missing or unsafe; restore the retained installation") - if state.get("console_auth") == "account": - # Never let Compose create replacement bind sources for lost auth - # state. An existing install must retain its account directory. - directory = root / "state/console" - if (not directory.is_dir() or directory.is_symlink() or - stat.S_IMODE(directory.stat().st_mode) & 0o077): - raise InstallError("Private console authentication state is missing or unsafe; restore the retained installation") - if (directory / "registered").exists() and not (directory / "admin.json").is_file(): - raise InstallError("Registered console account is missing; restore its private state backup") return state if root.exists() and any(root.iterdir()): raise InstallError("Installation directory is not empty; refusing to overwrite existing state") if mode == "web-only": - source = args.admin_token_file + source = args.core_key_file info = source.stat() if (not source.is_absolute() or source.is_symlink() or not stat.S_ISREG(info.st_mode) or stat.S_IMODE(info.st_mode) & 0o077 or info.st_size > 4096): - raise InstallError("Administrator token file must be an absolute, private regular file") + raise InstallError("Core key file must be an absolute, private regular file") token = source.read_text().strip() if not token or any(c.isspace() for c in token) or "\x00" in token: - raise InstallError("Invalid administrator token file") + raise InstallError("Invalid Core key file") if mode != "web-only": free_port(args.core_port) if mode != "core-only": @@ -267,11 +261,8 @@ def initialize(root, args, manifest): root.mkdir(mode=0o700, parents=True, exist_ok=True) os.chmod(root, 0o700) directories = ["config", "admin"] - directories.append("state") if mode != "web-only": - directories.append("state/e2b") - if mode != "core-only": - directories.append("state/console") + directories += ["state", "state/e2b"] for name in directories: (root / name).mkdir(mode=0o700) state = {"version": 1, "source_commit": manifest["source_commit"], "mode": mode, @@ -284,13 +275,11 @@ def initialize(root, args, manifest): if mode != "web-only": private_write(config / "credential.key", base64.b64encode(secrets.token_bytes(32)).decode()) private_write(config / "database.password", secrets.token_hex(32)) - admin_token = secrets.token_hex(32) - private_write(root / "admin/sandbox-admin.key", admin_token) - write_json(root / "admin/digests.json", [hashlib.sha256(admin_token.encode()).hexdigest()]) + core_key = secrets.token_hex(32) + private_write(root / "admin/core.key", core_key) + write_json(root / "admin/core-key-digests.json", [hashlib.sha256(core_key.encode()).hexdigest()]) if mode == "web-only": - private_write(root / "admin/sandbox-admin.key", token) - if mode != "core-only": - state["console_auth"] = "account" + private_write(root / "admin/core.key", token) password = (config / "database.password").read_text() if mode != "web-only" else "" if mode != "web-only": private_write(config / "core.env", environment_text(core_environment(root, state, password))) @@ -387,26 +376,14 @@ def main(argv=None): if state["mode"] != "core-only": url = f'http://127.0.0.1:{state["web_port"]}' host = urlsplit(state.get("public_url") or url).netloc - if state.get("console_auth") == "account": - if not wait_http(url + "/console/auth", {"Host": host}): - raise InstallError("Web authentication is unavailable. Inspect private console state") - core_url = state.get("core_url") or f'http://127.0.0.1:{state["core_port"]}' - token = (root / "admin/sandbox-admin.key").read_text().strip() - if not wait_http(core_url + "/core/v1/admin/projects", {"Authorization": "Bearer " + token}): - raise InstallError("Core administrator authentication failed. Inspect private configuration; no model was called") - print("Console: " + (state.get("public_url") or url)) - if (root / "state/console/admin.json").exists(): - print("Sign in with your administrator account. Keep its password and private state backup safe.") - else: - print("Open Web to register the administrator with your chosen username and password.") - print("Keep your administrator username and password safe; there is no email password reset.") - else: - auth = base64.b64encode(("admin:" + (root / "config/console.password").read_text()).encode()).decode() - if not wait_http(url + "/core/v1/admin/projects", {"Authorization": "Basic " + auth, - "Host": host}): - raise InstallError("Web could not authenticate to Core. Inspect private configuration; no model was called") - print("Console: " + (state.get("public_url") or url) + " (user: admin)") - print("Console password file: " + str(root / "config/console.password")) + if not wait_http(url + "/console/auth", {"Host": host}): + raise InstallError("Web sign-in is unavailable. Use --status and inspect the Web service") + core_url = state.get("core_url") or f'http://127.0.0.1:{state["core_port"]}' + token = (root / "admin/core.key").read_text().strip() + if not wait_http(core_url + "/core/v1/admin/projects", {"Authorization": "Bearer " + token}): + raise InstallError("Core key authentication failed. Inspect private configuration; no model was called") + print("Console: " + (state.get("public_url") or url)) + print("Sign in to Web with the Core key. Keep it private; it also authorizes the Core management API.") if state["mode"] != "web-only": print(f'API: http://127.0.0.1:{state["core_port"]}/v1') print("Create a Project and issue its API key through the administrator API before calling the direct Core API.") @@ -415,7 +392,7 @@ def main(argv=None): print("Provider: " + args.provider + ". Local node enrolled; Core provisions Sessions on demand.") else: print("No execution node was installed by this run. Open Hosted Sandbox Manager to manage providers and nodes.") - print("Sandbox administrator key file: " + str(root / "admin/sandbox-admin.key")) + print("Core key file: " + str(root / "admin/core.key")) print("Services installed. No model request was made. See docs/getting-started/quickstart.md.") diff --git a/deploy/install/local_node.py b/deploy/install/local_node.py index ad2464df7..6809c2631 100644 --- a/deploy/install/local_node.py +++ b/deploy/install/local_node.py @@ -38,7 +38,7 @@ def request(core, token, method, path, value=None): def install(root, state, manifest, bundle, run): core = f'http://127.0.0.1:{state["core_port"]}' - admin = (root / "admin/sandbox-admin.key").read_text().strip() + admin = (root / "admin/core.key").read_text().strip() current = request(core, admin, "GET", "deployment") if current.get("installation_id") != state["installation_id"]: raise LocalNodeError("Core deployment identity differs; preserve its installation state") diff --git a/deploy/install/test_install.py b/deploy/install/test_install.py index 8674d709c..f68b7234c 100644 --- a/deploy/install/test_install.py +++ b/deploy/install/test_install.py @@ -256,7 +256,7 @@ def test_node_payload_exports_only_matched_distribution_files(self): exported = {str(path.relative_to(payload)) for path in payload.rglob("*") if path.is_file()} self.assertEqual(len(exported), 5) self.assertNotIn("config/caller.key", exported) - self.assertNotIn("admin/sandbox-admin.key", exported) + self.assertNotIn("admin/core.key", exported) for name in exported: self.assertEqual((payload / name).read_bytes(), (bundle / name).read_bytes()) install.prepare_node_payload(self.root, state, bundle) @@ -295,49 +295,22 @@ def test_repeat_installation_preserves_execution_identity_and_all_secrets(self): self.assertEqual(before, self.snapshot()) self.assertFalse((self.root / "config/keys.json").exists()) - def test_new_console_account_state_is_private_and_only_web_can_write_it(self): + def test_web_signs_in_with_the_core_key_only(self): state = self.initialize() - self.assertEqual(state["console_auth"], "account") + self.assertNotIn("console_auth", state) + self.assertFalse((self.root / "state/console").exists()) self.assertFalse((self.root / "config/console.password").exists()) - self.assertEqual(stat.S_IMODE((self.root / "state/console").stat().st_mode), 0o700) - self.assertFalse((self.root / "config/console.setup.key").exists()) - services = self.document("compose.json")["services"] - web = services["web"] - self.assertEqual(web["environment"]["CORE_CONSOLE_AUTH_MODE"], "account") - self.assertNotIn("CORE_CONSOLE_SETUP_KEY_FILE", web["environment"]) - self.assertEqual(web["environment"]["CORE_CONSOLE_STATE_DIR"], "/state/console") - for name, service in services.items(): - mounts = [m for m in service.get("volumes", []) if isinstance(m, dict) and m["target"] == "/state/console"] - self.assertEqual(len(mounts), 1 if name == "web" else 0) - if mounts: - self.assertEqual(mounts[0]["source"], str(self.root / "state/console")) - self.assertFalse(mounts[0]["read_only"]) - install.private_write(self.root / "state/console/admin.json", '{"synthetic":"retained account"}') - before = self.snapshot() - self.initialize() - self.assertEqual(before, self.snapshot()) + environment = self.document("compose.json")["services"]["web"]["environment"] + self.assertEqual(environment["CORE_CONSOLE_CORE_KEY_FILE"], "/admin/core.key") + for retired in ("CORE_CONSOLE_ADMIN_TOKEN_FILE", "CORE_CONSOLE_AUTH_MODE", "CORE_CONSOLE_STATE_DIR", + "CORE_CONSOLE_PASSWORD_FILE"): + self.assertNotIn(retired, environment) - def test_legacy_installation_keeps_basic_credentials_and_compose(self): - state = self.initialize() - state.pop("console_auth") - install.private_write(self.root / "config/console.password", "retained-console-password") - shutil.rmtree(self.root / "state/console") - (self.root / "installation.json").write_text(json.dumps(state)) - compose = install.compose_config(self.root, state, self.manifest, "retained-database-password") - (self.root / "compose.json").write_text(json.dumps(compose)) - self.assertEqual(compose["services"]["web"]["environment"]["CORE_CONSOLE_PASSWORD_FILE"], "/config/console.password") - self.assertNotIn("CORE_CONSOLE_AUTH_MODE", compose["services"]["web"]["environment"]) - before = self.snapshot() - self.assertEqual(self.initialize(), state) - self.assertEqual(before, self.snapshot()) - - def test_repeat_account_install_refuses_missing_authentication_sources(self): - self.initialize() - shutil.rmtree(self.root / "state/console") - before = self.snapshot() - with self.assertRaisesRegex(install.InstallError, "authentication state"): - self.initialize() - self.assertEqual(before, self.snapshot()) + def test_renamed_core_key_flag_is_rejected(self): + output = io.StringIO() + with contextlib.redirect_stderr(output), self.assertRaises(SystemExit): + self.args("--web-only", "--core-url", "http://127.0.0.1:9091", "--admin-token-file", str(self.administrator_file())) + self.assertIn("--core-key-file", output.getvalue()) def test_retained_config_cannot_launch_an_unresolved_image(self): self.initialize('--sandbox-provider', 'true', '--provider', 'docker') @@ -410,8 +383,9 @@ def test_administrator_credential_is_private_and_only_web_holds_plaintext(self): flags = ("--native-core",) if native else () state = self.initialize(*flags) admin = self.root / "admin" - token = (admin / "sandbox-admin.key").read_text() - self.assertEqual(self.document("admin/digests.json"), [hashlib.sha256(token.encode()).hexdigest()]) + token = (admin / "core.key").read_text() + self.assertEqual(self.document("admin/core-key-digests.json"), [hashlib.sha256(token.encode()).hexdigest()]) + self.assertEqual({path.name for path in admin.iterdir()}, {"core.key", "core-key-digests.json"}) self.assertFalse((self.root / "config/caller.key").exists()) self.assertEqual(stat.S_IMODE(admin.stat().st_mode), 0o700) for path in admin.iterdir(): @@ -420,8 +394,9 @@ def test_administrator_credential_is_private_and_only_web_holds_plaintext(self): self.initialize(*flags) self.assertEqual(self.snapshot(), before) environment = install.core_environment(self.root, state, "synthetic database password") - expected = str(admin / "digests.json") if native else "/admin/digests.json" - self.assertEqual(environment["AGENTS_API_SANDBOX_ADMIN_DIGESTS_FILE"], expected) + expected = str(admin / "core-key-digests.json") if native else "/admin/core-key-digests.json" + self.assertEqual(environment["AGENTS_API_CORE_KEY_DIGESTS_FILE"], expected) + self.assertNotIn("AGENTS_API_SANDBOX_ADMIN_DIGESTS_FILE", environment) self.assertNotIn("AGENTS_API_KEYS_FILE", environment) self.assertFalse((self.root / "config/keys.json").exists()) for name, service in self.document("compose.json")["services"].items(): @@ -429,21 +404,21 @@ def test_administrator_credential_is_private_and_only_web_holds_plaintext(self): mounts = [m for m in service.get("volumes", []) if isinstance(m, dict) and m["target"].startswith("/admin")] self.assertEqual(len(mounts), 1 if name in ("core", "web") else 0) if mounts: - filename = "digests.json" if name == "core" else "sandbox-admin.key" + filename = "core-key-digests.json" if name == "core" else "core.key" self.assertEqual(mounts[0]["source"], str(admin / filename)) self.assertEqual(mounts[0]["target"], "/admin/" + filename) self.assertTrue(mounts[0]["read_only"]) for mount in service.get("volumes", []): if isinstance(mount, dict) and name != "web": source = Path(mount["source"]) - self.assertFalse((admin / "sandbox-admin.key").is_relative_to(source)) + self.assertFalse((admin / "core.key").is_relative_to(source)) if name != "core": - self.assertNotIn("AGENTS_API_SANDBOX_ADMIN_DIGESTS_FILE", service.get("environment", {})) + self.assertNotIn("AGENTS_API_CORE_KEY_DIGESTS_FILE", service.get("environment", {})) self.assertNotIn(token, json.dumps(service)) def test_web_only_uses_existing_local_core_without_database_or_provider(self): source = self.administrator_file() - flags = ("--web-only", "--core-url", "http://127.0.0.1:9091", "--admin-token-file", str(source)) + flags = ("--web-only", "--core-url", "http://127.0.0.1:9091", "--core-key-file", str(source)) state = self.initialize(*flags) self.assertEqual(self.device_probes, []) self.assertEqual(self.ports.call_args_list, [mock.call(8080)]) @@ -454,12 +429,13 @@ def test_web_only_uses_existing_local_core_without_database_or_provider(self): self.assertEqual(web["network_mode"], "host") self.assertEqual(web["environment"]["CORE_CONSOLE_ADDR"], "127.0.0.1:8080") self.assertEqual(web["environment"]["CORE_CONSOLE_UPSTREAM"], "http://127.0.0.1:9091") - self.assertEqual(web["environment"]["CORE_CONSOLE_ADMIN_TOKEN_FILE"], "/admin/sandbox-admin.key") + self.assertEqual(web["environment"]["CORE_CONSOLE_CORE_KEY_FILE"], "/admin/core.key") self.assertNotIn("CORE_CONSOLE_TOKEN_FILE", web["environment"]) self.assertNotIn("ports", web) self.assertNotIn("devices", web) self.assertEqual({path.name for path in (self.root / "config").iterdir()}, set()) - self.assertEqual((self.root / "admin/sandbox-admin.key").read_bytes(), source.read_bytes()) + self.assertEqual((self.root / "admin/core.key").read_bytes(), source.read_bytes()) + self.assertFalse((self.root / "state").exists()) before = self.snapshot() self.assertEqual(state, self.initialize(*flags)) self.assertEqual(before, self.snapshot()) @@ -482,7 +458,7 @@ def test_web_only_rejects_exposed_or_malformed_administrator_files(self): source = self.administrator_file(contents, mode) with self.assertRaises(install.InstallError): self.initialize("--web-only", "--core-url", "http://localhost:8091", - "--admin-token-file", str(source)) + "--core-key-file", str(source)) self.assertFalse(self.root.exists(), "invalid input left a non-retryable partial deployment") def test_web_only_rejects_directory_or_symlink_as_administrator_file(self): @@ -494,7 +470,7 @@ def test_web_only_rejects_directory_or_symlink_as_administrator_file(self): for path in (link, private_directory): with self.subTest(path=path.name), self.assertRaises(install.InstallError): self.initialize("--web-only", "--core-url", "http://localhost:8091", - "--admin-token-file", str(path)) + "--core-key-file", str(path)) self.assertFalse(self.root.exists()) def test_bundle_verifies_transferred_bytes_before_trusting_manifest(self): @@ -540,7 +516,7 @@ def test_default_has_no_provider_authority_or_runtime_configuration(self): self.assertEqual(self.device_probes, []) self.assertNotIn("device_gid", state) self.assertFalse((self.root / "state/sandbox-node").exists()) - self.assertTrue((self.root / "admin/digests.json").is_file()) + self.assertTrue((self.root / "admin/core-key-digests.json").is_file()) self.assertFalse((self.root / "config/managed-runtimes.json").exists()) compose = self.document("compose.json") self.assertEqual(set(compose["services"]), {"database", "migrate", "core", "web"}) @@ -689,14 +665,14 @@ def test_default_main_skips_kvm_native_service_and_runtime_import(self): self.assertIn("No execution node was installed by this run", output.getvalue()) self.assertIn("Core configuration file: " + str(self.root / "config/core.env"), output.getvalue()) self.assertNotIn((self.root / "config/database.password").read_text(), output.getvalue()) - self.assertIn("Open Web to register the administrator with your chosen username and password.", output.getvalue()) - self.assertNotIn("Setup key", output.getvalue()) + self.assertIn("Sign in to Web with the Core key.", output.getvalue()) + self.assertIn("Core key file: " + str(self.root / "admin/core.key"), output.getvalue()) self.assertFalse((self.root / "config/keys.json").exists()) self.assertEqual([call.args[0] for call in health.call_args_list], [ "http://127.0.0.1:8091/healthz", "http://127.0.0.1:8080/console/auth", "http://127.0.0.1:8091/core/v1/admin/projects", ]) - admin_token = (self.root / "admin/sandbox-admin.key").read_text() + admin_token = (self.root / "admin/core.key").read_text() self.assertEqual(health.call_args_list[-1].args[1], {"Authorization": "Bearer " + admin_token}) self.assertNotIn(admin_token, output.getvalue()) self.assertIn("Create a Project and issue its API key through the administrator API", output.getvalue()) @@ -718,7 +694,7 @@ def external_command(args, **_kwargs): mock.patch.object(install, "wait_http", return_value=True) as health, \ contextlib.redirect_stdout(output), contextlib.redirect_stderr(output): install.main(["--install-dir", str(self.root), "--web-only", "--core-url", - "http://127.0.0.1:9091", "--admin-token-file", str(source)]) + "http://127.0.0.1:9091", "--core-key-file", str(source)]) self.assertEqual(self.device_probes, []) imports = [call for call in calls if call[:2] == ["docker", "load"]] self.assertEqual(imports, [["docker", "load", "--input", str(bundle / "images/web.tar")]]) @@ -808,7 +784,7 @@ def test_core_connection_rejects_remote_cleartext_and_embedded_credentials(self) "https://core.example/?token=synthetic-secret"): output = io.StringIO() with self.subTest(url=url), contextlib.redirect_stderr(output), self.assertRaises(SystemExit): - self.args("--web-only", "--core-url", url, "--admin-token-file", str(source)) + self.args("--web-only", "--core-url", url, "--core-key-file", str(source)) self.assertNotIn("synthetic-secret", output.getvalue()) def test_status_rejects_failed_database_even_while_http_processes_are_alive(self): diff --git a/deploy/install/test_local_node.py b/deploy/install/test_local_node.py index 8eb8eec90..089bce424 100644 --- a/deploy/install/test_local_node.py +++ b/deploy/install/test_local_node.py @@ -16,7 +16,7 @@ def setUp(self): self.addCleanup(temporary.cleanup) self.root = Path(temporary.name) (self.root / "admin").mkdir() - (self.root / "admin/sandbox-admin.key").write_text("fixture-admin") + (self.root / "admin/core.key").write_text("fixture-admin") self.state = {"core_port": 8091, "installation_id": "94be54a1-138c-4f30-bc87-b13686272dbe", "provider": "microsandbox", "public_url": "https://core.example"} self.manifest = {"source_commit": "a" * 40, "images": {"runtime": "sha256:" + "b" * 64}, diff --git a/docs/api/README.md b/docs/api/README.md index 3c242c591..0eeb119a9 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -7,18 +7,18 @@ OpenAI compatibility: Core also has explicitly documented extensions under `/v1` | --- | --- | --- | --- | | Public Agents API | Applications; a database-issued Project API key | Direct Core `/v1` | [Public API](public-agent-api.md) | | Project-scoped Core extensions | Applications; the same Project API key | Direct Core; extension-specific paths | [Extension index](public-agent-api.md#core-extensions) | -| Administrator resources | Web's server or administrative automation; deployment credential | Core `/core/v1/admin` | [Management contract](../../contracts/agents-api/admin-api.md) | -| Hosted sandbox administration | Web's server or administrative automation; deployment credential | Core `/core/v1/sandbox` management routes | [Web API](web-management.md#sandbox-administration) | -| Console authentication | Browser; local console sign-in and session cookie | Console `/console/auth` | [Web API](web-management.md#browser-to-console) | +| Administrator resources | Web's server or administrative automation; [Core key](../getting-started/operations.md#core-key) | Core `/core/v1/admin` | [Management contract](../../contracts/agents-api/admin-api.md) | +| Hosted sandbox administration | Web's server or administrative automation; Core key | Core `/core/v1/sandbox` management routes | [Web API](web-management.md#sandbox-administration) | +| Console authentication | Browser; Core key sign-in, then an HttpOnly session cookie | Console `/console/auth` | [Web API](web-management.md#browser-to-console) | | Node and daemon transport | Installed node/Runtime; its own enrollment or connection credential | Direct Core `/api/v1`: `/api/v1/sandbox-node/*` and `/api/v1/agent-daemon/*`, including WebSockets | [Runtime credentials](../../contracts/agents-api/environment-executor-credentials.md), [node operations](../../services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host), [machine OpenAPI](../../contracts/agents-api/runtime.openapi.yaml) | Projects own assets. Multiple equally privileged keys share the Project's assets and execution principal; provenance retains the key that performed each write. Projects and application keys live in PostgreSQL, never deployment configuration. -Administrator credentials cannot authenticate public Agent API operations, and -Project keys cannot authenticate administrator operations. +The Core key cannot authenticate public Agent API operations, and Project keys +cannot authenticate administrator operations. -The console server stores its deployment credential privately and proxies only +The console server stores the Core key privately and proxies only allowlisted management operations. Browsers do not receive it. Applications and machines call Core directly; the console returns 404 for `/v1` and `/api/v1`, even when given an application key or machine credential. diff --git a/docs/api/web-management.md b/docs/api/web-management.md index 374ec2385..df6d2bc7e 100644 --- a/docs/api/web-management.md +++ b/docs/api/web-management.md @@ -6,29 +6,35 @@ Agent execution, copy or arbitrary asset editing operation. ## Browser to console -The browser uses the console's own origin. In account mode: +The browser uses the console's own origin and signs in with the +[Core key](../getting-started/operations.md#core-key): | Method and route | Request | Result | | --- | --- | --- | -| `GET /console/auth` | No body | `mode: setup`, `login`, or `authenticated`; authenticated mode includes `username` | -| `POST /console/auth/setup` | JSON `username`, `password` | Creates the sole local administrator and signs in; registration closes afterward | -| `POST /console/auth/login` | JSON `username`, `password` | Signs in with an HttpOnly session cookie | -| `POST /console/auth/logout` | No credential payload | Clears the current session | -| `GET /console/config` | Authenticated console session | Safe connection configuration | +| `GET /console/auth` | No body | `200 {"mode":"login"}` or `200 {"mode":"authenticated"}` | +| `POST /console/auth/login` | `Content-Type: application/json`, body `{"core_key":"…"}`; other members are rejected | `200 {"mode":"authenticated"}` and an HttpOnly, SameSite=Strict session cookie (Secure over HTTPS) | +| `POST /console/auth/logout` | No credential payload | `200 {"mode":"login"}`; clears the cookie and the server-side session | +| `GET /console/config` | Signed-in session | `node_installer` and `node_installer_sha256` | + +Sign-in errors use the console's `{"error": "…"}` envelope: 400 for a malformed +body, 401 for a wrong key, 415 for a non-JSON body, 429 with `Retry-After` when +attempts are limited, and 503 when the console cannot start a session. The console +compares the submitted key with its configured Core key in constant time and never +logs or returns it. Sessions live only in the console's memory; a console restart +or Core key rotation requires signing in again. There are no console accounts, +usernames, passwords, first-run setup or Basic authentication. Use same-origin browser requests and cookies. Mutations require the same-origin -request checks; do not put deployment credentials in JavaScript or browser storage. -The console's local account is not an Agent API user, Project member or role. -Already-configured Basic-auth deployments retain their existing sign-in mode; -`GET /console/auth` reports `legacy` there. No new Basic-auth flow is required in -frontend work. +request checks; never put the Core key in JavaScript or browser storage. ## Console to Core -The console server injects its private deployment Bearer credential on allowlisted +The console server injects the Core key as its Bearer credential on allowlisted management requests. It removes browser Authorization and forwarding-sensitive -headers, and supplies the signed-in account name as `X-Core-Console-Actor`. -Core treats that name as an audit display label, not an authorization input. +headers, and sets `X-Core-Console-Actor: console`. Core records that fixed value as +the audit `actor_label`, a display label rather than an authorization input. +Direct Core key requests from operator scripts carry no header and record an +empty label. Use [AdminClient](../../packages/agents-client/src/admin-client.ts) for the typed management client and [the complete administrator reference](../../contracts/agents-api/admin-api.md) diff --git a/docs/configuration.md b/docs/configuration.md index 4e4bc1d16..7eeead0b1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -9,6 +9,7 @@ parameters. Each setting has one owner; reinstalling does not restore defaults. | Settings | Authoritative source | How changes take effect | | --- | --- | --- | | Core database/pool, listener, execution concurrency, logging, audit retention | `/config/core.env` | Edit the private file; restart Core | +| Core key | `/admin/core.key` (Web) and `admin/core-key-digests.json` (Core) | Replace both files; restart Core and Web ([rotation](getting-started/operations.md#rotate-the-core-key)) | | Hosted provider, uniform guest resources, immutable Runtime specification, E2B credentials | Core PostgreSQL | Web or deployment API; existing maintenance/generation checks | | Node registration and sandbox capacity | Core PostgreSQL | Administrator enrollment and node updates | | Node host paths, Docker socket and local network wiring | Node `provider.json` | Edit host-local fields; restart the node | @@ -62,7 +63,7 @@ flags. Changing `core.env` does not require editing those launcher files. | `AGENTS_API_DAEMON_WS_URL` | Installer-generated reachable WebSocket URL | Enables daemon transport and advertises self-hosted connectivity; hosted bootstrap uses the saved deployment origin | | `AGENTS_API_CONFIG_FILE` | Installer-generated absolute loaded-file path | Diagnostic marker only, not a loader; preserve it | | `AGENTS_API_SANDBOX_INSTALLATION_ID` | Installer-generated canonical UUID | Stable identity pinned to the database, not provider selection; preserve it | -| `AGENTS_API_SANDBOX_ADMIN_DIGESTS_FILE` | Generated private digest file path | Administrator authority; original bearer key stays separately on the console server | +| `AGENTS_API_CORE_KEY_DIGESTS_FILE` | Generated private `admin/core-key-digests.json` path | JSON array of [Core key](getting-started/operations.md#core-key) SHA-256 digests that authorize `/core/v1`; the key itself stays in Web's `CORE_CONSOLE_CORE_KEY_FILE`. The old name `AGENTS_API_SANDBOX_ADMIN_DIGESTS_FILE` fails startup | | `AGENTS_API_CREDENTIAL_KEY_FILE` | Generated private encryption-key path | Preserve with the database; never regenerate to repair credentials | | `AGENTS_API_EXECUTION_OPTIONS_FILE` | Unset | Optional existing adapter-options JSON; see [model execution](../contracts/agents-api/model-execution.md) and [harness selection](../contracts/agents-api/harness-selection.md) | | `AGENTS_API_RUNTIME_HISTORY_FILE` | Unset | Optional existing Runtime history/export JSON; local collection defaults to 30 seconds, retention to 7 days; see [history contract](../contracts/agents-api/runtime-history-api.md) | diff --git a/docs/design-principles.md b/docs/design-principles.md index 788483ff5..2cf44cf06 100644 --- a/docs/design-principles.md +++ b/docs/design-principles.md @@ -64,8 +64,8 @@ user-authored conversation text, Skill source or Artifact content: administrator who inspect those records see their recorded contents. Public writes retain their actual API-key provenance. Administrator writes retain -a separate audit identity and target Project. The console account name is a label; -Core trusts the deployment credential, not that forwarded name. Audit failure rolls +a separate audit identity and target Project. The console's fixed actor label is +display-only; Core trusts the Core key, not that forwarded label. Audit failure rolls back the business transaction. Reads are not audited. Resources from the removed copy operation keep their `admin_copy` ownership, distinct from historical unknown ownership. No request bodies, secrets or file contents enter audit records. diff --git a/docs/getting-started/install.md b/docs/getting-started/install.md index ae4cd296d..aa9a66d1b 100644 --- a/docs/getting-started/install.md +++ b/docs/getting-started/install.md @@ -73,14 +73,15 @@ before release. See [console integration status](../web/README.md). Installation creates private configuration under `~/.parsar/core`, a dedicated PostgreSQL volume and a credential encryption key. Installation creates no Project or application API key. Projects and their keys are managed in the database; -configuration files contain deployment settings only. New installations create a -separate deployment administrator credential. Secret values are not printed. +configuration files contain deployment settings only. New installations create one +management credential, the [Core key](operations.md#core-key). Secret values are +not printed. Open the console address printed by the installer (`https://core.example` in -the example above). On the first visit, choose an administrator username and -password, and keep your sign-in details safe. The Web has one role: administrator, -with access to every console operation. It has no secondary user roles. The paired -console already connects to Core; no API key is needed to sign in. +the example above) and sign in with the Core key from +`~/.parsar/core/admin/core.key`. The console has no user accounts or passwords, and +one role: administrator, with access to every console operation. The paired +console already connects to Core with the same key on the server side. Use the administrator API to create a Project, then issue a named API key within it and save the one-time plaintext response privately. Core stores only its digest. @@ -88,9 +89,9 @@ The corresponding Web management screens remain pending. Multiple keys in a Proj share its assets and execution principal; writes record the actual key separately. Rotate by issuing another key in that Project and revoking the old one. Archiving the Project disables all its keys and retains assets for inspection and deletion. -API callers use their own keys and the public API endpoint. The deployment -credential cannot call `/v1`; the console cannot execute or create Agent resources -on their behalf. See the [management contract](../../contracts/agents-api/admin-api.md). +API callers use their own keys and the public API endpoint. The Core key cannot +call `/v1`; the console cannot execute or create Agent resources on their behalf. +See the [management contract](../../contracts/agents-api/admin-api.md). Hosts registered through the console supply sandbox resources for hosted Sessions; self-hosted Sessions use application-managed environments. Neither installation @@ -101,36 +102,25 @@ Default local ports and private files: - API: `http://127.0.0.1:8091/v1` - Web upstream: `http://127.0.0.1:8080`; use the configured public URL in your browser -- Administrator state: `~/.parsar/core/state/console/` (`admin.json` and `registered`) -- Deployment administrator key: `~/.parsar/core/admin/sandbox-admin.key` +- Core key: `~/.parsar/core/admin/core.key` +- Core key digest: `~/.parsar/core/admin/core-key-digests.json` Use exactly the displayed console address; the production proxy validates its -configured origin. The account file contains a password hash, not a recoverable -password. Back up the private console state along with installation configuration; -do not remove it to reset a password or reopen registration. Cookie sessions expire -after 12 hours and on console restart; the account survives restarts. Existing -installations retain `admin` Basic login with `config/console.password`; rerunning -the installer does not silently migrate their authentication mode. Account-mode -upgrades no longer require a setup key; remove any old `CORE_CONSOLE_SETUP_KEY_FILE` -setting and its file mount when updating your service configuration. Keep the -existing administrator state directory mounted. - -Manual account-mode consoles set `CORE_CONSOLE_AUTH_MODE=account`, an absolute -`CORE_CONSOLE_STATE_DIR` (private writable directory). Keep state outside the -static Web root. Setup/login/logout use private `/console/auth` routes and do not -extend the public Agent API. The console rejects every `/v1` request, including -requests carrying an explicit Bearer token; it holds no caller key. - -Every Core installation creates a separate private deployment administrator key at -`admin/sandbox-admin.key`. Core loads its SHA-256 digest from -`AGENTS_API_SANDBOX_ADMIN_DIGESTS_FILE`. Only the Core container mounts the -digest file read-only. The bundled Web server reads the separate administrator -key to proxy authenticated console operations; it never sends this key to the -browser. The migration service receives neither. A Web-only connection to an -external Core can enable management by configuring its administrator token -server-side through `CORE_CONSOLE_ADMIN_TOKEN_FILE`; the Web page does not -ask the operator to enter another key. Use the same-origin console connection for -management. Choose English or Chinese through the System language selector. +configured origin. Sign-in sessions live in the console's memory. They expire after +12 hours, and you sign in again after a console restart or a Core key rotation. +Sign-in, status and sign-out use private `/console/auth` routes and do not extend +the public Agent API. The console rejects every `/v1` request, including requests +carrying an explicit Bearer token; it holds no caller key. + +Core loads the Core key digest from `AGENTS_API_CORE_KEY_DIGESTS_FILE`. Only the +Core container mounts the digest file, read-only. The bundled Web server reads the +Core key through `CORE_CONSOLE_CORE_KEY_FILE` to check sign-in and to proxy +authenticated console operations; it never sends the key to the browser. The +migration service receives neither. A Web-only connection to an external Core uses +that Core's key, configured server-side. Existing installations from earlier +releases must rename these files and settings before upgrading; see +[Upgrade an existing installation](operations.md#upgrade-an-existing-installation). +Choose English or Chinese through the System language selector. ## Add nodes after a default installation @@ -288,14 +278,14 @@ Installation defaults are not evidence of model or workload acceptance. ### Separate Web installation To install only Web on a Linux host, provide the existing Core origin and a -private deployment-administrator key file. A loopback Core uses the same host network namespace; +private file containing that Core's Core key. A loopback Core uses the same host network namespace; a remote Core must use HTTPS. ```sh ./install.sh --web-only \ --install-dir "$HOME/.parsar/core-console" \ --core-url http://127.0.0.1:8091 \ - --admin-token-file "$HOME/.parsar/core/admin/sandbox-admin.key" + --core-key-file "$HOME/.parsar/core/admin/core.key" ``` Web-only mode cannot enable a sandbox provider. It starts no database or Core and diff --git a/docs/getting-started/operations.md b/docs/getting-started/operations.md index 97ef2e1bf..fe5c01dd6 100644 --- a/docs/getting-started/operations.md +++ b/docs/getting-started/operations.md @@ -71,6 +71,84 @@ public issue reports. For local diagnosis, use the exact installation file: docker compose -f "$HOME/.parsar/core/compose.json" ps --all ``` +## Core key + +Each installation has one management credential, the Core key. The installer +generates it in `/admin/core.key` (default install dir +`~/.parsar/core`) and writes its SHA-256 digest to `admin/core-key-digests.json`. +Core reads the digest file named by `AGENTS_API_CORE_KEY_DIGESTS_FILE` in +`config/core.env`; Web reads the key file named by `CORE_CONSOLE_CORE_KEY_FILE`. +Both read them only at startup. + +The Core key: + +- signs in to Web. The browser gets an HttpOnly session cookie, never the key; +- authorizes `/core/v1` management requests sent to Core as + `Authorization: Bearer `; +- never authorizes `/v1`. Applications use Project API keys, which in turn cannot + call `/core/v1`. + +Keep it private. `admin/` stays mode `0700` and both files `0600`, owned by the +installation user. Of the services, only Web reads `core.key`; Core reads only the +digest file. Don't copy the key into scripts, shell history, logs or issue reports. +Operator scripts run on the Core host, call Core's loopback port and read the key +from its file. This example keeps the key off the command line: + +```sh +curl -fsS -H @<(printf 'Authorization: Bearer %s\n' "$(cat "$HOME/.parsar/core/admin/core.key")") \ + http://127.0.0.1:8091/core/v1/admin/projects +``` + +### Rotate the Core key + +1. Generate a new key and replace both files: + + ```sh + cd "$HOME/.parsar/core/admin" + umask 077 + key=$(python3 -c 'import secrets; print(secrets.token_hex(32))') + printf '%s\n' "$key" > core.key.new + printf '["%s"]\n' "$(printf '%s' "$key" | sha256sum | cut -d' ' -f1)" > core-key-digests.json.new + mv core.key.new core.key && mv core-key-digests.json.new core-key-digests.json + unset key + ``` + +2. Restart Core and Web so they read the new files: + + ```sh + docker compose -f "$HOME/.parsar/core/compose.json" up -d --force-recreate core web + ``` + + With native Core, run `systemctl --user restart parsar--core.service`, then + the same Compose command with only `web`. A separate Web installation keeps its + own copy in its `admin/core.key`; replace that file and recreate its `web`. +3. Sign in to Web again with the new key and update your scripts. The restart + ends every Web session, and Core rejects the old key. + +The digest file is a JSON array, and Core accepts every digest it lists. To give +scripts time to switch, you can list the old and new digests, restart Core, and +then remove the old digest and restart Core again. Web holds only one key. + +### Upgrade an existing installation + +Earlier releases used other names for these files and settings. Current Core and +Web refuse to start while an old setting is present, and the error names the +replacement. Stop the services, then: + +| Old | New | Where | +| --- | --- | --- | +| `admin/sandbox-admin.key` | `admin/core.key` | File; Web bind mount in `compose.json` | +| `admin/digests.json` | `admin/core-key-digests.json` | File; Core bind mount in `compose.json` | +| `AGENTS_API_SANDBOX_ADMIN_DIGESTS_FILE` | `AGENTS_API_CORE_KEY_DIGESTS_FILE` | `config/core.env`; its value names the renamed file | +| `CORE_CONSOLE_ADMIN_TOKEN_FILE` | `CORE_CONSOLE_CORE_KEY_FILE` | Web `environment` in `compose.json` | + +Also remove `CORE_CONSOLE_AUTH_MODE`, `CORE_CONSOLE_STATE_DIR` and +`CORE_CONSOLE_PASSWORD_FILE` from Web's environment, with their `state/console` +and `config/console.password` mounts. Web no longer has accounts, passwords or +Basic authentication, and it refuses to start while those settings are present. +The old account state is not read; delete it after the upgrade or keep it as a +backup. Then start the services and sign in with the Core key. + ## Projects and API keys Use the [administrator API](../../contracts/agents-api/admin-api.md) to create a @@ -92,8 +170,8 @@ Retain together: - the dedicated PostgreSQL volume, including Projects, API-key digests, provenance and large objects; - `config/credential.key`, the database-owned deployment specification and installation identity; -- `admin/`, including on zero-node installations, containing the separate sandbox - administrator key and Core's digest configuration; +- `admin/`, including on zero-node installations, containing the + [Core key](#core-key) and its digest file; - each separately installed node's private configuration and persistent identity directory on its host (`~/.parsar/nodes//` for the Web-generated installer), as described in the @@ -194,7 +272,7 @@ persisted Files/Artifacts remain. E2B deployments from that period are not cover API and console bind to host loopback. With native Core, PostgreSQL publishes an installation-specific loopback port; with container Core it has no published port. The production Web proxy forwards only allowlisted administrator and sandbox -management routes after console login. Its deployment credential stays on the +management routes after console login. Its Core key stays on the server. The TLS reverse proxy routes `/v1` (applications) and `/api/v1` (node and Runtime daemon connections) directly to Core, together with the project executor-credential API under `/core/v1/environments/`. Everything else, including @@ -203,11 +281,10 @@ console pages, authentication and administration, goes to Web. Web returns 404 f daemon traffic. Machine routes keep their own enrollment and connection credentials. `/node-install/` serves only the matched non-secret node payload. Web has no Docker or KVM authority. -It requires an independent administrator login and rejects untrusted browser origins. -New installations store the administrator password hash in private console state; -legacy installations retain Basic authentication. There is only one Web role, -with full console authority; Agent API caller keys remain separate. Back up the -console account state, and sign in again after a console restart. +It requires [Core key](#core-key) sign-in and rejects untrusted browser origins. +There is only one Web role, with full console authority; Agent API caller keys +remain separate. Sessions live in Web's memory, so sign in again after a Web +restart or Core key rotation. A Core restart leaves the separately supervised node and resident microVM processes alone. Host reboot, user manager termination and loss of a running microVM are not equivalent to that @@ -243,7 +320,7 @@ Projects and their API keys are database records. Keys within one Project share assets and the same execution principal, while provenance identifies the actual key that made each write. Existing resources without recorded provenance return null. Historical key-kind metadata remains audit evidence, not a configuration -authentication path. Console credentials are separate deployment credentials. See the +authentication path. The Core key is separate from Project API keys. See the [query contract](../../contracts/agents-api/write-audit.md) for deployment-authenticated Project-scoped batch ownership and cursor history endpoints. Administrator mutations have a separate [audit log](../../contracts/agents-api/admin-api.md#monitoring-and-audit). These APIs do not log bodies or secrets. diff --git a/services/agents-api/CONTAINER.md b/services/agents-api/CONTAINER.md index 62f07ef8f..e5b387aa5 100644 --- a/services/agents-api/CONTAINER.md +++ b/services/agents-api/CONTAINER.md @@ -27,9 +27,9 @@ UID/GID 65532. No model credentials or tenant keys belong in the image. Use a new private directory for deployment configuration. Create `api.env` with `AGENTS_API_DATABASE_URL` pointing to the dedicated execution database and -`AGENTS_API_SANDBOX_ADMIN_DIGESTS_FILE=/run/admin-digests.json`. Create -`admin-digests.json` as a JSON array of deployment administrator SHA-256 digests. -Keep the bearer separately and use the [administrator API](../../contracts/agents-api/admin-api.md) +`AGENTS_API_CORE_KEY_DIGESTS_FILE=/run/core-key-digests.json`. Create +`core-key-digests.json` as a JSON array of Core key SHA-256 digests. +Keep the Core key separately and use the [administrator API](../../contracts/agents-api/admin-api.md) to create Projects and issue application keys after startup. Keep both files private, for example mode 0600 inside a mode 0700 directory. The database hostname must be reachable from the container; container localhost is not the host. @@ -54,7 +54,7 @@ docker run --name agents-api --detach --read-only \ --user "$(id -u):$(id -g)" \ --publish 127.0.0.1:8091:8091 \ --env-file "$config_dir/api.env" \ - --mount "type=bind,source=$config_dir/admin-digests.json,target=/run/admin-digests.json,readonly" \ + --mount "type=bind,source=$config_dir/core-key-digests.json,target=/run/core-key-digests.json,readonly" \ agents-api:dev curl --fail http://127.0.0.1:8091/healthz docker logs agents-api diff --git a/services/agents-api/HOSTED-RELEASE.md b/services/agents-api/HOSTED-RELEASE.md index 0e635e2d9..db3e8eb6f 100644 --- a/services/agents-api/HOSTED-RELEASE.md +++ b/services/agents-api/HOSTED-RELEASE.md @@ -94,7 +94,7 @@ instructions, public requests, image layers or a workspace. Configuration change require a Core restart. Leave remote executor URLs unset for this hosted profile. ```sh -chmod 0600 "$AGENTS_API_SANDBOX_ADMIN_DIGESTS_FILE" "$AGENTS_API_MANAGED_RUNTIMES_FILE" "$AGENTS_API_EXECUTION_OPTIONS_FILE" +chmod 0600 "$AGENTS_API_CORE_KEY_DIGESTS_FILE" "$AGENTS_API_MANAGED_RUNTIMES_FILE" "$AGENTS_API_EXECUTION_OPTIONS_FILE" "$AGENTS_API_BIN_DIR/agents-api-migrate" "$AGENTS_API_BIN_DIR/agents-api" ``` diff --git a/services/agents-api/HOSTED-SANDBOX-MANAGER.md b/services/agents-api/HOSTED-SANDBOX-MANAGER.md index 3d6021d3b..a8570f649 100644 --- a/services/agents-api/HOSTED-SANDBOX-MANAGER.md +++ b/services/agents-api/HOSTED-SANDBOX-MANAGER.md @@ -10,13 +10,13 @@ remains the user's responsibility. The Web console's **Hosted Sandbox Manager** page uses deployment administrator authority, separate from project credentials. In a paired distribution, the -console server reads its own administrator key and forwards it only on sandbox -management routes after console login. No administrator key reaches the browser. -There is no second login or manual administrator-key form. Management uses the +console server reads the Core key and forwards it only on sandbox management +routes after console login. The Core key never reaches the browser. There is no +second login or manual key form. Management uses the same-origin paired console connection; direct remote project connections do not grant deployment access. An unpaired console shows setup guidance. For manual or -Web-only deployments, an operator can configure the matching private `0600` token -file server-side through `CORE_CONSOLE_SANDBOX_ADMIN_TOKEN_FILE`. +Web-only deployments, an operator configures the matching private `0600` Core key +file server-side through `CORE_CONSOLE_CORE_KEY_FILE`. The sandbox page and its setup, enrollment, status and diagnostic controls support Chinese and English. Choose a language in System navigation; the preference is @@ -95,7 +95,7 @@ does not create a Session, sandbox or model request. Hosted Session admission fails until setup is complete and a ready node has capacity. E2B allocates directly without this node requirement. For manual zero-node deployments, set `AGENTS_API_SANDBOX_INSTALLATION_ID` to a -stable UUID, configure `AGENTS_API_SANDBOX_ADMIN_DIGESTS_FILE`, and enable the daemon +stable UUID, configure `AGENTS_API_CORE_KEY_DIGESTS_FILE`, and enable the daemon gateway using `AGENTS_API_DAEMON_WS_URL`. Do not also set `AGENTS_API_MANAGED_RUNTIMES_FILE`. The Web-selected origin supplies hosted Runtime bootstrap and its public daemon WebSocket address; it never uses request Host or diff --git a/services/agents-api/README.md b/services/agents-api/README.md index e46e81bd1..6c0b4e1fd 100644 --- a/services/agents-api/README.md +++ b/services/agents-api/README.md @@ -157,10 +157,10 @@ incomplete. Durable acceptance is not an exactly-once side-effect guarantee. Run migrations first, then `go run ./services/agents-api/cmd/server`. The service uses `AGENTS_API_DATABASE_URL` for its dedicated database; it does not read the -product database or accept product login cookies. Configure the separate deployment -administrator credential through `AGENTS_API_SANDBOX_ADMIN_DIGESTS_FILE` at startup to manage -Projects and keys. Deployment credentials cannot authenticate `/v1`, and application -API keys cannot authenticate administrator routes. +product database or accept product login cookies. Configure the Core key digest +file through `AGENTS_API_CORE_KEY_DIGESTS_FILE` at startup to manage Projects and +keys. The Core key cannot authenticate `/v1`, and application API keys cannot +authenticate administrator routes. Projects and API keys live only in the database. Configuration files contain infrastructure settings and deployment credentials, not business identities. diff --git a/services/agents-api/RELEASE.md b/services/agents-api/RELEASE.md index bcf30856c..6edf4ec5c 100644 --- a/services/agents-api/RELEASE.md +++ b/services/agents-api/RELEASE.md @@ -41,14 +41,13 @@ umask 077 export PARSAR_HOME="$HOME/.parsar/agents-api-deployment" mkdir -p "$PARSAR_HOME" export AGENTS_API_DATABASE_URL='postgres://:@/' -export AGENTS_API_SANDBOX_ADMIN_DIGESTS_FILE="$PARSAR_HOME/admin-digests.json" +export AGENTS_API_CORE_KEY_DIGESTS_FILE="$PARSAR_HOME/core-key-digests.json" export AGENTS_API_ADDR=127.0.0.1:8091 export AGENTS_API_ENGINE=codex ``` -Create `admin-digests.json` as a JSON array containing the SHA-256 digest of a -random deployment administrator bearer credential. Keep the bearer separately in -private operator storage. After startup, use it to create a Project and issue an +Create `core-key-digests.json` as a JSON array containing the SHA-256 digest of a +random Core key. Keep the Core key separately in private operator storage. After startup, use it to create a Project and issue an application key through the [administrator API](../../contracts/agents-api/admin-api.md). Projects and application keys live only in PostgreSQL. Keys in one Project share its scope and principal; rotation uses issuance and revocation without a restart. diff --git a/services/agents-api/cmd/server/managed_nodes.go b/services/agents-api/cmd/server/managed_nodes.go index 10c629d06..ff9cf1af3 100644 --- a/services/agents-api/cmd/server/managed_nodes.go +++ b/services/agents-api/cmd/server/managed_nodes.go @@ -49,7 +49,7 @@ func configureManagedNodes(s *store.Store, owner func(context.Context) error) (* return nil, err } if setupID != "" && result.admin == nil { - return nil, errors.New("Web sandbox setup requires deployment administrator credentials") + return nil, errors.New("Web sandbox setup requires AGENTS_API_CORE_KEY_DIGESTS_FILE with the Core key digest") } result.hub = node.NewHub(node.HubOptions{ Authenticate: func(ctx context.Context, id, credential string) (node.Identity, error) { @@ -95,17 +95,17 @@ func (m *managedNodes) close() { } func deploymentAdminAuthenticator() (*api.DeploymentAuthenticator, error) { - path := os.Getenv("AGENTS_API_SANDBOX_ADMIN_DIGESTS_FILE") + path := os.Getenv("AGENTS_API_CORE_KEY_DIGESTS_FILE") if path == "" { return nil, nil } raw, err := os.ReadFile(path) if err != nil { - return nil, errors.New("cannot read AGENTS_API_SANDBOX_ADMIN_DIGESTS_FILE") + return nil, errors.New("cannot read AGENTS_API_CORE_KEY_DIGESTS_FILE") } var digests []string if json.Unmarshal(raw, &digests) != nil || len(digests) == 0 { - return nil, errors.New("sandbox administrator configuration must contain an array of SHA-256 digests") + return nil, errors.New("AGENTS_API_CORE_KEY_DIGESTS_FILE must contain a JSON array of Core key SHA-256 digests") } return api.NewDeploymentAuthenticator(digests) } diff --git a/services/agents-api/cmd/server/managed_setup_test.go b/services/agents-api/cmd/server/managed_setup_test.go index 3f8947d77..cd6b81a15 100644 --- a/services/agents-api/cmd/server/managed_setup_test.go +++ b/services/agents-api/cmd/server/managed_setup_test.go @@ -20,11 +20,11 @@ func TestWebSetupCreatesManagerWithoutLocalProvider(t *testing.T) { t.Setenv("AGENTS_API_SANDBOX_INSTALLATION_ID", uuid.NewString()) t.Setenv("AGENTS_API_DAEMON_WS_URL", "ws://core:8091/api/v1/agent-daemon/ws") digest := sha256.Sum256([]byte("synthetic-admin")) - path := filepath.Join(t.TempDir(), "digests.json") + path := filepath.Join(t.TempDir(), "core-key-digests.json") if err := os.WriteFile(path, []byte(`["`+hex.EncodeToString(digest[:])+`"]`), 0600); err != nil { t.Fatal(err) } - t.Setenv("AGENTS_API_SANDBOX_ADMIN_DIGESTS_FILE", path) + t.Setenv("AGENTS_API_CORE_KEY_DIGESTS_FILE", path) m, err := configureManagedNodes(nil, func(context.Context) error { return nil }) if err != nil { t.Fatal(err) @@ -33,7 +33,7 @@ func TestWebSetupCreatesManagerWithoutLocalProvider(t *testing.T) { if m.setup == nil || m.admin == nil || m.hub == nil || m.runtime == nil || m.runtime.Provider != nil { t.Fatal("zero-node setup unexpectedly instantiated local compute or omitted management") } - t.Setenv("AGENTS_API_SANDBOX_ADMIN_DIGESTS_FILE", "") + t.Setenv("AGENTS_API_CORE_KEY_DIGESTS_FILE", "") if _, err := configureManagedNodes(nil, nil); err == nil { t.Fatal("setup accepted without admin authentication") } diff --git a/services/agents-api/cmd/server/process_configuration.go b/services/agents-api/cmd/server/process_configuration.go index 0e2d2f2be..8e85a0a5e 100644 --- a/services/agents-api/cmd/server/process_configuration.go +++ b/services/agents-api/cmd/server/process_configuration.go @@ -23,6 +23,10 @@ func executionConcurrency() (int, error) { } func validateProcessConfiguration() error { + // A renamed setting fails instead of being read under its old name. + if _, present := os.LookupEnv("AGENTS_API_SANDBOX_ADMIN_DIGESTS_FILE"); present { + return errors.New("AGENTS_API_SANDBOX_ADMIN_DIGESTS_FILE was renamed; set AGENTS_API_CORE_KEY_DIGESTS_FILE to the Core key digest file instead") + } for _, retired := range []string{"AGENTS_API_MANAGED_RUNTIMES_FILE", "AGENTS_API_SANDBOX_NODE_STATE_DIR", "AGENTS_API_SANDBOX_NODE_CORE_URL"} { if _, present := os.LookupEnv(retired); present { return errors.New(retired + " is retired; configure deployment in Core and enroll a separate node") diff --git a/services/agents-api/cmd/server/process_configuration_test.go b/services/agents-api/cmd/server/process_configuration_test.go index 208d6c4d9..88b055df1 100644 --- a/services/agents-api/cmd/server/process_configuration_test.go +++ b/services/agents-api/cmd/server/process_configuration_test.go @@ -39,3 +39,11 @@ func TestRetiredConfigurationRejectedWithoutReadingValues(t *testing.T) { }) } } + +func TestRenamedCoreKeyDigestSettingNamesItsReplacement(t *testing.T) { + t.Setenv("AGENTS_API_SANDBOX_ADMIN_DIGESTS_FILE", "/private/admin/digests.json") + err := validateProcessConfiguration() + if err == nil || !strings.Contains(err.Error(), "AGENTS_API_CORE_KEY_DIGESTS_FILE") || strings.Contains(err.Error(), "/private/") { + t.Fatal(err) + } +} diff --git a/services/agents-api/internal/api/project_api_key_configuration.go b/services/agents-api/internal/api/project_api_key_configuration.go index ffc5f72d8..e960925ce 100644 --- a/services/agents-api/internal/api/project_api_key_configuration.go +++ b/services/agents-api/internal/api/project_api_key_configuration.go @@ -10,10 +10,10 @@ type projectKeySeparationStore interface { ValidateProjectKeySeparation(context.Context, []string) error } -// ValidateCredentialSeparation rejects administrator collisions with persisted API keys. +// ValidateCredentialSeparation rejects Core key collisions with persisted API keys. func ValidateCredentialSeparation(ctx context.Context, admin *DeploymentAuthenticator, s projectKeySeparationStore) error { if admin == nil { - return errors.New("an administrator credential is required") + return errors.New("AGENTS_API_CORE_KEY_DIGESTS_FILE is required; Core needs the Core key digest") } digests := make([]string, 0, len(admin.digests)) for digest := range admin.digests { diff --git a/services/agents-api/internal/api/sandbox_manager_auth.go b/services/agents-api/internal/api/sandbox_manager_auth.go index 1ab63e9f5..38d65aeb3 100644 --- a/services/agents-api/internal/api/sandbox_manager_auth.go +++ b/services/agents-api/internal/api/sandbox_manager_auth.go @@ -8,22 +8,23 @@ import ( "strings" ) -// DeploymentAuthenticator is deliberately separate from project API principals. +// DeploymentAuthenticator checks the Core key. It is deliberately separate from +// project API principals. type DeploymentAuthenticator struct{ digests map[[32]byte]struct{} } func NewDeploymentAuthenticator(digests []string) (*DeploymentAuthenticator, error) { if len(digests) == 0 { - return nil, errors.New("at least one deployment administrator digest is required") + return nil, errors.New("at least one Core key digest is required") } result := &DeploymentAuthenticator{digests: make(map[[32]byte]struct{}, len(digests))} for _, value := range digests { raw, err := hex.DecodeString(value) if err != nil || len(raw) != sha256.Size || hex.EncodeToString(raw) != value { - return nil, errors.New("invalid deployment administrator digest") + return nil, errors.New("invalid Core key digest") } digest := [32]byte(raw) if _, exists := result.digests[digest]; exists { - return nil, errors.New("duplicate deployment administrator digest") + return nil, errors.New("duplicate Core key digest") } result.digests[digest] = struct{}{} } @@ -44,7 +45,7 @@ func (a *DeploymentAuthenticator) authenticate(next http.Handler) http.Handler { _, exists := a.digests[sha256.Sum256([]byte(token))] if !valid || !exists { w.Header().Set("WWW-Authenticate", "Bearer") - writeError(w, http.StatusUnauthorized, "invalid_admin_key", "A deployment administrator bearer key is required.") + writeError(w, http.StatusUnauthorized, "invalid_admin_key", "A valid Core key is required as the bearer credential.") return } next.ServeHTTP(w, r) diff --git a/services/agents-api/internal/store/project_api_keys.go b/services/agents-api/internal/store/project_api_keys.go index cd777f1b6..74a4a5ba2 100644 --- a/services/agents-api/internal/store/project_api_keys.go +++ b/services/agents-api/internal/store/project_api_keys.go @@ -190,7 +190,7 @@ func (s *Store) ValidateProjectKeySeparation(ctx context.Context, digests []stri return err } if exists { - return errors.New("administrator credential overlaps a persisted API key") + return errors.New("the Core key overlaps a persisted project API key") } } return nil diff --git a/services/agents-api/tests/container_server.py b/services/agents-api/tests/container_server.py index ef31609ab..6cd303c90 100755 --- a/services/agents-api/tests/container_server.py +++ b/services/agents-api/tests/container_server.py @@ -3,16 +3,16 @@ import os -# Match ownership of the suite's private administrator digest file without granting root access. +# Match ownership of the suite's private Core key digest file without granting root access. # The image's default UID is separately exercised by deployment acceptance. assert os.getuid() != 0, "Run container acceptance as an unprivileged host user" -admin_digests = os.environ["AGENTS_API_SANDBOX_ADMIN_DIGESTS_FILE"] +core_key_digests = os.environ["AGENTS_API_CORE_KEY_DIGESTS_FILE"] args = [ "docker", "run", "--rm", "--read-only", "--network=host", "--cap-drop=ALL", "--security-opt=no-new-privileges", "--user", f"{os.getuid()}:{os.getgid()}", - "--mount", f"type=bind,source={admin_digests},target=/run/admin-digests.json,readonly", - "--env", "AGENTS_API_SANDBOX_ADMIN_DIGESTS_FILE=/run/admin-digests.json", + "--mount", f"type=bind,source={core_key_digests},target=/run/core-key-digests.json,readonly", + "--env", "AGENTS_API_CORE_KEY_DIGESTS_FILE=/run/core-key-digests.json", ] credential_key = os.environ.get("AGENTS_API_CREDENTIAL_KEY_FILE") if credential_key: diff --git a/services/agents-api/tests/official_client.py b/services/agents-api/tests/official_client.py index c9ae22b8b..a08b1593e 100644 --- a/services/agents-api/tests/official_client.py +++ b/services/agents-api/tests/official_client.py @@ -109,13 +109,13 @@ def project_bindings(directory): process = None credential_canary = secrets.token_hex(32) with tempfile.TemporaryDirectory(prefix="agents-api-test-") as directory: - admin_digests = Path(directory) / "admin-digests.json" - admin_digests.touch(mode=0o600) - admin_digests.write_text(json.dumps([hashlib.sha256(admin_token.encode()).hexdigest()])) + core_key_digests = Path(directory) / "core-key-digests.json" + core_key_digests.touch(mode=0o600) + core_key_digests.write_text(json.dumps([hashlib.sha256(admin_token.encode()).hexdigest()])) credential_key = Path(directory) / "credential-key.txt" credential_key.touch(mode=0o600) credential_key.write_text(base64.b64encode(secrets.token_bytes(32)).decode() + "\n") - env = dict(os.environ, AGENTS_API_DATABASE_URL=dsn, AGENTS_API_SANDBOX_ADMIN_DIGESTS_FILE=str(admin_digests), AGENTS_API_ADDR=f"127.0.0.1:{port}", AGENTS_API_ENGINE="codex") + env = dict(os.environ, AGENTS_API_DATABASE_URL=dsn, AGENTS_API_CORE_KEY_DIGESTS_FILE=str(core_key_digests), AGENTS_API_ADDR=f"127.0.0.1:{port}", AGENTS_API_ENGINE="codex") env["AGENTS_API_CREDENTIAL_KEY_FILE"] = str(credential_key) # Enable the real Worker/gateway admission path without connecting a daemon. # Synthetic fixture inputs remain queued; this is not live model acceptance. diff --git a/services/core-console/account_store.go b/services/core-console/account_store.go deleted file mode 100644 index 3f9335e44..000000000 --- a/services/core-console/account_store.go +++ /dev/null @@ -1,153 +0,0 @@ -package main - -import ( - "encoding/json" - "errors" - "io" - "os" - "path/filepath" - "sync" - - "golang.org/x/crypto/bcrypt" -) - -const accountFilename = "admin.json" -const registeredFilename = "registered" - -var errAccountExists = errors.New("administrator already registered") - -type administrator struct { - Version int `json:"version"` - Username string `json:"username"` - PasswordHash string `json:"password_hash"` -} - -type accountStore struct { - directory string - mu sync.Mutex - registered bool -} - -func validateAccountDirectory(directory string) error { - if !filepath.IsAbs(directory) { - return errors.New("CORE_CONSOLE_STATE_DIR must be an absolute private directory") - } - info, err := os.Lstat(directory) - if err != nil || !info.IsDir() || info.Mode().Perm()&0o077 != 0 { - return errors.New("CORE_CONSOLE_STATE_DIR must be an existing private directory") - } - return nil -} - -func newAccountStore(directory string) (*accountStore, error) { - if err := validateAccountDirectory(directory); err != nil { - return nil, err - } - s := &accountStore{directory: directory} - if _, err := s.read(); err != nil { - return nil, err - } - return s, nil -} - -func (s *accountStore) read() (*administrator, error) { - s.mu.Lock() - defer s.mu.Unlock() - return s.readLocked() -} - -func (s *accountStore) readLocked() (*administrator, error) { - name := filepath.Join(s.directory, accountFilename) - info, err := os.Lstat(name) - if errors.Is(err, os.ErrNotExist) && !s.registered { - if _, markerErr := os.Lstat(filepath.Join(s.directory, registeredFilename)); errors.Is(markerErr, os.ErrNotExist) { - return nil, nil - } - return nil, errors.New("registered administrator state is missing") - } - if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0o077 != 0 || info.Size() > 4096 { - return nil, errors.New("administrator state is unavailable or unsafe") - } - f, err := os.Open(name) - if err != nil { - return nil, errors.New("administrator state is unavailable") - } - defer f.Close() - decoder := json.NewDecoder(io.LimitReader(f, 4097)) - decoder.DisallowUnknownFields() - var account administrator - if decoder.Decode(&account) != nil || decoder.Decode(new(any)) != io.EOF || account.Version != 1 || !validUsername(account.Username) { - return nil, errors.New("administrator state is invalid") - } - cost, err := bcrypt.Cost([]byte(account.PasswordHash)) - if err != nil || cost != bcrypt.DefaultCost { - return nil, errors.New("administrator password state is invalid") - } - s.registered = true - return &account, nil -} - -func (s *accountStore) create(account administrator) error { - s.mu.Lock() - defer s.mu.Unlock() - current, err := s.readLocked() - if err != nil { - return err - } - if current != nil { - return errAccountExists - } - f, err := os.CreateTemp(s.directory, ".admin-*") - if err != nil { - return errors.New("cannot create administrator state") - } - defer os.Remove(f.Name()) - defer f.Close() - if err := json.NewEncoder(f).Encode(account); err != nil { - return errors.New("cannot write administrator state") - } - if err := f.Sync(); err != nil { - return errors.New("cannot persist administrator state") - } - // Link publishes a complete file atomically and never replaces another - // process's winner. A crash before publication leaves only an unused temp file. - if err := os.Link(f.Name(), filepath.Join(s.directory, accountFilename)); err != nil { - if errors.Is(err, os.ErrExist) { - return errAccountExists - } - return errors.New("cannot publish administrator state") - } - s.registered = true - // Keep a durable consumed marker so losing just the account file never - // silently reopens administrator registration. - marker, err := os.OpenFile(filepath.Join(s.directory, registeredFilename), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) - if err != nil { - return errors.New("cannot persist registration marker") - } - markerErr := marker.Sync() - closeErr := marker.Close() - if markerErr != nil || closeErr != nil { - return errors.New("cannot persist registration marker") - } - directory, err := os.Open(s.directory) - if err != nil { - return errors.New("cannot persist administrator directory") - } - defer directory.Close() - if err := directory.Sync(); err != nil { - return errors.New("cannot persist administrator directory") - } - return nil -} - -func validUsername(value string) bool { - if len(value) < 1 || len(value) > 64 { - return false - } - for _, ch := range value { - if !(ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z' || ch >= '0' && ch <= '9' || ch == '.' || ch == '_' || ch == '-') { - return false - } - } - return true -} diff --git a/services/core-console/account_store_test.go b/services/core-console/account_store_test.go deleted file mode 100644 index 6fbb227d4..000000000 --- a/services/core-console/account_store_test.go +++ /dev/null @@ -1,112 +0,0 @@ -package main - -import ( - "net/http" - "os" - "path/filepath" - "strings" - "testing" -) - -func TestAccountStoreRejectsUnsafeOrCorruptState(t *testing.T) { - for _, corruption := range []string{"invalid-json", "unknown-version", "bad-hash", "public-file", "symlink", "directory"} { - t.Run(corruption, func(t *testing.T) { - c := accountConsoleConfig(t, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) - h := accountConsole(t, c) - setupAccount(t, h) - name := filepath.Join(c.stateDir, accountFilename) - switch corruption { - case "invalid-json": - _ = os.WriteFile(name, []byte("{"), 0o600) - case "unknown-version", "bad-hash": - body, _ := os.ReadFile(name) - value := string(body) - if corruption == "unknown-version" { - value = strings.Replace(value, `"version":1`, `"version":2`, 1) - } else { - value = strings.Replace(value, "$2a$10$", "$2a$31$", 1) - } - _ = os.WriteFile(name, []byte(value), 0o600) - case "public-file": - _ = os.Chmod(name, 0o644) - case "symlink": - _ = os.Rename(name, name+".backup") - _ = os.Symlink(name+".backup", name) - case "directory": - _ = os.Remove(name) - _ = os.Mkdir(name, 0o700) - } - if reopened, err := newConsole(c); err == nil { - reopened.Close() - t.Fatal("invalid persisted administrator state was accepted") - } - w := authRequest(h, "GET", "/console/auth", "", nil) - if w.Code != 503 || strings.Contains(w.Body.String(), `"setup"`) { - t.Fatal("invalid state reopened registration") - } - }) - } -} - -func TestAccountStoreDoesNotReopenSetupAfterLiveDeletion(t *testing.T) { - c := accountConsoleConfig(t, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) - h := accountConsole(t, c) - setupAccount(t, h) - if err := os.Remove(filepath.Join(c.stateDir, accountFilename)); err != nil { - t.Fatal(err) - } - if w := authRequest(h, "GET", "/console/auth", "", nil); w.Code != 503 { - t.Fatal("missing registered state reopened setup") - } - if w := authRequest(h, "POST", "/console/auth/setup", accountInput("other", testAccountPassword), nil); w.Code != 503 { - t.Fatal("live state removal allowed administrator replacement") - } - if reopened, err := newConsole(c); err == nil { - reopened.Close() - t.Fatal("restart reopened setup after registered account state disappeared") - } -} - -func TestAccountConfigurationIsExplicitAndPrivate(t *testing.T) { - work := t.TempDir() - token := filepath.Join(work, "caller.key") - if err := os.WriteFile(token, []byte("private-core-token"), 0o600); err != nil { - t.Fatal(err) - } - state := filepath.Join(work, "state") - if err := os.Mkdir(state, 0o700); err != nil { - t.Fatal(err) - } - t.Setenv("CORE_CONSOLE_ADMIN_TOKEN_FILE", token) - t.Setenv("CORE_CONSOLE_PASSWORD_FILE", filepath.Join(work, "missing-password")) - t.Setenv("CORE_CONSOLE_AUTH_MODE", "account") - t.Setenv("CORE_CONSOLE_SETUP_KEY_FILE", filepath.Join(work, "obsolete-missing-setup-key")) - t.Setenv("CORE_CONSOLE_STATE_DIR", state) - t.Setenv("CORE_CONSOLE_DIST", work) - t.Setenv("CORE_CONSOLE_ORIGIN", testOrigin) - if c, err := loadConfig(); err != nil || c.password != "" { - t.Fatalf("valid account configuration rejected: %v", err) - } - for _, mode := range []string{"", "unknown"} { - t.Run("mode="+mode, func(t *testing.T) { - t.Setenv("CORE_CONSOLE_AUTH_MODE", mode) - if _, err := loadConfig(); err == nil { - t.Fatal("missing legacy password or unknown mode enabled registration") - } - }) - } - for _, directory := range []string{"", "relative", filepath.Join(work, "missing"), token} { - t.Run("directory="+directory, func(t *testing.T) { - t.Setenv("CORE_CONSOLE_STATE_DIR", directory) - if _, err := loadConfig(); err == nil { - t.Fatal("invalid account directory accepted") - } - }) - } - if err := os.Chmod(state, 0o755); err != nil { - t.Fatal(err) - } - if _, err := loadConfig(); err == nil { - t.Fatal("public account directory accepted") - } -} diff --git a/services/core-console/api_keys_test.go b/services/core-console/api_keys_test.go index e22459f70..3e80f7d68 100644 --- a/services/core-console/api_keys_test.go +++ b/services/core-console/api_keys_test.go @@ -8,23 +8,23 @@ import ( "testing" ) -func TestAdminProxyUsesAccountIdentityAndServerCredential(t *testing.T) { +func TestAdminProxyUsesFixedActorAndServerCoreKey(t *testing.T) { var calls atomic.Int32 - c := accountConsoleConfig(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c := coreKeyConsoleConfig(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1) - if r.Header.Get("Authorization") != "Bearer deployment-token" || r.Header.Get("X-Core-Console-Actor") != "owner" || r.Header.Get("Cookie") != "" || r.Header.Get("Origin") != "" { + if r.Header.Get("Authorization") != "Bearer "+testCoreKey || r.Header.Get("X-Core-Console-Actor") != "console" || r.Header.Get("Cookie") != "" || r.Header.Get("Origin") != "" { t.Error("admin proxy credential or actor boundary failed") } w.WriteHeader(200) })) - h := accountConsole(t, c) + h := startConsole(t, c) paths := []struct{ method, path string }{{"GET", "/core/v1/admin/projects?limit=5"}, {"POST", "/core/v1/admin/projects"}, {"POST", "/core/v1/admin/projects/project/keys"}, {"POST", "/core/v1/admin/projects/project/archive"}, {"DELETE", "/core/v1/admin/projects/project/keys/key"}, {"GET", "/core/v1/admin/projects/key/sessions/session/artifacts/artifact/content"}, {"DELETE", "/core/v1/admin/projects/key/skills/skill/versions/1"}, {"GET", "/core/v1/admin/summary"}, {"GET", "/core/v1/admin/core-metrics?range=6h"}} for _, tc := range paths { if w := authRequest(h, tc.method, tc.path, `{}`, nil); w.Code != 401 { t.Errorf("unauthenticated %s = %d", tc.path, w.Code) } } - cookie := setupAccount(t, h) + cookie := signIn(t, h) for _, tc := range paths { r := httptest.NewRequest(tc.method, tc.path, strings.NewReader(`{}`)) r.Host = h.host @@ -50,10 +50,10 @@ func TestAdminProxyUsesAccountIdentityAndServerCredential(t *testing.T) { t.Fatal("unsupported management operation reached Core") } } -func TestConsoleRequiresAdministratorCredential(t *testing.T) { - c := accountConsoleConfig(t, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) - c.adminToken = "" +func TestConsoleRequiresCoreKey(t *testing.T) { + c := coreKeyConsoleConfig(t, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + c.coreKey = "" if _, err := newConsole(c); err == nil { - t.Fatal("console started without administrator credential") + t.Fatal("console started without the Core key") } } diff --git a/services/core-console/auth.go b/services/core-console/auth.go index 9d6b206cb..364163aa0 100644 --- a/services/core-console/auth.go +++ b/services/core-console/auth.go @@ -3,45 +3,36 @@ package main import ( "crypto/rand" "crypto/sha256" + "crypto/subtle" "encoding/hex" "encoding/json" - "errors" "io" "mime" "net/http" "strings" "sync" "time" - - "golang.org/x/crypto/bcrypt" ) const sessionCookie = "core_console_session" const sessionLifetime = 12 * time.Hour -type consoleSession struct { - username string - expires time.Time -} - +// consoleAuth signs the browser in with the Core key. Sessions live only in +// memory, so a console restart or Core key rotation requires signing in again. type consoleAuth struct { - store *accountStore + coreKey [sha256.Size]byte secure bool mu sync.Mutex - sessions map[[sha256.Size]byte]consoleSession + sessions map[[sha256.Size]byte]time.Time attempts int window time.Time workers chan struct{} } -func newConsoleAuth(c config) (*consoleAuth, error) { - store, err := newAccountStore(c.stateDir) - if err != nil { - return nil, err - } - return &consoleAuth{store: store, - secure: strings.HasPrefix(c.origin, "https://"), sessions: make(map[[sha256.Size]byte]consoleSession), - workers: make(chan struct{}, 2)}, nil +func newConsoleAuth(c config) *consoleAuth { + return &consoleAuth{coreKey: sha256.Sum256([]byte(c.coreKey)), + secure: strings.HasPrefix(c.origin, "https://"), sessions: make(map[[sha256.Size]byte]time.Time), + workers: make(chan struct{}, 2)} } func authJSON(w http.ResponseWriter, status int, value any) { @@ -69,25 +60,25 @@ func cookieDigest(r *http.Request) ([sha256.Size]byte, bool) { return sha256.Sum256([]byte(value)), true } -func (a *consoleAuth) authenticated(r *http.Request) string { +func (a *consoleAuth) authenticated(r *http.Request) bool { digest, ok := cookieDigest(r) if !ok { - return "" + return false } a.mu.Lock() defer a.mu.Unlock() - session, ok := a.sessions[digest] - if !ok || !time.Now().Before(session.expires) { + expires, ok := a.sessions[digest] + if !ok || !time.Now().Before(expires) { delete(a.sessions, digest) - return "" + return false } - return session.username + return true } -func (a *consoleAuth) setSession(w http.ResponseWriter, r *http.Request, username string) { +func (a *consoleAuth) setSession(w http.ResponseWriter, r *http.Request) { token := make([]byte, 32) if _, err := rand.Read(token); err != nil { - authError(w, http.StatusInternalServerError, "Cannot start a login session") + authError(w, http.StatusServiceUnavailable, "Sign-in is unavailable; try again later") return } value := hex.EncodeToString(token) @@ -96,27 +87,27 @@ func (a *consoleAuth) setSession(w http.ResponseWriter, r *http.Request, usernam if previous, ok := cookieDigest(r); ok { delete(a.sessions, previous) } - for key, session := range a.sessions { - if !now.Before(session.expires) { + for key, expires := range a.sessions { + if !now.Before(expires) { delete(a.sessions, key) } } // Keep memory bounded even when clients discard every login cookie. if len(a.sessions) >= 64 { var oldest [sha256.Size]byte - expires := now.Add(sessionLifetime + time.Second) - for key, session := range a.sessions { - if session.expires.Before(expires) { - oldest, expires = key, session.expires + earliest := now.Add(sessionLifetime + time.Second) + for key, expires := range a.sessions { + if expires.Before(earliest) { + oldest, earliest = key, expires } } delete(a.sessions, oldest) } - a.sessions[sha256.Sum256([]byte(value))] = consoleSession{username, now.Add(sessionLifetime)} + a.sessions[sha256.Sum256([]byte(value))] = now.Add(sessionLifetime) a.mu.Unlock() http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: value, Path: "/", HttpOnly: true, Secure: a.secure, SameSite: http.SameSiteStrictMode, MaxAge: int(sessionLifetime.Seconds())}) - authJSON(w, http.StatusOK, map[string]string{"mode": "authenticated", "username": username}) + authJSON(w, http.StatusOK, map[string]string{"mode": "authenticated"}) } func (a *consoleAuth) admitAttempt() bool { @@ -135,21 +126,14 @@ func (a *consoleAuth) admitAttempt() bool { func (a *consoleAuth) serve(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/console/auth" && r.Method == http.MethodGet { - account, err := a.store.read() - if err != nil { - authError(w, http.StatusServiceUnavailable, "Administrator state is unavailable") - return - } - if account == nil { - authJSON(w, http.StatusOK, map[string]string{"mode": "setup"}) - } else if username := a.authenticated(r); username != "" { - authJSON(w, http.StatusOK, map[string]string{"mode": "authenticated", "username": username}) + if a.authenticated(r) { + authJSON(w, http.StatusOK, map[string]string{"mode": "authenticated"}) } else { authJSON(w, http.StatusOK, map[string]string{"mode": "login"}) } return } - if r.URL.Path != "/console/auth/setup" && r.URL.Path != "/console/auth/login" && r.URL.Path != "/console/auth/logout" { + if r.URL.Path != "/console/auth/login" && r.URL.Path != "/console/auth/logout" { authError(w, http.StatusNotFound, "Unknown authentication route") return } @@ -174,23 +158,18 @@ func (a *consoleAuth) serve(w http.ResponseWriter, r *http.Request) { authError(w, http.StatusUnsupportedMediaType, "Use application/json") return } - var input struct { - Username string `json:"username"` - Password string `json:"password"` - } + // Decode members by exact name so case variants count as unknown fields. + var input map[string]json.RawMessage + var coreKey string decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)) - decoder.DisallowUnknownFields() - if decoder.Decode(&input) != nil || decoder.Decode(new(any)) != io.EOF { - authError(w, http.StatusBadRequest, "Invalid authentication request") - return - } - if !validUsername(input.Username) || len(input.Password) < 12 || len(input.Password) > 72 { - authError(w, http.StatusBadRequest, "Use a 1–64 character username (letters, digits, ., _, -) and a 12–72 byte password") + if decoder.Decode(&input) != nil || decoder.Decode(new(any)) != io.EOF || len(input) != 1 || + json.Unmarshal(input["core_key"], &coreKey) != nil || coreKey == "" { + authError(w, http.StatusBadRequest, "Send a JSON object with only a non-empty core_key") return } if !a.admitAttempt() { w.Header().Set("Retry-After", "60") - authError(w, http.StatusTooManyRequests, "Too many authentication attempts; try again in one minute") + authError(w, http.StatusTooManyRequests, "Too many sign-in attempts; try again in one minute") return } select { @@ -198,45 +177,16 @@ func (a *consoleAuth) serve(w http.ResponseWriter, r *http.Request) { defer func() { <-a.workers }() default: w.Header().Set("Retry-After", "1") - authError(w, http.StatusTooManyRequests, "Authentication is busy; try again shortly") + authError(w, http.StatusTooManyRequests, "Sign-in is busy; try again shortly") return } - account, err := a.store.read() - if err != nil { - authError(w, http.StatusServiceUnavailable, "Administrator state is unavailable") + // Comparing fixed-size digests keeps the check constant-time for any key length. + submitted := sha256.Sum256([]byte(coreKey)) + if subtle.ConstantTimeCompare(submitted[:], a.coreKey[:]) != 1 { + authError(w, http.StatusUnauthorized, "Invalid Core key") return } - if r.URL.Path == "/console/auth/setup" { - if account != nil { - authError(w, http.StatusConflict, "Administrator already registered; sign in") - return - } - hash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost) - if err != nil { - authError(w, http.StatusInternalServerError, "Cannot register administrator") - return - } - account = &administrator{Version: 1, Username: input.Username, PasswordHash: string(hash)} - if err := a.store.create(*account); err != nil { - if errors.Is(err, errAccountExists) { - authError(w, http.StatusConflict, "Administrator already registered; sign in") - } else { - authError(w, http.StatusInternalServerError, "Cannot persist administrator; check the private state directory") - } - return - } - } else { - if account == nil { - authError(w, http.StatusConflict, "Register the administrator first") - return - } - passwordErr := bcrypt.CompareHashAndPassword([]byte(account.PasswordHash), []byte(input.Password)) - if passwordErr != nil || input.Username != account.Username { - authError(w, http.StatusUnauthorized, "Invalid username or password") - return - } - } - a.setSession(w, r, account.Username) + a.setSession(w, r) } func publicConsoleAsset(r *http.Request) bool { diff --git a/services/core-console/auth_test.go b/services/core-console/auth_test.go index 3888aa2b9..6a13be85c 100644 --- a/services/core-console/auth_test.go +++ b/services/core-console/auth_test.go @@ -14,9 +14,9 @@ import ( "time" ) -const testAccountPassword = "correct-password-for-console" +const testCoreKey = "deployment-core-key" -func accountConsoleConfig(t *testing.T, backend http.Handler) config { +func coreKeyConsoleConfig(t *testing.T, backend http.Handler) config { t.Helper() upstream := httptest.NewServer(backend) t.Cleanup(upstream.Close) @@ -30,15 +30,10 @@ func accountConsoleConfig(t *testing.T, backend http.Handler) config { t.Fatal(err) } } - state := t.TempDir() - if err := os.Chmod(state, 0o700); err != nil { - t.Fatal(err) - } - return config{origin: testOrigin, upstream: u, dist: dist, adminToken: "deployment-token", - authMode: "account", stateDir: state} + return config{origin: testOrigin, upstream: u, dist: dist, coreKey: testCoreKey} } -func accountConsole(t *testing.T, c config) *console { +func startConsole(t *testing.T, c config) *console { t.Helper() h, err := newConsole(c) if err != nil { @@ -62,42 +57,50 @@ func authRequest(h *console, method, path, body string, cookie *http.Cookie) *ht return w } -func accountInput(username, password string) string { - body, _ := json.Marshal(map[string]string{"username": username, "password": password}) +func coreKeyInput(key string) string { + body, _ := json.Marshal(map[string]string{"core_key": key}) return string(body) } -func setupAccount(t *testing.T, h *console) *http.Cookie { +func signIn(t *testing.T, h *console) *http.Cookie { t.Helper() - w := authRequest(h, "POST", "/console/auth/setup", accountInput("owner", testAccountPassword), nil) - if w.Code != 200 || !strings.Contains(w.Body.String(), `"mode":"authenticated"`) { - t.Fatalf("setup failed: %d %s", w.Code, w.Body) + w := authRequest(h, "POST", "/console/auth/login", coreKeyInput(h.coreKey), nil) + if w.Code != 200 || strings.TrimSpace(w.Body.String()) != `{"mode":"authenticated"}` { + t.Fatalf("sign-in failed: %d %s", w.Code, w.Body) } cookies := w.Result().Cookies() if len(cookies) != 1 { - t.Fatal("setup did not issue exactly one session cookie") + t.Fatal("sign-in did not issue exactly one session cookie") } return cookies[0] } -func TestAccountSetupLoginRestartAndLogout(t *testing.T) { +// Tests that talk to a real listener reuse one session per server, so the +// sign-in rate limit is not consumed by every request. +var testSessions sync.Map + +func serveSignedIn(t *testing.T, h *console) *httptest.Server { + t.Helper() + server := httptest.NewServer(h) + t.Cleanup(server.Close) + testSessions.Store(server.URL, signIn(t, h)) + return server +} + +func TestCoreKeySignInSessionAndLogout(t *testing.T) { var calls atomic.Int32 - c := accountConsoleConfig(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c := coreKeyConsoleConfig(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1) - want := "Bearer deployment-token" - if strings.HasPrefix(r.URL.Path, "/core/") { - want = "Bearer deployment-token" - } - if r.Header.Get("Authorization") != want || r.Header.Get("Cookie") != "" { + if r.Header.Get("Authorization") != "Bearer "+testCoreKey || r.Header.Get("Cookie") != "" { t.Error("console failed to isolate upstream credentials") } w.WriteHeader(200) })) - h := accountConsole(t, c) - if w := authRequest(h, "GET", "/console/auth", "", nil); w.Code != 200 || !strings.Contains(w.Body.String(), `"setup"`) { + h := startConsole(t, c) + if w := authRequest(h, "GET", "/console/auth", "", nil); w.Code != 200 || strings.TrimSpace(w.Body.String()) != `{"mode":"login"}` { t.Fatalf("initial mode: %d %s", w.Code, w.Body) } - for _, path := range []string{"/core/v1/admin/projects", "/console/config", "/core/v1/sandbox/nodes", "/private.txt", "/state/console/admin.json"} { + for _, path := range []string{"/core/v1/admin/projects", "/console/config", "/core/v1/sandbox/nodes", "/private.txt"} { if w := authRequest(h, "GET", path, "", nil); w.Code != 401 { t.Errorf("private path %s returned %d", path, w.Code) } @@ -107,58 +110,44 @@ func TestAccountSetupLoginRestartAndLogout(t *testing.T) { t.Errorf("login asset %s returned %d", path, w.Code) } } + wrong := authRequest(h, "POST", "/console/auth/login", coreKeyInput("incorrect-core-key"), nil) + if wrong.Code != 401 || len(wrong.Result().Cookies()) != 0 || !strings.Contains(wrong.Body.String(), `"error"`) { + t.Fatalf("wrong key: %d %s", wrong.Code, wrong.Body) + } if calls.Load() != 0 { t.Fatal("unauthenticated request reached Core") } - cookie := setupAccount(t, h) + cookie := signIn(t, h) if !cookie.HttpOnly || cookie.SameSite != http.SameSiteStrictMode || cookie.Path != "/" || cookie.MaxAge != 43200 || cookie.Secure { t.Fatalf("unsafe cookie: %+v", cookie) } + if w := authRequest(h, "GET", "/console/auth", "", cookie); strings.TrimSpace(w.Body.String()) != `{"mode":"authenticated"}` { + t.Fatalf("signed-in mode: %s", w.Body) + } for _, path := range []string{"/core/v1/admin/projects", "/core/v1/sandbox/nodes", "/console/config"} { w := authRequest(h, "GET", path, "", cookie) - if w.Code != 200 || strings.Contains(w.Body.String(), c.adminToken) || strings.Contains(w.Body.String(), c.adminToken) { - t.Errorf("authenticated path %s failed or leaked a credential: %d", path, w.Code) + if w.Code != 200 || strings.Contains(w.Body.String(), testCoreKey) { + t.Errorf("authenticated path %s failed or leaked the Core key: %d", path, w.Code) } } if calls.Load() != 2 { - t.Fatal("authenticated public/admin requests did not reach Core") - } - if w := authRequest(h, "POST", "/console/auth/setup", accountInput("other", testAccountPassword), nil); w.Code != 409 { - t.Fatal("registration remained open after setup") - } - stored, err := os.ReadFile(filepath.Join(c.stateDir, accountFilename)) - if err != nil || strings.Contains(string(stored), testAccountPassword) { - t.Fatal("account was not persisted safely") - } - info, _ := os.Stat(filepath.Join(c.stateDir, accountFilename)) - if info.Mode().Perm() != 0o600 { - t.Fatal("account file is not private") + t.Fatal("authenticated management requests did not reach Core") } - restarted := accountConsole(t, c) - if w := authRequest(restarted, "GET", "/console/auth", "", cookie); w.Code != 200 || !strings.Contains(w.Body.String(), `"login"`) { - t.Fatal("restart lost the account or retained the old session") + if w := authRequest(startConsole(t, c), "GET", "/console/auth", "", cookie); !strings.Contains(w.Body.String(), `"login"`) { + t.Fatal("a restarted console accepted a previous session") } - if w := authRequest(restarted, "POST", "/console/auth/login", accountInput("owner", "incorrect-password"), nil); w.Code != 401 { - t.Fatal("incorrect password accepted") - } - loggedIn := authRequest(restarted, "POST", "/console/auth/login", accountInput("owner", testAccountPassword), nil) - if loggedIn.Code != 200 { - t.Fatalf("persistent account login failed: %d", loggedIn.Code) - } - newCookie := loggedIn.Result().Cookies()[0] - logout := authRequest(restarted, "POST", "/console/auth/logout", "", newCookie) - if logout.Code != 200 || !strings.Contains(logout.Body.String(), `"login"`) || logout.Result().Cookies()[0].MaxAge != -1 { + logout := authRequest(h, "POST", "/console/auth/logout", "", cookie) + if logout.Code != 200 || strings.TrimSpace(logout.Body.String()) != `{"mode":"login"}` || logout.Result().Cookies()[0].MaxAge != -1 { t.Fatal("logout response did not clear the session") } - if w := authRequest(restarted, "GET", "/core/v1/admin/projects", "", newCookie); w.Code != 401 { + if w := authRequest(h, "GET", "/core/v1/admin/projects", "", cookie); w.Code != 401 { t.Fatal("logged-out cookie retained authority") } } -func TestAccountOriginChecksAndRequestBounds(t *testing.T) { - c := accountConsoleConfig(t, http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Error("unexpected proxy call") })) - h := accountConsole(t, c) - for _, path := range []string{"/console/auth", "/console/auth/setup", "/console/auth/login", "/console/auth/logout"} { +func TestCoreKeySignInRejectsMalformedAndCrossOriginRequests(t *testing.T) { + h := startConsole(t, coreKeyConsoleConfig(t, http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Error("unexpected proxy call") }))) + for _, path := range []string{"/console/auth", "/console/auth/login", "/console/auth/logout"} { method := "POST" if path == "/console/auth" { method = "GET" @@ -168,7 +157,7 @@ func TestAccountOriginChecksAndRequestBounds(t *testing.T) { func(r *http.Request) { r.Header.Set("Origin", "https://attacker.example") }, func(r *http.Request) { r.Header.Set("Sec-Fetch-Site", "cross-site") }, } { - r := httptest.NewRequest(method, path, strings.NewReader(accountInput("owner", testAccountPassword))) + r := httptest.NewRequest(method, path, strings.NewReader(coreKeyInput(testCoreKey))) r.Host = h.host r.Header.Set("Origin", testOrigin) modify(r) @@ -179,87 +168,68 @@ func TestAccountOriginChecksAndRequestBounds(t *testing.T) { } } } - for _, body := range []string{ - accountInput("invalid user", testAccountPassword), - accountInput("owner", "short"), - accountInput("owner", strings.Repeat("界", 25)), - accountInput("owner", strings.Repeat("x", 4100)), - `{"username":"owner","password":"valid-password","unexpected":true}`, - accountInput("owner", testAccountPassword) + `{}`, - } { - if w := authRequest(h, "POST", "/console/auth/setup", body, nil); w.Code != 400 { - t.Errorf("invalid body returned %d", w.Code) + for _, body := range []string{"", "null", `[]`, `{}`, `{"core_key":""}`, `{"core_key":null}`, `{"core_key":1}`, + `{"CORE_KEY":"` + testCoreKey + `"}`, `{"core_key":"` + testCoreKey + `","username":"admin"}`, + coreKeyInput(testCoreKey) + `{}`, coreKeyInput(strings.Repeat("x", 4100))} { + if w := authRequest(h, "POST", "/console/auth/login", body, nil); w.Code != 400 || strings.Contains(w.Body.String(), testCoreKey) { + t.Errorf("malformed body %.40q returned %d", body, w.Code) } } - for _, path := range []string{"/console/auth/setup", "/console/auth/login", "/console/auth/logout"} { + r := httptest.NewRequest("POST", "/console/auth/login", strings.NewReader(coreKeyInput(testCoreKey))) + r.Host = h.host + r.Header.Set("Origin", testOrigin) + r.Header.Set("Content-Type", "text/plain") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != 415 { + t.Fatalf("non-JSON sign-in returned %d", w.Code) + } + for _, path := range []string{"/console/auth/login", "/console/auth/logout"} { if w := authRequest(h, "GET", path, "", nil); w.Code != 405 { t.Errorf("GET mutation %s returned %d", path, w.Code) } } - r := httptest.NewRequest("POST", "/console/auth/setup", strings.NewReader(accountInput("owner", testAccountPassword))) + if w := authRequest(h, "POST", "/console/auth/setup", `{"username":"owner","password":"retired-password"}`, nil); w.Code != 404 { + t.Fatalf("retired setup route returned %d", w.Code) + } + r = httptest.NewRequest("GET", "/core/v1/admin/projects", nil) + r.Host = h.host + r.SetBasicAuth("admin", testCoreKey) + w = httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != 401 || w.Header().Get("WWW-Authenticate") != "" { + t.Fatal("Basic authentication is still accepted or advertised") + } + r = httptest.NewRequest("POST", "/console/auth/login", strings.NewReader(coreKeyInput(testCoreKey))) r.Host = h.host r.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() + w = httptest.NewRecorder() h.ServeHTTP(w, r) if w.Code != 403 { - t.Fatal("mutation without browser origin evidence was accepted") + t.Fatal("sign-in without browser origin evidence was accepted") } for attempt := 0; attempt < 10; attempt++ { - w := authRequest(h, "POST", "/console/auth/login", accountInput("owner", testAccountPassword), nil) - if w.Code != 409 { + if w := authRequest(h, "POST", "/console/auth/login", coreKeyInput("incorrect"), nil); w.Code != 401 { t.Fatalf("attempt %d unexpectedly returned %d", attempt, w.Code) } } - if w := authRequest(h, "POST", "/console/auth/setup", accountInput("owner", testAccountPassword), nil); w.Code != 429 { - t.Fatal("authentication rate limit was not enforced") - } -} - -func TestConcurrentAccountSetupAcrossInstances(t *testing.T) { - c := accountConsoleConfig(t, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) - servers := []*console{accountConsole(t, c), accountConsole(t, c)} - var group sync.WaitGroup - results := make(chan int, 2) - for index, server := range servers { - group.Add(1) - go func() { - defer group.Done() - username := []string{"alice", "bob"}[index] - results <- authRequest(server, "POST", "/console/auth/setup", accountInput(username, testAccountPassword), nil).Code - }() - } - group.Wait() - close(results) - counts := map[int]int{} - for code := range results { - counts[code]++ - } - if counts[200] != 1 || counts[409] != 1 { - t.Fatalf("registration race did not yield one winner: %v", counts) - } - for _, h := range servers { - if w := authRequest(h, "GET", "/console/auth", "", nil); !strings.Contains(w.Body.String(), `"login"`) { - t.Fatal("another instance did not observe registration") - } - } - entries, _ := os.ReadDir(c.stateDir) - if len(entries) != 2 || entries[0].Name() != accountFilename || entries[1].Name() != registeredFilename { - t.Fatal("registration left incomplete or competing account files") + limited := authRequest(h, "POST", "/console/auth/login", coreKeyInput(testCoreKey), nil) + if limited.Code != 429 || limited.Header().Get("Retry-After") != "60" || len(limited.Result().Cookies()) != 0 { + t.Fatal("sign-in rate limit was not enforced") } } -func TestAccountSecureCookieExpiry(t *testing.T) { - c := accountConsoleConfig(t, http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Error("expired session reached Core") })) +func TestSecureCookieExpiry(t *testing.T) { + c := coreKeyConsoleConfig(t, http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Error("expired session reached Core") })) c.origin = "https://127.0.0.1:8080" - h := accountConsole(t, c) - cookie := setupAccount(t, h) + h := startConsole(t, c) + cookie := signIn(t, h) if !cookie.Secure { t.Fatal("HTTPS session cookie omitted Secure") } h.auth.mu.Lock() - for key, session := range h.auth.sessions { - session.expires = time.Now().Add(-time.Second) - h.auth.sessions[key] = session + for key := range h.auth.sessions { + h.auth.sessions[key] = time.Now().Add(-time.Second) } h.auth.mu.Unlock() if w := authRequest(h, "GET", "/core/v1/admin/projects", "", cookie); w.Code != 401 { diff --git a/services/core-console/config.go b/services/core-console/config.go index e8ff6d1e4..2ee819f3b 100644 --- a/services/core-console/config.go +++ b/services/core-console/config.go @@ -11,13 +11,21 @@ import ( ) type config struct { - addr, origin, dist, password string - adminToken, nodePayloadDir string - authMode, stateDir string - upstream *url.URL + addr, origin, dist string + coreKey, nodePayloadDir string + upstream *url.URL } func loadConfig() (config, error) { + // Renamed and retired settings fail startup instead of being silently ignored. + if _, present := os.LookupEnv("CORE_CONSOLE_ADMIN_TOKEN_FILE"); present { + return config{}, errors.New("CORE_CONSOLE_ADMIN_TOKEN_FILE was renamed; set CORE_CONSOLE_CORE_KEY_FILE to the Core key file instead") + } + for _, retired := range []string{"CORE_CONSOLE_AUTH_MODE", "CORE_CONSOLE_STATE_DIR", "CORE_CONSOLE_PASSWORD_FILE"} { + if _, present := os.LookupEnv(retired); present { + return config{}, errors.New(retired + " is retired; Web signs in with the Core key only, so remove this setting") + } + } c := config{ addr: envDefault("CORE_CONSOLE_ADDR", ":8080"), origin: envDefault("CORE_CONSOLE_ORIGIN", "http://127.0.0.1:8080"), @@ -34,31 +42,13 @@ func loadConfig() (config, error) { if !filepath.IsAbs(c.dist) { return config{}, errors.New("CORE_CONSOLE_DIST must be absolute") } - c.adminToken, err = readSecret(envDefault("CORE_CONSOLE_ADMIN_TOKEN_FILE", "/admin/sandbox-admin.key")) + c.coreKey, err = readSecret(envDefault("CORE_CONSOLE_CORE_KEY_FILE", "/admin/core.key")) if err != nil { - return config{}, errors.New("CORE_CONSOLE_ADMIN_TOKEN_FILE must name a private regular file containing one token") - } - c.authMode = os.Getenv("CORE_CONSOLE_AUTH_MODE") - switch c.authMode { - case "": - c.password, err = readSecret(envDefault("CORE_CONSOLE_PASSWORD_FILE", "/config/console.password")) - if err != nil { - return config{}, errors.New("CORE_CONSOLE_PASSWORD_FILE must name a private regular file containing one password") - } - if c.password == c.adminToken { - return config{}, errors.New("console password and administrator credential must differ") - } - case "account": - c.stateDir = os.Getenv("CORE_CONSOLE_STATE_DIR") - if err := validateAccountDirectory(c.stateDir); err != nil { - return config{}, err - } - default: - return config{}, errors.New("CORE_CONSOLE_AUTH_MODE must be account or unset for legacy Basic authentication") + return config{}, errors.New("CORE_CONSOLE_CORE_KEY_FILE must name a private regular file containing the Core key") } c.nodePayloadDir = os.Getenv("CORE_CONSOLE_NODE_PAYLOAD_DIR") - if c.nodePayloadDir != "" && (!filepath.IsAbs(c.nodePayloadDir) || c.adminToken == "") { - return config{}, errors.New("node payload requires an absolute directory and paired administrator access") + if c.nodePayloadDir != "" && !filepath.IsAbs(c.nodePayloadDir) { + return config{}, errors.New("CORE_CONSOLE_NODE_PAYLOAD_DIR must be absolute") } return c, nil } diff --git a/services/core-console/config_test.go b/services/core-console/config_test.go index 3ab8e4814..6372c9ab3 100644 --- a/services/core-console/config_test.go +++ b/services/core-console/config_test.go @@ -9,19 +9,16 @@ import ( func TestConfigRejectsUnsafeURLsAndSecretFiles(t *testing.T) { directory := t.TempDir() - token, password := filepath.Join(directory, "caller.key"), filepath.Join(directory, "console.password") - for name, value := range map[string]string{token: "private-core-token\n", password: "private-console-password\n"} { - if err := os.WriteFile(name, []byte(value), 0o600); err != nil { - t.Fatal(err) - } + key := filepath.Join(directory, "core.key") + if err := os.WriteFile(key, []byte("private-core-key\n"), 0o600); err != nil { + t.Fatal(err) } - t.Setenv("CORE_CONSOLE_ADMIN_TOKEN_FILE", token) - t.Setenv("CORE_CONSOLE_PASSWORD_FILE", password) + t.Setenv("CORE_CONSOLE_CORE_KEY_FILE", key) t.Setenv("CORE_CONSOLE_ORIGIN", testOrigin) t.Setenv("CORE_CONSOLE_UPSTREAM", "http://core:8091") t.Setenv("CORE_CONSOLE_DIST", directory) c, err := loadConfig() - if err != nil || c.adminToken != "private-core-token" { + if err != nil || c.coreKey != "private-core-key" { t.Fatalf("valid configuration failed: %v", err) } for _, value := range []string{"http://user:secret@core:8091", "http://core:8091/v1", "http://core:8091?token=secret", "http://core:8091#", "file:///config/caller.key", ""} { @@ -34,20 +31,44 @@ func TestConfigRejectsUnsafeURLsAndSecretFiles(t *testing.T) { }) } for _, value := range []string{"", "token with spaces", strings.Repeat("x", 4097), "token\x00"} { - if err := os.WriteFile(token, []byte(value), 0o600); err != nil { + if err := os.WriteFile(key, []byte(value), 0o600); err != nil { t.Fatal(err) } if _, err := loadConfig(); err == nil { - t.Fatal("invalid token accepted") + t.Fatal("invalid Core key accepted") } } - if err := os.WriteFile(token, []byte("valid-token"), 0o600); err != nil { + if err := os.WriteFile(key, []byte("valid-token"), 0o600); err != nil { t.Fatal(err) } - if err := os.Chmod(token, 0o644); err != nil { + if err := os.Chmod(key, 0o644); err != nil { t.Fatal(err) } if _, err := loadConfig(); err == nil { t.Fatal("publicly readable secret accepted") } } + +func TestConfigRejectsRenamedAndRetiredSettings(t *testing.T) { + directory := t.TempDir() + key := filepath.Join(directory, "core.key") + if err := os.WriteFile(key, []byte("private-core-key"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("CORE_CONSOLE_CORE_KEY_FILE", key) + t.Setenv("CORE_CONSOLE_DIST", directory) + for setting, want := range map[string]string{ + "CORE_CONSOLE_ADMIN_TOKEN_FILE": "CORE_CONSOLE_CORE_KEY_FILE", + "CORE_CONSOLE_AUTH_MODE": "Core key", + "CORE_CONSOLE_STATE_DIR": "Core key", + "CORE_CONSOLE_PASSWORD_FILE": "Core key", + } { + t.Run(setting, func(t *testing.T) { + t.Setenv(setting, key) + _, err := loadConfig() + if err == nil || !strings.Contains(err.Error(), setting) || !strings.Contains(err.Error(), want) || strings.Contains(err.Error(), key) { + t.Fatalf("old setting error = %v", err) + } + }) + } +} diff --git a/services/core-console/distribution_test.go b/services/core-console/distribution_test.go index f355ec246..bb35de2fb 100644 --- a/services/core-console/distribution_test.go +++ b/services/core-console/distribution_test.go @@ -31,7 +31,7 @@ func TestOfflineArtifactsAreManifestAllowlisted(t *testing.T) { } } upstream, _ := url.Parse("http://127.0.0.1:1") - h, err := newConsole(config{origin: testOrigin, upstream: upstream, dist: dist, password: "private-console-password", adminToken: "server-admin", nodePayloadDir: payload}) + h, err := newConsole(config{origin: testOrigin, upstream: upstream, dist: dist, coreKey: "server-admin", nodePayloadDir: payload}) if err != nil { t.Fatal(err) } @@ -40,7 +40,6 @@ func TestOfflineArtifactsAreManifestAllowlisted(t *testing.T) { defer server.Close() for _, name := range []string{"matched-node", "private-key", "undeclared"} { req := consoleRequest(t, server, "GET", "/node-install/artifacts/"+name) - req.Header.Del("Authorization") response, body := responseBody(t, server, req) if name == "matched-node" { if response.StatusCode != 200 || !strings.Contains(body, "payload") { diff --git a/services/core-console/node_installation.go b/services/core-console/node_installation.go index 2ab02e3a3..739ce5f0d 100644 --- a/services/core-console/node_installation.go +++ b/services/core-console/node_installation.go @@ -100,9 +100,7 @@ func (h *console) serveNodePayload(w http.ResponseWriter, r *http.Request) { func (h *console) serveConsoleConfiguration(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(struct { - SandboxAdmin bool `json:"sandbox_admin"` - APIKeys bool `json:"api_keys"` NodeInstaller bool `json:"node_installer"` NodeInstallerSHA256 string `json:"node_installer_sha256"` - }{h.adminToken != "", h.adminToken != "", h.nodePayload != nil, h.nodeInstallerDigest}) + }{h.nodePayload != nil, h.nodeInstallerDigest}) } diff --git a/services/core-console/node_installation_test.go b/services/core-console/node_installation_test.go index 5d7b58954..0da5f6422 100644 --- a/services/core-console/node_installation_test.go +++ b/services/core-console/node_installation_test.go @@ -29,36 +29,35 @@ func TestPairedConsoleProxiesOnlyAdministration(t *testing.T) { t.Fatal(err) } } - h, err := newConsole(config{origin: testOrigin, upstream: u, dist: dist, password: "private-console-password", adminToken: "server-admin", nodePayloadDir: payload}) + h, err := newConsole(config{origin: testOrigin, upstream: u, dist: dist, coreKey: "server-admin", nodePayloadDir: payload}) if err != nil { t.Fatal(err) } defer h.Close() - server := httptest.NewServer(h) - defer server.Close() + server := serveSignedIn(t, h) for _, tc := range []struct { method, path, auth string status int }{ - {"POST", "/core/v1/sandbox/deployment", "basic", 200}, + {"POST", "/core/v1/sandbox/deployment", "session", 200}, {"POST", "/core/v1/sandbox/deployment", "none", 401}, - {"PUT", "/core/v1/sandbox/deployment", "basic", 200}, + {"PUT", "/core/v1/sandbox/deployment", "session", 200}, {"PUT", "/core/v1/sandbox/deployment", "none", 401}, - {"PATCH", "/core/v1/sandbox/deployment/maintenance", "basic", 200}, + {"PATCH", "/core/v1/sandbox/deployment/maintenance", "session", 200}, {"PATCH", "/core/v1/sandbox/deployment/maintenance", "none", 401}, {"GET", "/core/v1/sandbox/nodes", "node", 401}, - {"GET", "/core/v1/admin/projects", "basic", 200}, - {"POST", "/core/v1/sandbox/enroll", "basic", 404}, + {"GET", "/core/v1/admin/projects", "session", 200}, + {"POST", "/core/v1/sandbox/enroll", "session", 404}, {"POST", "/api/v1/sandbox-node/enroll", "node", 404}, - {"GET", "/console/config", "basic", 200}, + {"GET", "/console/config", "session", 200}, {"GET", "/console/config", "none", 401}, {"GET", "/node-install/node-install.pyz", "none", 200}, {"GET", "/node-install/caller.key", "none", 404}, {"POST", "/node-install/node-install.pyz", "none", 405}, } { r := consoleRequest(t, server, tc.method, tc.path) - if tc.auth == "none" { - r.Header.Del("Authorization") + if tc.auth != "session" { + r.Header.Del("Cookie") } if tc.auth == "node" { r.Header.Set("Authorization", "Bearer node-token") @@ -73,8 +72,9 @@ func TestPairedConsoleProxiesOnlyAdministration(t *testing.T) { if strings.Contains(body, "server-admin") || strings.Contains(body, "project-token") || strings.Contains(body, "must-not-be-served") { t.Fatal("credential leaked") } - if tc.path == "/console/config" && tc.status == 200 && !strings.Contains(body, `"sandbox_admin":true`) { - t.Fatal("paired mode missing") + if tc.path == "/console/config" && tc.status == 200 && (!strings.Contains(body, `"node_installer":true`) || + strings.Contains(body, "sandbox_admin") || strings.Contains(body, "api_keys")) { + t.Fatalf("console configuration = %s", body) } } if calls.Load() != 4 { diff --git a/services/core-console/project_proxy_test.go b/services/core-console/project_proxy_test.go index 44370116c..c5c805c26 100644 --- a/services/core-console/project_proxy_test.go +++ b/services/core-console/project_proxy_test.go @@ -10,13 +10,13 @@ import ( // Applications (/v1) and machines (/api/v1) reach Core directly through the // reverse proxy; the console forwards neither, whatever credential is presented. func TestCoreDirectRoutesNeverPassThroughConsole(t *testing.T) { - c := accountConsoleConfig(t, http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Error("direct Core route reached Core through console") })) - h := accountConsole(t, c) - cookie := setupAccount(t, h) + c := coreKeyConsoleConfig(t, http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Error("direct Core route reached Core through console") })) + h := startConsole(t, c) + cookie := signIn(t, h) for _, path := range []string{"/v1", "/v1/agents", "/v1/agents/sessions", "/v1/files/file/content", "/core/v1/environments/env/executor-credentials", "/console/api-keys", "/api/v1", "/api/v1/sandbox-node/enroll", "/api/v1/sandbox-node/configuration", "/api/v1/sandbox-node/connect", "/api/v1/agent-daemon/enroll", "/api/v1/agent-daemon/ws"} { for _, method := range []string{"GET", "POST", "DELETE"} { - for _, authorization := range []string{"", "Bearer project-key", "Bearer node-token", "Basic YWRtaW46cGFzc3dvcmQ="} { + for _, authorization := range []string{"", "Bearer project-key", "Bearer node-token", "Bearer " + testCoreKey, "Basic YWRtaW46cGFzc3dvcmQ="} { for _, upgrade := range []string{"", "websocket"} { r := httptest.NewRequest(method, path, strings.NewReader(`{}`)) r.Host = h.host @@ -56,9 +56,9 @@ func TestManagedSessionArchiveAllowlist(t *testing.T) { // Encoded or doubled separators cannot turn a /core request into a forwarded // /api/v1 request. func TestConsoleRejectsSmuggledMachinePaths(t *testing.T) { - c := accountConsoleConfig(t, http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Error("smuggled path reached Core") })) - h := accountConsole(t, c) - cookie := setupAccount(t, h) + c := coreKeyConsoleConfig(t, http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Error("smuggled path reached Core") })) + h := startConsole(t, c) + cookie := signIn(t, h) for _, target := range []string{"/core/v1/sandbox/..%2F..%2F..%2Fapi/v1/sandbox-node/enroll", "//api/v1/sandbox-node/enroll", "/core/v1/sandbox/nodes/%2E%2E/%2E%2E/%2E%2E/%2E%2E/api/v1/agent-daemon/ws"} { r := httptest.NewRequest("POST", target, strings.NewReader(`{}`)) diff --git a/services/core-console/sandbox_admin_test.go b/services/core-console/sandbox_admin_test.go index 4497ecc96..f4a9d4d86 100644 --- a/services/core-console/sandbox_admin_test.go +++ b/services/core-console/sandbox_admin_test.go @@ -22,8 +22,8 @@ func TestSandboxAdminUsesAuthenticatedConsoleAndServerCredential(t *testing.T) { var calls atomic.Int32 server, _ := testConsole(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1) - if r.Header.Get("Authorization") != "Bearer private-admin-token" || r.Header.Get("X-Core-Console-Actor") != "admin" { - t.Error("Basic console did not supply fixed administrator identity") + if r.Header.Get("Authorization") != "Bearer private-core-key" || r.Header.Get("X-Core-Console-Actor") != "console" { + t.Error("console did not supply the Core key and fixed actor label") } w.WriteHeader(204) })) @@ -35,7 +35,7 @@ func TestSandboxAdminUsesAuthenticatedConsoleAndServerCredential(t *testing.T) { t.Errorf("%s = %d", tc.path, response.StatusCode) } } - for _, token := range []string{"project-key", "private-admin-token", "incorrect"} { + for _, token := range []string{"project-key", "private-core-key", "incorrect"} { response, _ := responseBody(t, server, adminRequest(t, server.URL, "GET", "/core/v1/sandbox/nodes", token)) if response.StatusCode != 401 { t.Fatal("bearer bypassed console login") diff --git a/services/core-console/server.go b/services/core-console/server.go index 921123311..c265e96ef 100644 --- a/services/core-console/server.go +++ b/services/core-console/server.go @@ -2,8 +2,6 @@ package main import ( "context" - "crypto/sha256" - "crypto/subtle" "errors" "io" stdlog "log" @@ -23,15 +21,14 @@ type console struct { proxy *httputil.ReverseProxy transport *http.Transport host string - password [sha256.Size]byte auth *consoleAuth } type consoleActorContextKey struct{} func newConsole(c config) (*console, error) { - if c.adminToken == "" { - return nil, errors.New("console requires a deployment administrator credential") + if c.coreKey == "" { + return nil, errors.New("console requires the Core key") } root, err := os.OpenRoot(c.dist) if err != nil { @@ -43,14 +40,7 @@ func newConsole(c config) (*console, error) { return nil, errors.New("console assets require index.html") } origin, _ := url.Parse(c.origin) - h := &console{config: c, root: root, host: origin.Host, password: sha256.Sum256([]byte(c.password))} - if c.authMode == "account" { - h.auth, err = newConsoleAuth(c) - if err != nil { - root.Close() - return nil, err - } - } + h := &console{config: c, root: root, host: origin.Host, auth: newConsoleAuth(c)} if c.nodePayloadDir != "" { h.nodePayload, err = os.OpenRoot(c.nodePayloadDir) if err != nil { @@ -78,7 +68,7 @@ func newConsole(c config) (*console, error) { r.Out.Header.Del("Cookie") r.Out.Header.Del("Origin") r.Out.Header.Del("Referer") - r.Out.Header.Set("Authorization", "Bearer "+c.adminToken) + r.Out.Header.Set("Authorization", "Bearer "+c.coreKey) actor, _ := r.In.Context().Value(consoleActorContextKey{}).(string) r.Out.Header.Set("X-Core-Console-Actor", actor) }, @@ -144,36 +134,16 @@ func (h *console) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } if r.URL.Path == "/console/auth" || strings.HasPrefix(r.URL.Path, "/console/auth/") { - if h.auth != nil { - h.auth.serve(w, r) - } else if r.URL.Path == "/console/auth" && r.Method == http.MethodGet { - authJSON(w, http.StatusOK, map[string]string{"mode": "legacy"}) - } else { - authError(w, http.StatusNotFound, "Account authentication is not configured") - } + h.auth.serve(w, r) return } - if h.auth != nil && publicConsoleAsset(r) { + if publicConsoleAsset(r) { h.serveStatic(w, r) return } - actor := "admin" - if h.auth != nil { - actor = h.auth.authenticated(r) - if actor == "" { - authError(w, http.StatusUnauthorized, "Sign in to the console") - return - } - } else { - username, password, ok := r.BasicAuth() - digest := sha256.Sum256([]byte(password)) - userDigest, adminDigest := sha256.Sum256([]byte(username)), sha256.Sum256([]byte("admin")) - if !ok || len(r.Header.Values("Authorization")) != 1 || - subtle.ConstantTimeCompare(digest[:], h.password[:])&subtle.ConstantTimeCompare(userDigest[:], adminDigest[:]) != 1 { - w.Header().Set("WWW-Authenticate", `Basic realm="Core console", charset="UTF-8"`) - http.Error(w, "Authentication required", http.StatusUnauthorized) - return - } + if !h.auth.authenticated(r) { + authError(w, http.StatusUnauthorized, "Sign in to the console") + return } if r.URL.Path == "/console/config" && r.Method == http.MethodGet { h.serveConsoleConfiguration(w, r) @@ -184,7 +154,8 @@ func (h *console) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } - h.proxy.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), consoleActorContextKey{}, actor))) + // Core records this fixed audit label for display only; there are no console users. + h.proxy.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), consoleActorContextKey{}, "console"))) return } h.serveStatic(w, r) diff --git a/services/core-console/server_test.go b/services/core-console/server_test.go index 2ca6aa96a..95af817cb 100644 --- a/services/core-console/server_test.go +++ b/services/core-console/server_test.go @@ -30,14 +30,12 @@ func testConsole(t *testing.T, backend http.Handler) (*httptest.Server, string) if err := os.WriteFile(filepath.Join(dist, "index.html"), []byte("existing web build"), 0o644); err != nil { t.Fatal(err) } - handler, err := newConsole(config{origin: testOrigin, upstream: u, dist: dist, password: "private-console-password", adminToken: "private-admin-token"}) + handler, err := newConsole(config{origin: testOrigin, upstream: u, dist: dist, coreKey: "private-core-key"}) if err != nil { t.Fatal(err) } t.Cleanup(handler.Close) - server := httptest.NewServer(handler) - t.Cleanup(server.Close) - return server, dist + return serveSignedIn(t, handler), dist } func consoleRequest(t *testing.T, server *httptest.Server, method, path string) *http.Request { @@ -47,7 +45,9 @@ func consoleRequest(t *testing.T, server *httptest.Server, method, path string) t.Fatal(err) } r.Host = "127.0.0.1:8080" - r.SetBasicAuth("admin", "private-console-password") + if cookie, ok := testSessions.Load(server.URL); ok { + r.AddCookie(cookie.(*http.Cookie)) + } r.Header.Set("Origin", testOrigin) r.Header.Set("Sec-Fetch-Site", "same-origin") return r @@ -79,10 +79,13 @@ func TestAuthenticationAndCrossSiteAdmission(t *testing.T) { change func(*http.Request) status int }{ - {"missing login", "GET", func(r *http.Request) { r.Header.Del("Authorization") }, 401}, - {"wrong password", "GET", func(r *http.Request) { r.SetBasicAuth("admin", "wrong") }, 401}, - {"wrong user", "GET", func(r *http.Request) { r.SetBasicAuth("other", "private-console-password") }, 401}, - {"duplicate login", "GET", func(r *http.Request) { r.Header.Add("Authorization", r.Header.Get("Authorization")) }, 401}, + {"missing session", "GET", func(r *http.Request) { r.Header.Del("Cookie") }, 401}, + {"unknown session", "GET", func(r *http.Request) { r.Header.Set("Cookie", sessionCookie+"="+strings.Repeat("0", 64)) }, 401}, + {"duplicate session", "GET", func(r *http.Request) { r.Header.Add("Cookie", r.Header.Get("Cookie")) }, 401}, + {"Core key as bearer", "GET", func(r *http.Request) { + r.Header.Del("Cookie") + r.Header.Set("Authorization", "Bearer private-core-key") + }, 401}, {"DNS rebinding host", "GET", func(r *http.Request) { r.Host = "attacker.example:8080" }, 403}, {"cross origin", "POST", func(r *http.Request) { r.Header.Set("Origin", "https://attacker.example") }, 403}, {"null origin", "POST", func(r *http.Request) { r.Header.Set("Origin", "null") }, 403}, @@ -112,11 +115,11 @@ func TestAuthenticationAndCrossSiteAdmission(t *testing.T) { if calls.Load() != 0 { t.Fatal("rejected request reached Core") } - anonymous := consoleRequest(t, server, "GET", "/") - anonymous.Header.Del("Authorization") + anonymous := consoleRequest(t, server, "GET", "/sessions/saved") + anonymous.Header.Del("Cookie") response, body := responseBody(t, server, anonymous) if response.StatusCode != 401 || strings.Contains(body, "existing web build") { - t.Fatal("static assets bypassed console authentication") + t.Fatal("application routes bypassed console authentication") } request := consoleRequest(t, server, "GET", "/healthz") request.Header = make(http.Header) @@ -138,7 +141,8 @@ func TestProxyUsesOnlyConfiguredCoreCredential(t *testing.T) { _, _ = io.WriteString(w, `{"data":[]}`) })) r := consoleRequest(t, server, "POST", "/core/v1/admin/projects?limit=5") - r.Header.Set("Cookie", "browser=private") + r.Header.Add("Cookie", "browser=private") + r.Header.Set("Authorization", "Bearer browser-token") r.Header.Set("Proxy-Authorization", "Basic browser-secret") r.Header.Set("OpenAI-Beta", "agents=v1") r.Header.Set("Forwarded", "host=attacker.example") @@ -153,8 +157,8 @@ func TestProxyUsesOnlyConfiguredCoreCredential(t *testing.T) { } } request := <-observed - if request.URL.RequestURI() != "/core/v1/admin/projects?limit=5" || request.Header.Get("Authorization") != "Bearer private-admin-token" || request.Header.Get("OpenAI-Beta") != "agents=v1" { - t.Fatal("proxy changed the public request or failed to inject the Core token") + if request.URL.RequestURI() != "/core/v1/admin/projects?limit=5" || request.Header.Get("Authorization") != "Bearer private-core-key" || request.Header.Get("OpenAI-Beta") != "agents=v1" { + t.Fatal("proxy changed the public request or failed to inject the Core key") } for _, name := range []string{"Cookie", "Proxy-Authorization", "Origin", "Referer", "Forwarded", "X-Forwarded-Host"} { if request.Header.Get(name) != "" { @@ -171,10 +175,10 @@ func TestProxyRejectsRedirectWithoutFollowingOrExposingIt(t *testing.T) { })) defer destination.Close() server, _ := testConsole(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, destination.URL+"/?token=private-admin-token", http.StatusTemporaryRedirect) + http.Redirect(w, r, destination.URL+"/?token=private-core-key", http.StatusTemporaryRedirect) })) response, body := responseBody(t, server, consoleRequest(t, server, "GET", "/core/v1/admin/projects")) - if response.StatusCode != 502 || response.Header.Get("Location") != "" || strings.Contains(body, "private-admin-token") || destinationCalls.Load() != 0 { + if response.StatusCode != 502 || response.Header.Get("Location") != "" || strings.Contains(body, "private-core-key") || destinationCalls.Load() != 0 { t.Fatal("upstream redirect escaped the fixed proxy") } } diff --git a/site/index.html b/site/index.html index 42c056f96..21d112c34 100644 --- a/site/index.html +++ b/site/index.html @@ -84,7 +84,7 @@

Install. Sign in.
Add a node.

  1. Install Core + Web

    ZERO NODES

    Run from your extracted bundle. Core, Web and PostgreSQL start together. No model key required.

    ./install.sh \
         --public-url https://core.example
  2. -
  3. Sign in to your console

    Open the address printed by the installer. Sign in as admin with the password in this private file:

    ~/.parsar/core/config/console.password

    The paired console is already connected to Core.

  4. +
  5. Sign in to your console

    Open the address printed by the installer. Sign in with the Core key in this private file:

    ~/.parsar/core/admin/core.key

    The paired console is already connected to Core.

  6. Bring a node online

    Open Hosted Sandbox Manager, choose Docker or microsandbox, and initialize the deployment. Select Add node, then copy and run the command on a prepared Linux host.

    Dockermicrosandbox

    Web shows when the node is online and its provider is ready. All nodes use the same provider.

Installation and enrollment do not call a model. Other installation options

From aa900f14ecd582d52f82e22c820b8b28f401aeed Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Fri, 25 Sep 2026 13:44:46 +0000 Subject: [PATCH 2/6] Never let failed sign-ins lock out the Core key The console compared the key only after charging a global budget of ten attempts per minute, so anyone could keep Web sign-in blocked. It now compares first, in constant time, and charges the budget only for failed attempts: the correct key always signs in, while further wrong keys get 429 with Retry-After. The worker bound stays. Because correct keys are never limited, Web and the Web-only installer now refuse a Core key shorter than 32 characters. Core loads only digests and cannot check the length. Installer-generated keys have 64. The audit docs now say the actor label is caller-asserted and display-only, and the rotation steps run in a subshell, recreate services with --no-deps, cover core-only installations and state the minimum length. Two remaining "deployment credential" mentions in the quickstart and operations guides now say Core key. --- CONTRIBUTING.md | 5 ++-- contracts/agents-api/admin-api.md | 9 +++--- deploy/install/install.py | 2 ++ deploy/install/test_install.py | 4 +-- docs/api/web-management.md | 16 ++++++----- docs/getting-started/install.md | 2 +- docs/getting-started/operations.md | 42 +++++++++++++++++----------- docs/getting-started/quickstart.md | 2 +- services/core-console/auth.go | 24 ++++++++++------ services/core-console/auth_test.go | 9 ++++-- services/core-console/config.go | 4 +++ services/core-console/config_test.go | 15 +++++----- 12 files changed, 82 insertions(+), 52 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a47c1787b..1940844b5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1897,8 +1897,9 @@ startup. Cookie sessions are in memory, bounded, HttpOnly, SameSite Strict and Secure for HTTPS origins; a restart or Core key rotation requires sign-in again. Unauthenticated access is limited to the static login UI, finite console authentication routes and the static node installation payload. Sign-in uses -same-origin JSON POSTs with bounded bodies, attempt limiting and bounded -concurrent work. See the +same-origin JSON POSTs with bounded bodies and bounded concurrent work. Only +failed attempts are rate limited, so the correct key always signs in; Web and the +installer therefore require Core keys of at least 32 characters. See the [Core key operations guide](docs/getting-started/operations.md#core-key). Projects and application API keys live in Core PostgreSQL. Project creation owns diff --git a/contracts/agents-api/admin-api.md b/contracts/agents-api/admin-api.md index d9e838534..27cfad7f7 100644 --- a/contracts/agents-api/admin-api.md +++ b/contracts/agents-api/admin-api.md @@ -3,8 +3,9 @@ This Core extension uses Core key Bearer authentication under `/core/v1/admin`. It does not change `/v1`, the fixed Python SDK, or native Runtime interfaces. API keys cannot authenticate these routes; the Core key cannot authenticate `/v1`. -The console server sends the fixed `X-Core-Console-Actor: console`; direct Core -key requests send none. Core records it only as an unverified display label. +`X-Core-Console-Actor` is a caller-asserted, display-only label that Core records +without verifying. Web sends `console`; direct Core key scripts normally send none +but could set any label. Never use it for authorization or as proof of origin. ## Projects and keys @@ -143,8 +144,8 @@ routes, including the per-Session administrator read, keep their shape. `resource_type`, `resource_id`, `action`, inclusive `created_after`, exclusive `created_before`, `limit=1..100` (default 50), and opaque `after` filters. Response is `{data, has_more, next_cursor}`. Each row has `id`, `created_at`, -`admin_credential_id` (credential digest prefix), `actor_label` (`console` for Web -operations, empty for direct Core key requests), `action`, +`admin_credential_id` (credential digest prefix), `actor_label` (the caller-asserted +display label: normally `console` from Web and empty from direct Core key requests), `action`, `project_id`, `resource_type`, `resource_id`, `result_ids`, `request_id`, `trace_id`. `result_ids` is an empty array except on historical `copy` entries. No credential values or request bodies are recorded. Logs and historical copy diff --git a/deploy/install/install.py b/deploy/install/install.py index 2aa287166..697dc5102 100644 --- a/deploy/install/install.py +++ b/deploy/install/install.py @@ -254,6 +254,8 @@ def initialize(root, args, manifest): token = source.read_text().strip() if not token or any(c.isspace() for c in token) or "\x00" in token: raise InstallError("Invalid Core key file") + if len(token) < 32: + raise InstallError("The Core key must have at least 32 characters") if mode != "web-only": free_port(args.core_port) if mode != "core-only": diff --git a/deploy/install/test_install.py b/deploy/install/test_install.py index f68b7234c..8d7569d50 100644 --- a/deploy/install/test_install.py +++ b/deploy/install/test_install.py @@ -94,7 +94,7 @@ def snapshot(self): for path in [self.root, *self.root.rglob("*")] } - def administrator_file(self, contents="synthetic-existing-admin-token", mode=0o600): + def administrator_file(self, contents="synthetic-existing-core-key-0123456789", mode=0o600): path = self.work / "existing-admin.key" path.write_text(contents) path.chmod(mode) @@ -453,7 +453,7 @@ def test_private_files_are_owner_only_even_under_permissive_umask(self): def test_web_only_rejects_exposed_or_malformed_administrator_files(self): for contents, mode in (("synthetic-token", 0o644), ("", 0o600), ("two tokens", 0o600), - ("token\x00", 0o600), ("x" * 4097, 0o600)): + ("token\x00", 0o600), ("x" * 4097, 0o600), ("x" * 31, 0o600)): with self.subTest(contents=contents, mode=mode): source = self.administrator_file(contents, mode) with self.assertRaises(install.InstallError): diff --git a/docs/api/web-management.md b/docs/api/web-management.md index df6d2bc7e..e6a1ac70b 100644 --- a/docs/api/web-management.md +++ b/docs/api/web-management.md @@ -18,9 +18,11 @@ The browser uses the console's own origin and signs in with the Sign-in errors use the console's `{"error": "…"}` envelope: 400 for a malformed body, 401 for a wrong key, 415 for a non-JSON body, 429 with `Retry-After` when -attempts are limited, and 503 when the console cannot start a session. The console -compares the submitted key with its configured Core key in constant time and never -logs or returns it. Sessions live only in the console's memory; a console restart +failed attempts are limited or sign-in is busy, and 503 when the console cannot +start a session. The console compares the submitted key with its configured Core +key in constant time and never logs or returns it. Only failed attempts count +toward the limit; the correct key signs in even while failures are limited. The +console refuses to start with a Core key shorter than 32 characters. Sessions live only in the console's memory; a console restart or Core key rotation requires signing in again. There are no console accounts, usernames, passwords, first-run setup or Basic authentication. @@ -31,10 +33,10 @@ request checks; never put the Core key in JavaScript or browser storage. The console server injects the Core key as its Bearer credential on allowlisted management requests. It removes browser Authorization and forwarding-sensitive -headers, and sets `X-Core-Console-Actor: console`. Core records that fixed value as -the audit `actor_label`, a display label rather than an authorization input. -Direct Core key requests from operator scripts carry no header and record an -empty label. +headers, and sets `X-Core-Console-Actor: console`. Core records the header as the +audit `actor_label`. The label is caller-asserted and display-only: direct Core key +scripts normally send none, which records an empty label, but could set any +value. Never use it for authorization or as proof of origin. Use [AdminClient](../../packages/agents-client/src/admin-client.ts) for the typed management client and [the complete administrator reference](../../contracts/agents-api/admin-api.md) diff --git a/docs/getting-started/install.md b/docs/getting-started/install.md index aa9a66d1b..4e91bcf78 100644 --- a/docs/getting-started/install.md +++ b/docs/getting-started/install.md @@ -290,7 +290,7 @@ a remote Core must use HTTPS. Web-only mode cannot enable a sandbox provider. It starts no database or Core and requires no KVM. Its key remains on the server, outside the static Web files. -The input file must be private (0600). +The input file must be private (0600) and contain a Core key of at least 32 characters. Use `--install-dir /absolute/path`, `--core-port 8092` and `--web-port 8081` for separate installations. Their database volumes, provider identities and Runtime diff --git a/docs/getting-started/operations.md b/docs/getting-started/operations.md index fe5c01dd6..b9717670a 100644 --- a/docs/getting-started/operations.md +++ b/docs/getting-started/operations.md @@ -74,11 +74,15 @@ docker compose -f "$HOME/.parsar/core/compose.json" ps --all ## Core key Each installation has one management credential, the Core key. The installer -generates it in `/admin/core.key` (default install dir -`~/.parsar/core`) and writes its SHA-256 digest to `admin/core-key-digests.json`. -Core reads the digest file named by `AGENTS_API_CORE_KEY_DIGESTS_FILE` in -`config/core.env`; Web reads the key file named by `CORE_CONSOLE_CORE_KEY_FILE`. -Both read them only at startup. +generates a 64-character random key in `/admin/core.key` (default +install dir `~/.parsar/core`) and writes its SHA-256 digest to +`admin/core-key-digests.json`. Core reads the digest file named by +`AGENTS_API_CORE_KEY_DIGESTS_FILE` in `config/core.env`; Web reads the key file +named by `CORE_CONSOLE_CORE_KEY_FILE`. Both read them only at startup. + +A Core key must have at least 32 characters and no whitespace. Web and the +Web-only installer refuse a shorter key; Core sees only digests, so it cannot +check the length. Web limits failed sign-ins, but the correct key always signs in. The Core key: @@ -101,27 +105,31 @@ curl -fsS -H @<(printf 'Authorization: Bearer %s\n' "$(cat "$HOME/.parsar/core/a ### Rotate the Core key -1. Generate a new key and replace both files: +1. Generate a new 64-character key and replace both files. The subshell keeps + `umask 077` and the key variable out of your shell: ```sh - cd "$HOME/.parsar/core/admin" - umask 077 - key=$(python3 -c 'import secrets; print(secrets.token_hex(32))') - printf '%s\n' "$key" > core.key.new - printf '["%s"]\n' "$(printf '%s' "$key" | sha256sum | cut -d' ' -f1)" > core-key-digests.json.new - mv core.key.new core.key && mv core-key-digests.json.new core-key-digests.json - unset key + ( + cd "$HOME/.parsar/core/admin" + umask 077 + key=$(python3 -c 'import secrets; print(secrets.token_hex(32))') + printf '%s\n' "$key" > core.key.new + printf '["%s"]\n' "$(printf '%s' "$key" | sha256sum | cut -d' ' -f1)" > core-key-digests.json.new + mv core.key.new core.key && mv core-key-digests.json.new core-key-digests.json + ) ``` 2. Restart Core and Web so they read the new files: ```sh - docker compose -f "$HOME/.parsar/core/compose.json" up -d --force-recreate core web + docker compose -f "$HOME/.parsar/core/compose.json" up -d --no-deps --force-recreate core web ``` With native Core, run `systemctl --user restart parsar--core.service`, then - the same Compose command with only `web`. A separate Web installation keeps its - own copy in its `admin/core.key`; replace that file and recreate its `web`. + the same Compose command with only `web`. A core-only installation has no `web` + service: recreate only `core` (or restart the native service). A separate Web + installation keeps its own copy in its `admin/core.key`; replace that file and + recreate its `web`. 3. Sign in to Web again with the new key and update your scripts. The restart ends every Web session, and Core rejects the old key. @@ -161,7 +169,7 @@ To rotate, issue another key within the same Project, update the application, th revoke the old key. Renaming a Project or revoking a key preserves its assets and execution principal. Archiving a Project disables all its keys while retaining assets and already accepted execution. Administrators may inspect or delete retained -resources; they cannot execute them using the deployment credential. +resources; they cannot execute them using the Core key. ## Data and upgrades diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index e2f6a5b66..9fd1ca419 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -15,7 +15,7 @@ pip install openai==3.13.0 The deployment administrator first creates a Project through `POST /core/v1/admin/projects` with `{"name":"Default"}`, then issues a key through `POST /core/v1/admin/projects/{project_id}/keys` with a descriptive `{"name":"..."}`. -These requests use the separate deployment credential. The management UI has not +These requests use the [Core key](operations.md#core-key) as their Bearer credential. The management UI has not yet migrated; see [integration status](../web/README.md). Obtain that key through a private channel and supply it as `PARSAR_API_KEY` in your diff --git a/services/core-console/auth.go b/services/core-console/auth.go index 364163aa0..d1350f97e 100644 --- a/services/core-console/auth.go +++ b/services/core-console/auth.go @@ -17,6 +17,10 @@ import ( const sessionCookie = "core_console_session" const sessionLifetime = 12 * time.Hour +// minimumCoreKeyLength keeps guessing infeasible even though a correct key is +// never rate limited. Installer-generated keys have 64 characters. +const minimumCoreKeyLength = 32 + // consoleAuth signs the browser in with the Core key. Sessions live only in // memory, so a console restart or Core key rotation requires signing in again. type consoleAuth struct { @@ -110,7 +114,9 @@ func (a *consoleAuth) setSession(w http.ResponseWriter, r *http.Request) { authJSON(w, http.StatusOK, map[string]string{"mode": "authenticated"}) } -func (a *consoleAuth) admitAttempt() bool { +// admitFailure charges one failed sign-in to the shared budget. Correct keys are +// never charged, so failed attempts cannot lock out the key holder. +func (a *consoleAuth) admitFailure() bool { a.mu.Lock() defer a.mu.Unlock() now := time.Now() @@ -167,11 +173,6 @@ func (a *consoleAuth) serve(w http.ResponseWriter, r *http.Request) { authError(w, http.StatusBadRequest, "Send a JSON object with only a non-empty core_key") return } - if !a.admitAttempt() { - w.Header().Set("Retry-After", "60") - authError(w, http.StatusTooManyRequests, "Too many sign-in attempts; try again in one minute") - return - } select { case a.workers <- struct{}{}: defer func() { <-a.workers }() @@ -182,11 +183,16 @@ func (a *consoleAuth) serve(w http.ResponseWriter, r *http.Request) { } // Comparing fixed-size digests keeps the check constant-time for any key length. submitted := sha256.Sum256([]byte(coreKey)) - if subtle.ConstantTimeCompare(submitted[:], a.coreKey[:]) != 1 { - authError(w, http.StatusUnauthorized, "Invalid Core key") + if subtle.ConstantTimeCompare(submitted[:], a.coreKey[:]) == 1 { + a.setSession(w, r) + return + } + if !a.admitFailure() { + w.Header().Set("Retry-After", "60") + authError(w, http.StatusTooManyRequests, "Too many failed sign-in attempts; try again in one minute") return } - a.setSession(w, r) + authError(w, http.StatusUnauthorized, "Invalid Core key") } func publicConsoleAsset(r *http.Request) bool { diff --git a/services/core-console/auth_test.go b/services/core-console/auth_test.go index 6a13be85c..f0110ad9f 100644 --- a/services/core-console/auth_test.go +++ b/services/core-console/auth_test.go @@ -208,15 +208,20 @@ func TestCoreKeySignInRejectsMalformedAndCrossOriginRequests(t *testing.T) { if w.Code != 403 { t.Fatal("sign-in without browser origin evidence was accepted") } +} + +func TestFailedSignInsNeverLockOutTheCoreKey(t *testing.T) { + h := startConsole(t, coreKeyConsoleConfig(t, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))) for attempt := 0; attempt < 10; attempt++ { if w := authRequest(h, "POST", "/console/auth/login", coreKeyInput("incorrect"), nil); w.Code != 401 { t.Fatalf("attempt %d unexpectedly returned %d", attempt, w.Code) } } - limited := authRequest(h, "POST", "/console/auth/login", coreKeyInput(testCoreKey), nil) + limited := authRequest(h, "POST", "/console/auth/login", coreKeyInput("incorrect"), nil) if limited.Code != 429 || limited.Header().Get("Retry-After") != "60" || len(limited.Result().Cookies()) != 0 { - t.Fatal("sign-in rate limit was not enforced") + t.Fatal("failed sign-ins were not limited") } + signIn(t, h) } func TestSecureCookieExpiry(t *testing.T) { diff --git a/services/core-console/config.go b/services/core-console/config.go index 2ee819f3b..d488204f3 100644 --- a/services/core-console/config.go +++ b/services/core-console/config.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strings" "unicode" + "unicode/utf8" ) type config struct { @@ -46,6 +47,9 @@ func loadConfig() (config, error) { if err != nil { return config{}, errors.New("CORE_CONSOLE_CORE_KEY_FILE must name a private regular file containing the Core key") } + if utf8.RuneCountInString(c.coreKey) < minimumCoreKeyLength { + return config{}, errors.New("the Core key in CORE_CONSOLE_CORE_KEY_FILE must have at least 32 characters") + } c.nodePayloadDir = os.Getenv("CORE_CONSOLE_NODE_PAYLOAD_DIR") if c.nodePayloadDir != "" && !filepath.IsAbs(c.nodePayloadDir) { return config{}, errors.New("CORE_CONSOLE_NODE_PAYLOAD_DIR must be absolute") diff --git a/services/core-console/config_test.go b/services/core-console/config_test.go index 6372c9ab3..3e05bfadb 100644 --- a/services/core-console/config_test.go +++ b/services/core-console/config_test.go @@ -10,7 +10,8 @@ import ( func TestConfigRejectsUnsafeURLsAndSecretFiles(t *testing.T) { directory := t.TempDir() key := filepath.Join(directory, "core.key") - if err := os.WriteFile(key, []byte("private-core-key\n"), 0o600); err != nil { + valid := strings.Repeat("k", 32) + if err := os.WriteFile(key, []byte(valid+"\n"), 0o600); err != nil { t.Fatal(err) } t.Setenv("CORE_CONSOLE_CORE_KEY_FILE", key) @@ -18,7 +19,7 @@ func TestConfigRejectsUnsafeURLsAndSecretFiles(t *testing.T) { t.Setenv("CORE_CONSOLE_UPSTREAM", "http://core:8091") t.Setenv("CORE_CONSOLE_DIST", directory) c, err := loadConfig() - if err != nil || c.coreKey != "private-core-key" { + if err != nil || c.coreKey != valid { t.Fatalf("valid configuration failed: %v", err) } for _, value := range []string{"http://user:secret@core:8091", "http://core:8091/v1", "http://core:8091?token=secret", "http://core:8091#", "file:///config/caller.key", ""} { @@ -30,15 +31,15 @@ func TestConfigRejectsUnsafeURLsAndSecretFiles(t *testing.T) { } }) } - for _, value := range []string{"", "token with spaces", strings.Repeat("x", 4097), "token\x00"} { + for _, value := range []string{"", "token with spaces", strings.Repeat("x", 4097), "token\x00", strings.Repeat("s", 31)} { if err := os.WriteFile(key, []byte(value), 0o600); err != nil { t.Fatal(err) } - if _, err := loadConfig(); err == nil { - t.Fatal("invalid Core key accepted") + if _, err := loadConfig(); err == nil || strings.Contains(err.Error(), "sss") { + t.Fatal("invalid Core key accepted or echoed") } } - if err := os.WriteFile(key, []byte("valid-token"), 0o600); err != nil { + if err := os.WriteFile(key, []byte(valid), 0o600); err != nil { t.Fatal(err) } if err := os.Chmod(key, 0o644); err != nil { @@ -52,7 +53,7 @@ func TestConfigRejectsUnsafeURLsAndSecretFiles(t *testing.T) { func TestConfigRejectsRenamedAndRetiredSettings(t *testing.T) { directory := t.TempDir() key := filepath.Join(directory, "core.key") - if err := os.WriteFile(key, []byte("private-core-key"), 0o600); err != nil { + if err := os.WriteFile(key, []byte(strings.Repeat("k", 32)), 0o600); err != nil { t.Fatal(err) } t.Setenv("CORE_CONSOLE_CORE_KEY_FILE", key) From 884c33370766eb98fdf85a4b38069748f1f49486 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 13:18:57 +0000 Subject: [PATCH 3/6] Sign in to the console with the Core key --- apps/web/DESIGN.md | 10 +- apps/web/PRODUCT.md | 28 +++-- apps/web/README.md | 3 +- apps/web/e2e/access.spec.ts | 56 +++++---- apps/web/e2e/console.ts | 7 +- apps/web/e2e/fixture-console.mjs | 54 +++++--- apps/web/src/ConsoleApp.tsx | 17 +-- .../src/features/first-run/ConsoleAccess.tsx | 119 +++++++++--------- apps/web/src/features/first-run/auth.test.ts | 34 ++--- apps/web/src/features/first-run/auth.ts | 58 +++++---- .../src/features/first-run/console-access.css | 2 - .../features/onboarding/OnboardingLayout.tsx | 8 +- .../features/onboarding/OnboardingStage.tsx | 2 +- .../src/features/onboarding/onboarding.css | 10 +- apps/web/src/i18n/locales/en/keys.ts | 6 +- apps/web/src/i18n/locales/en/onboarding.ts | 9 +- apps/web/src/i18n/locales/zh-CN/keys.ts | 6 +- apps/web/src/i18n/locales/zh-CN/onboarding.ts | 11 +- apps/web/src/lib/console-auth-strings.ts | 26 ++-- docs/web/architecture.md | 2 +- docs/web/core-connection.md | 24 ++-- docs/web/protocol-coverage.md | 8 +- docs/web/roadmap.md | 10 +- 23 files changed, 267 insertions(+), 243 deletions(-) diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index 5ebb8d745..52246723f 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -580,9 +580,13 @@ the Parsar mark on a tile with a travelling border beam, and two orbits of Agents, Sessions, Skills, Vaults, files, templates and machines around it; the Parsar mark is itself nodes on a ring. Brand copy sits bottom-left in solid ink; it is a paragraph, not a heading, because the panel's title names the task. -First run is three steps with numbered progress in the panel header: the -administrator account, the first project and its key (shown once; the example -request types itself into a terminal), and a three-chapter tour — Monitor, +Signing in asks for one thing, the deployment's Core key, in a single password +field; where the key is kept and what it can do sit behind a help tip beside the +label, and a refused key, too many attempts or an unavailable console is an error +beside the field. When no project exists yet, first run follows on the same stage +in two steps with numbered progress in the panel header: the first project and its +project API key (shown once; the example request types itself into a terminal), +and a three-chapter tour — Monitor, Resources, Platform — whose stage shows a real dark screenshot of those pages, tilted towards the panel. Steps change inside a View Transition: the stage holds still while the panel slides and blurs across; opening the console dissolves diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md index 1ac14c97d..76d626d28 100644 --- a/apps/web/PRODUCT.md +++ b/apps/web/PRODUCT.md @@ -40,13 +40,17 @@ workbench. ## Operating Context -- Paired console (`services/core-console`): a single local administrator account - (legacy installations keep Basic authentication). The browser holds only the - console sign-in; the console server holds the deployment administrator - credential and forwards the Web API (`/core/v1/admin/**`) and sandbox - administration (`/core/v1/sandbox/**`). The console never calls `/v1`. -- The console account is not an Agents API identity. An administrator who wants to - call the Agents API issues a key in a project like any other caller. +- Paired console (`services/core-console`): the administrator signs in with the + deployment's Core key, the administration credential the installer writes to + `admin/core.key` under the installation directory (by default + `~/.parsar/core/admin/core.key`; keeping and rotating it is described in + [Core key](../../docs/getting-started/operations.md#core-key)). There are no + console accounts or usernames. The browser sends the key only to sign in and + keeps only the session cookie; the console server holds the Core key and forwards + the Web API (`/core/v1/admin/**`) and sandbox administration + (`/core/v1/sandbox/**`). The console never calls `/v1`. +- The Core key is not an Agents API identity and cannot call `/v1`. An administrator + who wants to call the Agents API issues a project API key like any other caller. - `/console/config` reports whether sandbox administration is available; without it the Nodes page explains that it is not configured and the fleet figures show as unavailable. @@ -84,13 +88,15 @@ workbench. - **microsandbox** suspends idle sandboxes into snapshots, so its nodes show how many sleep (Core's retained minus active) on the Nodes list, a node's page, Sandbox metrics and Overview; Docker never suspends and shows none of it. -- **First run**: after the administrator account is created and while no project - exists, full-screen steps outside the shell create the first project (default name - `Default`) and its first key, show the plaintext once with an example request, +- **First run**: after signing in with the Core key while no project exists, + full-screen steps outside the shell create the first project (default name + `Default`) and its first project API key, show the plaintext once with an example request, then give a three-chapter tour of the console (Monitor, Resources, Platform) before opening it. Signing in uses the same stage. - Terminology: API terms stay in English in the Chinese UI (Agent, Session, Turn, - Skill, Vault, Credential, API key). + Skill, Vault, Credential, API key). The sign-in credential is the Core key + ("Core Key"); keys issued in a project for applications are project API keys + ("项目 API Key"). ## Capabilities and Constraints diff --git a/apps/web/README.md b/apps/web/README.md index d50e3b07a..bc2db8a6c 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -9,7 +9,8 @@ described in [DESIGN.md](DESIGN.md) and its product scope in [PRODUCT.md](PRODUC Browser management requests use same-origin `/core/v1/admin` through `AdminClient`, plus the existing sandbox management client for allowed `/core/v1/sandbox` routes. -The console authenticates the browser and keeps the deployment credential server-side. +The administrator signs in with the deployment's Core key; the console keeps the key +server-side and gives the browser only a session cookie. Applications use their own Project keys directly against Core's public `/v1` API. The production console returns 404 for `/v1`, even with an explicit Bearer token. diff --git a/apps/web/e2e/access.spec.ts b/apps/web/e2e/access.spec.ts index 1be37409b..cec15d4ff 100644 --- a/apps/web/e2e/access.spec.ts +++ b/apps/web/e2e/access.spec.ts @@ -1,43 +1,45 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, type Page } from "@playwright/test"; -import { expectManagementBoundary, resetFixture } from "./console"; +import { expectManagementBoundary, FIXTURE_CORE_KEY, resetFixture } from "./console"; test.afterEach(async ({ request }) => expectManagementBoundary(request)); -test("creates the administrator, keeps no credential in the browser, and signs out and back in", async ({ page, request }) => { - await resetFixture(request, "setup"); +async function signIn(page: Page, coreKey: string) { + await page.getByLabel("Core key", { exact: true }).fill(coreKey); + await page.getByRole("button", { name: "Sign in" }).click(); +} + +const browserStorage = (page: Page) => page.evaluate(() => JSON.stringify({ ...window.localStorage, ...window.sessionStorage })); + +test("signs in with the Core key, keeps it out of the browser, and signs out and back in", async ({ page, request }) => { + await resetFixture(request, "login"); await page.addInitScript(() => window.localStorage.setItem("agents-core-web.language", "en")); await page.goto("/"); - await expect(page.getByRole("heading", { name: "Create your administrator account" })).toBeVisible(); - await page.locator("input[name=username]").fill("admin"); - await page.locator("input[name=password]").fill("correct horse battery"); - await page.locator("input[name=confirm]").fill("correct horse battery"); - await page.getByRole("button", { name: "Create administrator account" }).click(); + await expect(page.getByRole("heading", { name: "Sign in to Parsar Core" })).toBeVisible(); + await signIn(page, "not-the-core-key"); + await expect(page.getByRole("alert")).toHaveText("This Core key is not correct. Check it and try again."); + await signIn(page, FIXTURE_CORE_KEY); await expect(page.getByRole("heading", { name: "Overview", level: 1 })).toBeVisible(); - - const stored = await page.evaluate(() => JSON.stringify({ ...window.localStorage, ...window.sessionStorage })); - expect(stored).not.toContain("correct horse battery"); + expect(await browserStorage(page)).not.toContain(FIXTURE_CORE_KEY); await page.getByRole("button", { name: "Sign out" }).click(); - await expect(page.getByRole("heading", { name: "Welcome back" })).toBeVisible(); - await page.locator("input[name=username]").fill("admin"); - await page.locator("input[name=password]").fill("wrong password!!"); - await page.getByRole("button", { name: "Sign in" }).click(); - await expect(page.getByRole("alert")).toBeVisible(); - await page.locator("input[name=password]").fill("correct horse battery"); - await page.getByRole("button", { name: "Sign in" }).click(); + await expect(page.getByRole("heading", { name: "Sign in to Parsar Core" })).toBeVisible(); + await signIn(page, FIXTURE_CORE_KEY); await expect(page.getByRole("heading", { name: "Overview", level: 1 })).toBeVisible(); + + // Repeated wrong keys: the console says how long to wait, from Retry-After. + await page.getByRole("button", { name: "Sign out" }).click(); + for (let attempt = 0; attempt < 3; attempt++) await signIn(page, "not-the-core-key"); + await expect(page.getByRole("alert")).toHaveText("Too many attempts. Try again in 30 seconds."); + expect(await browserStorage(page)).not.toContain(FIXTURE_CORE_KEY); }); -test("sets up a fresh install: first project, a key shown once, the tour, then the console", async ({ page, request }) => { - await resetFixture(request, "setup", { fresh: true }); +test("sets up a fresh install: Core key sign-in, first project, a key shown once, the tour, then the console", async ({ page, request }) => { + await resetFixture(request, "login", { fresh: true }); await page.addInitScript(() => window.localStorage.setItem("agents-core-web.language", "en")); await page.goto("/"); - await page.locator("input[name=username]").fill("admin"); - await page.locator("input[name=password]").fill("correct horse battery"); - await page.locator("input[name=confirm]").fill("correct horse battery"); - await page.getByRole("button", { name: "Create administrator account" }).click(); + await signIn(page, FIXTURE_CORE_KEY); await expect(page.getByRole("heading", { name: "Create your first project" })).toBeVisible(); await page.locator("input[name=key-name]").fill("my-app"); @@ -49,5 +51,7 @@ test("sets up a fresh install: first project, a key shown once, the tour, then t await expect(page.getByRole("heading", { name: "Is it healthy, and where does it fail?" })).toBeVisible(); await page.getByRole("button", { name: "Skip" }).click(); await expect(page.getByRole("heading", { name: "Overview", level: 1 })).toBeVisible(); - expect(await page.evaluate(() => JSON.stringify({ ...window.localStorage, ...window.sessionStorage }))).not.toContain("fixture-secret"); + const stored = await browserStorage(page); + expect(stored).not.toContain("fixture-secret"); + expect(stored).not.toContain(FIXTURE_CORE_KEY); }); diff --git a/apps/web/e2e/console.ts b/apps/web/e2e/console.ts index 10eda2875..b12ed654c 100644 --- a/apps/web/e2e/console.ts +++ b/apps/web/e2e/console.ts @@ -3,8 +3,11 @@ import { expect, type APIRequestContext, type Page } from "@playwright/test"; const fixture = `http://127.0.0.1:${process.env.AGENTS_FIXTURE_PORT ?? 18092}`; const web = `http://127.0.0.1:${process.env.AGENTS_WEB_PORT ?? 4174}`; -/** Fresh fixture state: "setup" (no administrator yet), "login" or "authenticated". */ -export async function resetFixture(request: APIRequestContext, auth: "setup" | "login" | "authenticated" = "authenticated", options: { fresh?: boolean; sandbox?: "configured" | "none" | "e2b" } = {}) { +/** The fixture deployment's Core key; the same value as CORE_KEY in fixture-console.mjs. */ +export const FIXTURE_CORE_KEY = "fixture-core-key-3f9a2c71"; + +/** Fresh fixture state: signed out ("login") or already signed in ("authenticated"). */ +export async function resetFixture(request: APIRequestContext, auth: "login" | "authenticated" = "authenticated", options: { fresh?: boolean; sandbox?: "configured" | "none" | "e2b" } = {}) { await request.post(`${fixture}/__fixture/reset?auth=${auth}${options.fresh ? "&projects=none" : ""}&sandbox=${options.sandbox ?? "configured"}`); } diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index 55c93362a..c6b05f77c 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -12,6 +12,11 @@ import { buildDemo } from "./data/routes.mjs"; const port = Number(process.env.AGENTS_FIXTURE_PORT ?? 18092); const SESSION_COOKIE = "core_console=fixture-session"; +/** The deployment's Core key in this fixture; the same value as FIXTURE_CORE_KEY in console.ts. */ +const CORE_KEY = "fixture-core-key-3f9a2c71"; +/** Consecutive wrong keys before sign-in is refused for a while, and for how long (seconds). */ +const LOCKOUT_AFTER = 3; +const LOCKOUT_SECONDS = 30; let state; const hex = (c) => c.repeat(64); @@ -30,7 +35,7 @@ function e2bDeployment() { return { ...configuredDeployment(), provider: "e2b", mode: "direct", resources: { allocations: 3, pending: 1 }, specification: { resources: { cpus: 2, memory_mib: 2048 } }, e2b: { template: "parsar-runtime:0f1e2d3c-4b5a-6978-8a9b-0c1d2e3f4a5b", credential_configured: true, template_build: templateBuild } }; } -function reset(mode = "setup", fresh = false, sandbox = "configured") { +function reset(mode = "login", fresh = false, sandbox = "configured") { const base = buildDemo(); const now = Math.floor(Date.now() / 1000); const resources = buildResources(now, base.agents, base.sessions); @@ -39,7 +44,8 @@ function reset(mode = "setup", fresh = false, sandbox = "configured") { if (fresh) admin.projects.splice(0); state = { ...base, resources, admin, - auth: { mode, username: mode === "authenticated" ? "admin" : null, password: mode === "setup" ? null : "correct horse battery" }, + // "authenticated": the console holds a session for the fixture cookie; "login": it holds none. + auth: { mode: mode === "authenticated" ? "authenticated" : "login", failures: 0, lockedUntil: 0 }, violations: [], writes: [], failNext: null, nextId: 1, // "none": the deployment is not configured yet, so the Nodes page offers setup. deployment: sandbox === "none" ? null : sandbox === "e2b" ? e2bDeployment() : configuredDeployment(), @@ -73,25 +79,37 @@ function list(all, url, max = 100) { const id = (prefix) => `${prefix}${String(state.nextId++).padStart(8, "0")}`; const uuid = () => `00000000-0000-4000-8000-${String(state.nextId++).padStart(12, "0")}`; +/** The console service's authentication errors: `{ "error": message }`. */ +function authError(response, status, message, headers = {}) { + send(response, status, { error: message }, headers); +} + +/** Sign-in with the Core key: a wrong key is refused, and repeated wrong keys lock sign-in for a while. */ +async function login(request, response) { + const auth = state.auth; + if (!request.headers["content-type"]?.startsWith("application/json")) return authError(response, 415, "Use application/json"); + let input; + try { input = await body(request); } catch { return authError(response, 400, "Invalid authentication request"); } + if (typeof input?.core_key !== "string" || !input.core_key) return authError(response, 400, "Invalid authentication request"); + const wait = Math.ceil((auth.lockedUntil - Date.now()) / 1000); + if (wait > 0) return authError(response, 429, "Too many authentication attempts", { "retry-after": String(wait) }); + if (input.core_key !== CORE_KEY) { + auth.failures += 1; + if (auth.failures < LOCKOUT_AFTER) return authError(response, 401, "Invalid Core key"); + auth.lockedUntil = Date.now() + LOCKOUT_SECONDS * 1000; + return authError(response, 429, "Too many authentication attempts", { "retry-after": String(LOCKOUT_SECONDS) }); + } + Object.assign(auth, { mode: "authenticated", failures: 0 }); + return send(response, 200, { mode: "authenticated" }, { "set-cookie": `${SESSION_COOKIE}; Path=/; HttpOnly; SameSite=Strict` }); +} + async function consoleRoute(request, response, url) { const auth = state.auth; if (url.pathname === "/console/auth" && request.method === "GET") { - if (auth.mode === "authenticated" && request.headers.cookie?.includes(SESSION_COOKIE)) return send(response, 200, { mode: "authenticated", username: auth.username }); - return send(response, 200, { mode: auth.mode === "setup" ? "setup" : "login" }); - } - if (url.pathname === "/console/auth/setup" && request.method === "POST") { - if (auth.mode !== "setup") return error(response, 409, "Setup is complete."); - const { username, password } = await body(request); - if (!username || !password || password.length < 12) return error(response, 400, "Invalid administrator."); - Object.assign(auth, { mode: "authenticated", username, password }); - return send(response, 200, { mode: "authenticated", username }, { "set-cookie": `${SESSION_COOKIE}; Path=/; HttpOnly; SameSite=Strict` }); - } - if (url.pathname === "/console/auth/login" && request.method === "POST") { - const { username, password } = await body(request); - if (username !== auth.username || password !== auth.password) return error(response, 401, "Sign-in failed."); - auth.mode = "authenticated"; - return send(response, 200, { mode: "authenticated", username }, { "set-cookie": `${SESSION_COOKIE}; Path=/; HttpOnly; SameSite=Strict` }); + const signedIn = auth.mode === "authenticated" && request.headers.cookie?.includes(SESSION_COOKIE); + return send(response, 200, { mode: signedIn ? "authenticated" : "login" }); } + if (url.pathname === "/console/auth/login" && request.method === "POST") return login(request, response); if (url.pathname === "/console/auth/logout" && request.method === "POST") { auth.mode = "login"; return send(response, 200, { mode: "login" }, { "set-cookie": `${SESSION_COOKIE.split("=")[0]}=; Path=/; Max-Age=0` }); @@ -257,7 +275,7 @@ async function sandboxRoute(request, response, path) { async function fixtureRoute(request, response, url) { if (url.pathname === "/__fixture/health") return send(response, 200, { ok: true }); if (url.pathname === "/__fixture/reset" && request.method === "POST") { - reset(url.searchParams.get("auth") ?? "setup", url.searchParams.get("projects") === "none", url.searchParams.get("sandbox") ?? "configured"); + reset(url.searchParams.get("auth") ?? "login", url.searchParams.get("projects") === "none", url.searchParams.get("sandbox") ?? "configured"); return send(response, 200, { ok: true }); } if (url.pathname === "/__fixture/fail-next" && request.method === "POST") { diff --git a/apps/web/src/ConsoleApp.tsx b/apps/web/src/ConsoleApp.tsx index 758cc591e..ec05a8503 100644 --- a/apps/web/src/ConsoleApp.tsx +++ b/apps/web/src/ConsoleApp.tsx @@ -1,13 +1,10 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { AppearanceMenu } from "./components/AppearanceMenu"; import { ConsoleSidebar } from "./components/ConsoleSidebar"; import { FirstProjectSetup } from "./features/api-keys/FirstProjectSetup"; import { ProjectsPage } from "./features/api-keys/ProjectsPage"; import { AgentsPage } from "./features/agents/AgentsPage"; -import { ConsoleAccountMenu, useConsoleAccount } from "./features/first-run/ConsoleAccess"; -import { OnboardingLayout } from "./features/onboarding/OnboardingLayout"; import { TemplatesPage } from "./features/environment-templates/TemplatesPage"; import { FilesPage } from "./features/files/FilesPage"; import { AgentMetricsPage } from "./features/metrics/AgentMetricsPage"; @@ -63,7 +60,6 @@ function ConsolePage({ view }: { view: ConsoleView }) { function ConsoleShell() { const { t } = useTranslation("navigation"); const { state } = useProjects(); - const account = useConsoleAccount(); const [location, setLocation] = useState(readLocation); const [setupDone, setSetupDone] = useState(false); // Once first-run setup has started it stays until it finishes: a background @@ -106,17 +102,8 @@ function ConsoleShell() { for (const project of state.projects) void queryClient.prefetchQuery(collectionQuery(spec, project.id)); }, [state.projects]); - // First run: an administrator with no project yet creates the first one and its key. - // Just after the administrator account was created, the projects are still - // loading: keep the onboarding stage rather than flashing the console. - if (account?.fresh && !setupDone && !setupStarted && state.status === "loading" && state.projects.length === 0) { - return ( - }> -

{t("preparing", { ns: "onboarding" })}

-
- ); - } - + // First run: with no project yet, the administrator creates the first one and + // its API key. Signing in reads the projects first, so this opens at once. if ((needsSetup || setupStarted) && !setupDone) { return setSetupDone(true)} />; } diff --git a/apps/web/src/features/first-run/ConsoleAccess.tsx b/apps/web/src/features/first-run/ConsoleAccess.tsx index 8d768cf24..d8abc817e 100644 --- a/apps/web/src/features/first-run/ConsoleAccess.tsx +++ b/apps/web/src/features/first-run/ConsoleAccess.tsx @@ -5,17 +5,20 @@ import { HelpTip } from "../../components/console-ui"; import { ThemeMenu } from "../../components/ThemeMenu"; import { useToast } from "../../components/Toast"; import { setLanguage } from "../../i18n"; +import { projectsQuery, queryClient } from "../../lib/queries"; import { OnboardingLayout } from "../onboarding/OnboardingLayout"; import { withTransition } from "../onboarding/view-transition"; import { changeConsoleAuth, ConsoleAuthError, readConsoleAuth, type ConsoleAuth } from "./auth"; import "./console-access.css"; -const ConsoleAccountContext = createContext<{ - username: string; - logout: () => Promise; - /** The administrator account was created in this page: first-run setup follows. */ - fresh: boolean; -} | null>(null); +/** + * Where the installer writes the Core key: its file inside the installation + * directory, and that file under the default installation directory. The + * sign-in help names both. + */ +const CORE_KEY_LOCATION = { file: "admin/core.key", defaultPath: "~/.parsar/core/admin/core.key" } as const; + +const ConsoleAccountContext = createContext<{ logout: () => Promise } | null>(null); export const useConsoleAccount = () => useContext(ConsoleAccountContext); export function ConsoleLanguage() { @@ -33,30 +36,35 @@ export function ConsoleAccountMenu() { const toast = useToast(); if (!account) return null; return
-
; } +/** True when the deployment has no project yet, so first-run setup follows signing in. */ +async function needsFirstProject(): Promise { + try { + return (await queryClient.fetchQuery(projectsQuery)).length === 0; + } catch { + return false; // The console reports the failed read itself. + } +} + export function ConsoleAccess({ children }: { children: ReactNode }) { const { t } = useTranslation("firstRun"); const [status, setStatus] = useState(null); const [failed, setFailed] = useState(false); const [revision, setRevision] = useState(0); - const accountExpected = useRef(false); - const [fresh, setFresh] = useState(false); const generation = useRef(0); const refresh = useCallback(() => setRevision((current) => current + 1), []); useEffect(() => { const controller = new AbortController(); const current = ++generation.current; setFailed(false); - void readConsoleAuth(controller.signal, accountExpected.current).then((value) => { - if (generation.current !== current) return; - if (value.mode !== "legacy") accountExpected.current = true; - setStatus(value); + void readConsoleAuth(controller.signal).then((value) => { + if (generation.current === current) setStatus(value); }).catch(() => { if (!controller.signal.aborted && generation.current === current) setFailed(true); }); return () => { controller.abort(); generation.current++; }; }, [revision]); @@ -67,83 +75,78 @@ export function ConsoleAccess({ children }: { children: ReactNode }) { window.addEventListener("focus", check); return () => { window.clearInterval(timer); window.removeEventListener("focus", check); }; }, [status?.mode, refresh]); - if (status?.mode === "legacy") return children; - if (status?.mode === "authenticated") return { - const next = await changeConsoleAuth("logout", {}); + if (status?.mode === "authenticated") return { + const next = await changeConsoleAuth({ action: "logout" }); generation.current++; - setFresh(false); setStatus(next); } }}>{children}; - const setup = status?.mode === "setup"; - return }> - {status && !failed ? { - // Setup continues to the first project on the same stage; signing in reveals the console. - withTransition(setup ? "step" : "enter", () => { + return }> + {status && !failed ? { + // Without a project, first-run setup continues on the same stage; otherwise the console opens. + withTransition(firstRun ? "step" : "enter", () => { generation.current++; - setFresh(setup); setStatus(next); }, from); - }} onRefresh={refresh} /> : + }} /> :

{t(failed ? "Could not connect to your console." : "Connecting to your console…")}

{failed ? : null}
}
; } -function AccountForm({ setup, onAuthenticated, onRefresh }: { - setup: boolean; onAuthenticated: (status: ConsoleAuth, from: HTMLElement | null) => void; onRefresh: () => void; +function CoreKeyForm({ onAuthenticated }: { + onAuthenticated: (status: ConsoleAuth, firstRun: boolean, from: HTMLElement | null) => void; }) { const { t } = useTranslation("firstRun"); const id = useId(); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); - const [uncertain, setUncertain] = useState(false); const pending = useRef(false); + const input = useRef(null); const controller = useRef(null); useEffect(() => () => controller.current?.abort(), []); + + function errorMessage(cause: unknown): string { + const status = cause instanceof ConsoleAuthError ? cause.status : 0; + if (status === 401) return t("This Core key is not correct. Check it and try again."); + if (status === 429) { + const seconds = cause instanceof ConsoleAuthError ? cause.retryAfterSeconds : null; + return seconds && seconds > 1 ? t("Too many attempts. Try again in {{seconds}} seconds.", { seconds }) : t("Too many attempts. Wait a moment before trying again."); + } + if (status === 503) return t("The console cannot check the key right now. Try again later."); + return t("Could not sign in. Try again."); + } + async function submit(event: FormEvent) { event.preventDefault(); - if (pending.current || uncertain) return; - const data = new FormData(event.currentTarget); + if (pending.current) return; const submitter = event.nativeEvent instanceof SubmitEvent && event.nativeEvent.submitter instanceof HTMLElement ? event.nativeEvent.submitter : null; - const password = String(data.get("password") ?? ""); - if (setup && password !== data.get("confirm")) { setError(t("Passwords do not match.")); return; } - const bytes = new TextEncoder().encode(password).length; - if (setup && (bytes < 12 || bytes > 72)) { setError(t("Use a password between 12 and 72 bytes.")); return; } + const coreKey = String(new FormData(event.currentTarget).get("core-key") ?? "").trim(); + if (!coreKey) return; pending.current = true; setBusy(true); setError(null); const request = new AbortController(); controller.current = request; try { - const next = await changeConsoleAuth(setup ? "setup" : "login", { - username: String(data.get("username") ?? ""), password, - }, request.signal); - if (!request.signal.aborted) { - onAuthenticated(next, submitter); - } + const next = await changeConsoleAuth({ action: "login", coreKey }, request.signal); + const firstRun = await needsFirstProject(); + if (!request.signal.aborted) onAuthenticated(next, firstRun, submitter); } catch (cause) { if (request.signal.aborted) return; - const status = cause instanceof ConsoleAuthError ? cause.status : 0; - if (status === 401 || status === 400) setError(t(setup ? "Check the account details and try again." : "Check your sign-in details and try again.")); - else if (status === 429) setError(t("Too many attempts. Wait a moment before trying again.")); - else { - setUncertain(true); - setError(t(status === 409 ? "An administrator already exists. Sign in to continue." : "Could not confirm the result. Check the account status before trying again.")); - } + setError(errorMessage(cause)); + input.current?.select(); } finally { pending.current = false; if (!request.signal.aborted) setBusy(false); } } + return
void submit(event)}> -

{t(setup ? "Create your administrator account" : "Welcome back")}

- {/* Rules sit behind help tips beside their labels, not as small print under the fields. */} -
- {setup ? {t("Letters, numbers, dots, underscores and hyphens.")} : null} - -
+

{t("Sign in to Parsar Core")}

+ {/* Where the key is and what it can do sit behind the help tip, not as small print under the field. */}
- {setup ? {t("12–72 bytes. Keep this password somewhere safe.")} : null} - + + {t("This deployment's Core key. The installer saved it in a private file: {{file}} in the installation directory, by default {{defaultPath}}. It is an administration credential: it cannot call the /v1 Agents API, and the console never keeps it in your browser.", CORE_KEY_LOCATION)} + {/* Read-only rather than disabled while signing in, so a refused key can be selected for correction. */} +
- {setup ? : null} - {error ?

{error}

: null} - {uncertain ? : - } + {error ? : null} +
; } diff --git a/apps/web/src/features/first-run/auth.test.ts b/apps/web/src/features/first-run/auth.test.ts index 69f905e8e..74604e876 100644 --- a/apps/web/src/features/first-run/auth.test.ts +++ b/apps/web/src/features/first-run/auth.test.ts @@ -1,27 +1,29 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { changeConsoleAuth, parseConsoleAuth, readConsoleAuth } from "./auth"; +import { changeConsoleAuth, ConsoleAuthError, parseConsoleAuth, readConsoleAuth } from "./auth"; afterEach(() => vi.unstubAllGlobals()); describe("console authentication boundary", () => { - it("accepts only finite modes and returns no additional server fields", () => { - expect(parseConsoleAuth({ mode: "authenticated", username: "admin", token: "secret" })).toEqual({ mode: "authenticated", username: "admin" }); - for (const value of [null, {}, { mode: "admin" }, { mode: "authenticated" }, { mode: "authenticated", username: "" }]) expect(() => parseConsoleAuth(value)).toThrow(); + it("accepts only the login and authenticated modes and keeps no other server field", () => { + expect(parseConsoleAuth({ mode: "authenticated", username: "admin", token: "secret" })).toEqual({ mode: "authenticated" }); + expect(parseConsoleAuth({ mode: "login" })).toEqual({ mode: "login" }); + for (const value of [null, {}, { mode: "admin" }, { mode: "setup" }, { mode: "legacy" }]) expect(() => parseConsoleAuth(value)).toThrow(); }); - it("allows the legacy static response only before account mode is established", async () => { - vi.stubGlobal("fetch", vi.fn(async () => new Response("", { headers: { "content-type": "text/html" } }))); - expect(await readConsoleAuth()).toEqual({ mode: "legacy" }); - await expect(readConsoleAuth(undefined, true)).rejects.toThrow(); - }); - it("does not turn transport, invalid JSON or explicit mode downgrade into legacy access", async () => { - for (const response of [new Response("", { status: 503 }), new Response("{}", { headers: { "content-type": "application/json" } }), new Response('{"mode":"legacy"}')]) { + it("treats an HTML page, a failed read or invalid JSON as a failure, never as access", async () => { + for (const response of [new Response("", { headers: { "content-type": "text/html" } }), new Response("", { status: 404 }), new Response("", { status: 503 }), new Response("{}")]) { vi.stubGlobal("fetch", vi.fn(async () => response)); - await expect(readConsoleAuth(undefined, true)).rejects.toThrow(); + await expect(readConsoleAuth()).rejects.toBeInstanceOf(ConsoleAuthError); } }); - it("sends credentials only in a same-origin JSON POST, not in the URL", async () => { - const fetcher = vi.fn(async () => new Response('{"mode":"authenticated","username":"admin"}')); + it("sends the Core key only in a same-origin JSON POST body", async () => { + const fetcher = vi.fn(async () => new Response('{"mode":"authenticated"}')); vi.stubGlobal("fetch", fetcher); - await changeConsoleAuth("login", { username: "admin", password: "private" }); - expect(fetcher.mock.calls[0]).toEqual(["/console/auth/login", expect.objectContaining({ method: "POST", credentials: "same-origin", body: '{"username":"admin","password":"private"}' })]); + expect(await changeConsoleAuth({ action: "login", coreKey: "private" })).toEqual({ mode: "authenticated" }); + expect(fetcher.mock.calls[0]).toEqual(["/console/auth/login", expect.objectContaining({ method: "POST", credentials: "same-origin", body: '{"core_key":"private"}' })]); + }); + it("reports the status of a refused sign-in and how long to wait after too many attempts", async () => { + vi.stubGlobal("fetch", vi.fn(async () => new Response('{"error":"Too many attempts"}', { status: 429, headers: { "retry-after": "42" } }))); + await expect(changeConsoleAuth({ action: "login", coreKey: "k" })).rejects.toMatchObject({ status: 429, retryAfterSeconds: 42 }); + vi.stubGlobal("fetch", vi.fn(async () => new Response('{"error":"Invalid Core key"}', { status: 401 }))); + await expect(changeConsoleAuth({ action: "login", coreKey: "k" })).rejects.toMatchObject({ status: 401, retryAfterSeconds: null }); }); }); diff --git a/apps/web/src/features/first-run/auth.ts b/apps/web/src/features/first-run/auth.ts index aebbecb2a..7266beb09 100644 --- a/apps/web/src/features/first-run/auth.ts +++ b/apps/web/src/features/first-run/auth.ts @@ -1,35 +1,49 @@ -export type ConsoleAuth = { mode: "legacy" | "setup" | "login" } | { mode: "authenticated"; username: string }; +/** Whether the browser holds a console session. The console has one administrator and no usernames. */ +export type ConsoleAuth = { mode: "login" } | { mode: "authenticated" }; export class ConsoleAuthError extends Error { - constructor(readonly status: number) { super("Console authentication failed."); } + constructor(readonly status: number, readonly retryAfterSeconds: number | null = null) { super("Console authentication failed."); } } export function parseConsoleAuth(value: unknown): ConsoleAuth { if (!value || typeof value !== "object" || !("mode" in value)) throw new ConsoleAuthError(502); - if (value.mode === "legacy" || value.mode === "setup" || value.mode === "login") return { mode: value.mode }; - if (value.mode === "authenticated" && "username" in value && typeof value.username === "string" && value.username.length > 0) { - return { mode: "authenticated", username: value.username }; - } + if (value.mode === "login" || value.mode === "authenticated") return { mode: value.mode }; throw new ConsoleAuthError(502); } -export async function readConsoleAuth(signal?: AbortSignal, accountExpected = false): Promise { - const response = await fetch("/console/auth", { credentials: "same-origin", cache: "no-store", signal }); - // Older consoles and the standalone development server serve the SPA here. - if (!accountExpected && (response.status === 404 || response.ok && response.headers.get("content-type")?.includes("text/html"))) { - return { mode: "legacy" }; +/** Seconds to wait from a `Retry-After` header, given as seconds or as an HTTP date. */ +export function retryAfterSeconds(header: string | null, now = Date.now()): number | null { + const value = header?.trim(); + if (!value) return null; + if (/^\d+$/.test(value)) return Number(value); + const at = Date.parse(value); + return Number.isNaN(at) ? null : Math.max(0, Math.ceil((at - now) / 1000)); +} + +async function readStatus(response: Response): Promise { + if (!response.ok) throw new ConsoleAuthError(response.status, response.status === 429 ? retryAfterSeconds(response.headers.get("retry-after")) : null); + try { + return parseConsoleAuth(await response.json()); + } catch { + throw new ConsoleAuthError(502); } - if (!response.ok) throw new ConsoleAuthError(response.status); - const status = parseConsoleAuth(await response.json()); - if (accountExpected && status.mode === "legacy") throw new ConsoleAuthError(502); - return status; } -export async function changeConsoleAuth(action: "setup" | "login" | "logout", body: Record, signal?: AbortSignal): Promise { - const response = await fetch(`/console/auth/${action}`, { - method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), signal, - }); - if (!response.ok) throw new ConsoleAuthError(response.status); - return parseConsoleAuth(await response.json()); +export async function readConsoleAuth(signal?: AbortSignal): Promise { + return readStatus(await fetch("/console/auth", { credentials: "same-origin", cache: "no-store", signal })); +} + +/** + * Signs in with the deployment's Core key, or signs out. The key travels only + * in this same-origin JSON body; the console server answers with a session + * cookie and the browser never stores the key. + */ +export async function changeConsoleAuth( + change: { action: "login"; coreKey: string } | { action: "logout" }, + signal?: AbortSignal, +): Promise { + return readStatus(await fetch(`/console/auth/${change.action}`, { + method: "POST", credentials: "same-origin", cache: "no-store", headers: { "Content-Type": "application/json" }, + body: JSON.stringify(change.action === "login" ? { core_key: change.coreKey } : {}), signal, + })); } diff --git a/apps/web/src/features/first-run/console-access.css b/apps/web/src/features/first-run/console-access.css index 519a4cbeb..87b350874 100644 --- a/apps/web/src/features/first-run/console-access.css +++ b/apps/web/src/features/first-run/console-access.css @@ -5,9 +5,7 @@ .console-auth-form .field { gap: 7px; } .console-auth-form .field > span { font-size: 12px; } .console-auth-form .field input { padding: 10px 12px; min-height: 42px; background: var(--surface-subtle); } -.console-auth-form .field small { font-size: 11px; line-height: 1.6; color: var(--fg-muted); } .console-auth-form .button { min-height: 42px; justify-content: center; gap: 12px; } .console-auth-form .console-auth-error { color: var(--danger); } .console-account-menu > button { background: transparent; border: 0; display: flex; white-space: nowrap; gap: 9px; align-items: center; padding: 8px; color: var(--fg-muted); font: inherit; font-size: 12px; cursor: pointer; } -.console-account-menu small { color: var(--danger); } .console-account-menu > button:hover { color: var(--fg); } diff --git a/apps/web/src/features/onboarding/OnboardingLayout.tsx b/apps/web/src/features/onboarding/OnboardingLayout.tsx index 84f5df8e6..50f7c5229 100644 --- a/apps/web/src/features/onboarding/OnboardingLayout.tsx +++ b/apps/web/src/features/onboarding/OnboardingLayout.tsx @@ -5,8 +5,8 @@ import { useTranslation } from "react-i18next"; import { OnboardingStage, type StageScene, type TourChapter } from "./OnboardingStage"; import "./onboarding.css"; -export type OnboardingStep = "account" | "project" | "tour"; -const STEPS: readonly OnboardingStep[] = ["account", "project", "tour"]; +export type OnboardingStep = "project" | "tour"; +const STEPS: readonly OnboardingStep[] = ["project", "tour"]; /** * Every onboarding screen: the dark stage on the left, the step's panel on @@ -14,12 +14,12 @@ const STEPS: readonly OnboardingStep[] = ["account", "project", "tour"]; * (no `step`) uses the same frame without the progress. */ export function OnboardingLayout({ scene, chapter, step, controls, children }: { - /** What the stage shows; null while the console is still checking the account. */ + /** What the stage shows; null while the console is still checking the sign-in. */ scene: StageScene | "tour" | null; chapter?: TourChapter; /** The first-run step this screen is; omitted when signing in. */ step?: OnboardingStep; - /** Theme, language and account controls. */ + /** Theme, language and sign-out controls. */ controls: ReactNode; children: ReactNode; }) { diff --git a/apps/web/src/features/onboarding/OnboardingStage.tsx b/apps/web/src/features/onboarding/OnboardingStage.tsx index ada8064cb..45d9b6117 100644 --- a/apps/web/src/features/onboarding/OnboardingStage.tsx +++ b/apps/web/src/features/onboarding/OnboardingStage.tsx @@ -9,7 +9,7 @@ import { FlickeringGrid } from "@/components/magicui/flickering-grid"; import { LightRays } from "@/components/magicui/light-rays"; import { OrbitingCircles } from "@/components/magicui/orbiting-circles"; -export type StageScene = "account" | "login" | "project"; +export type StageScene = "login" | "project"; export type TourChapter = "monitor" | "resources" | "platform"; const EASE = [0.16, 1, 0.3, 1] as const; diff --git a/apps/web/src/features/onboarding/onboarding.css b/apps/web/src/features/onboarding/onboarding.css index 6b35f9fcd..2682878e5 100644 --- a/apps/web/src/features/onboarding/onboarding.css +++ b/apps/web/src/features/onboarding/onboarding.css @@ -1,5 +1,6 @@ /* - * Onboarding: administrator setup, the first project and key, and the tour. + * Onboarding: signing in with the Core key, the first project and its API key, + * and the tour. * A dark stage (always dark, whatever the theme) holds the brand moment — Core * as the Parsar mark with its orbits, or a screenshot of the console during * the tour; the panel beside it follows the console theme and holds the task. @@ -282,7 +283,7 @@ color: #fff; } -/* The account form and the first project screens inside the panel. */ +/* The sign-in form and the first project screens inside the panel. */ .onboarding-panel .console-auth-form, .onboarding-panel .first-key-column { width: 100%; @@ -298,11 +299,6 @@ letter-spacing: -0.02em; } -.onboarding-preparing { - color: var(--ink-3); - font-size: 13px; -} - /* The example request types itself into a terminal. */ .onboarding-terminal { position: relative; diff --git a/apps/web/src/i18n/locales/en/keys.ts b/apps/web/src/i18n/locales/en/keys.ts index 89cd27df9..a747002cc 100644 --- a/apps/web/src/i18n/locales/en/keys.ts +++ b/apps/web/src/i18n/locales/en/keys.ts @@ -1,7 +1,7 @@ export const keys = { page: { title: "Projects and keys", - help: "A project owns a set of assets: Agents, Sessions, Skills, templates, files and Vaults. Every API key of a project sees and uses the same assets; projects are isolated from each other. Archiving a project revokes all of its keys and keeps its assets.", + help: "A project owns a set of assets: Agents, Sessions, Skills, templates, files and Vaults. Every API key of a project sees and uses the same assets; projects are isolated from each other. Archiving a project revokes all of its keys and keeps its assets. Applications call the Agents API (/v1) with these project API keys; the Core key you sign in with is separate and is not listed here.", create: "Create project", refresh: "Refresh projects", loading: "Loading projects…", @@ -194,12 +194,12 @@ export const keys = { }, firstRun: { title: "Create your first project", - lead: "A project holds Agents, Sessions and files. Callers reach it with an API key.", + lead: "A project holds Agents, Sessions and files. Applications reach it with a project API key.", projectName: "Project name", submit: "Create project and key", submitting: "Creating…", projectCreated: "Project {{project}} was created. Issue its key to continue.", - readyTitle: "Your API key is ready", + readyTitle: "Your project API key is ready", keyCaption: "{{key}} · {{project}}", tryIt: "Try it", tryItHelp: "Run this in a terminal with PROJECT_API_KEY set to the project API key above. It lists the project's Agents; a new project has none yet. The console does not send this request.", diff --git a/apps/web/src/i18n/locales/en/onboarding.ts b/apps/web/src/i18n/locales/en/onboarding.ts index 3703acc38..c46c11175 100644 --- a/apps/web/src/i18n/locales/en/onboarding.ts +++ b/apps/web/src/i18n/locales/en/onboarding.ts @@ -1,17 +1,14 @@ export const onboarding = { steps: { label: "Setup progress", - account: "Administrator", - project: "Project and key", + project: "Project and API key", tour: "Your console", }, stage: { - account: { title: "One Core. Many Agents.", body: "Connect your machines, run your Agents, and watch every Session from one console." }, - login: { title: "Welcome back to your Core.", body: "Your Agents, Sessions and machines, where you left them." }, + login: { title: "One Core. Many Agents.", body: "Connect your machines, run your Agents, and watch every Session from one console." }, project: { title: "A project holds the work.", body: "Agents, Sessions, Skills, files and Vaults belong to a project. Applications reach it with the project's API keys." }, orbit: "Core and what it manages: Agents, Sessions, Skills, Vaults, files, templates and machines", }, - preparing: "Preparing your console…", terminal: "Try it in a terminal", tour: { eyebrow: "Your console · {{n}} of {{total}}", @@ -43,7 +40,7 @@ export const onboarding = { name: "Platform", title: "The deployment itself.", points: [ - "Projects and keys: create projects, issue keys once, revoke or archive.", + "Projects and keys: create projects, issue project API keys shown once, revoke or archive.", "Nodes: add your machines with one command and watch their capacity.", "System: the Core build and how it was started.", ], diff --git a/apps/web/src/i18n/locales/zh-CN/keys.ts b/apps/web/src/i18n/locales/zh-CN/keys.ts index 29c98d477..1cd5f7af2 100644 --- a/apps/web/src/i18n/locales/zh-CN/keys.ts +++ b/apps/web/src/i18n/locales/zh-CN/keys.ts @@ -3,7 +3,7 @@ type TranslationShape = { [K in keyof T]: T[K] extends string ? string : Tran export const keys: TranslationShape = { page: { title: "项目与 key", - help: "项目拥有一组资产:Agent、Session、Skill、环境模板、文件和 Vault。同一个项目的所有 API key 看到和使用的是同一批资产,项目之间互相隔离。归档项目会撤销它的全部 key,资产保留。", + help: "项目拥有一组资产:Agent、Session、Skill、环境模板、文件和 Vault。同一个项目的所有 API key 看到和使用的是同一批资产,项目之间互相隔离。归档项目会撤销它的全部 key,资产保留。应用用这些项目 API Key 调用 Agents API(/v1);登录控制台用的 Core Key 与它们无关,也不在这里列出。", create: "创建项目", refresh: "刷新项目", loading: "正在加载项目…", @@ -196,12 +196,12 @@ export const keys: TranslationShape = { }, firstRun: { title: "创建第一个项目", - lead: "项目存放 Agent、Session 和文件,调用方用 API key 访问它。", + lead: "项目存放 Agent、Session 和文件,应用用项目 API Key 访问它。", projectName: "项目名称", submit: "创建项目和 key", submitting: "正在创建…", projectCreated: "项目 {{project}} 已创建,签发它的 key 后继续。", - readyTitle: "API key 已就绪", + readyTitle: "项目 API Key 已就绪", keyCaption: "{{key}} · {{project}}", tryIt: "试一下", tryItHelp: "在终端里把 PROJECT_API_KEY 设为上面的项目 API Key 后运行。它会列出项目里的 Agent,新项目还没有 Agent。控制台不会发送这个请求。", diff --git a/apps/web/src/i18n/locales/zh-CN/onboarding.ts b/apps/web/src/i18n/locales/zh-CN/onboarding.ts index bd9a37cae..7d077b141 100644 --- a/apps/web/src/i18n/locales/zh-CN/onboarding.ts +++ b/apps/web/src/i18n/locales/zh-CN/onboarding.ts @@ -1,17 +1,14 @@ export const onboarding = { steps: { label: "设置进度", - account: "管理员", - project: "项目和 key", + project: "项目和 API Key", tour: "认识控制台", }, stage: { - account: { title: "一个 Core,承载所有 Agent。", body: "连接你的机器,运行你的 Agent,在一个控制台里看清每一个 Session。" }, - login: { title: "欢迎回到你的 Core。", body: "你的 Agent、Session 和机器,都还在原处。" }, - project: { title: "项目承载所有工作。", body: "Agent、Session、Skills、文件和 Vault 都归属某个项目;应用用项目的 API key 访问它们。" }, + login: { title: "一个 Core,承载所有 Agent。", body: "连接你的机器,运行你的 Agent,在一个控制台里看清每一个 Session。" }, + project: { title: "项目承载所有工作。", body: "Agent、Session、Skills、文件和 Vault 都归属某个项目;应用用项目 API Key 访问它们。" }, orbit: "Core 和它管理的一切:Agent、Session、Skills、Vault、文件、环境模板和机器", }, - preparing: "正在准备你的控制台…", terminal: "在终端里试一下", tour: { eyebrow: "认识控制台 · {{n}} / {{total}}", @@ -43,7 +40,7 @@ export const onboarding = { name: "平台", title: "部署本身。", points: [ - "项目与 key:创建项目,签发只显示一次的 key,撤销或归档。", + "项目与 key:创建项目,签发只显示一次的项目 API Key,撤销或归档。", "节点:一条命令接入你的机器,随时看容量。", "系统:Core 的构建版本和启动配置。", ], diff --git a/apps/web/src/lib/console-auth-strings.ts b/apps/web/src/lib/console-auth-strings.ts index a0555bc50..11ca907d8 100644 --- a/apps/web/src/lib/console-auth-strings.ts +++ b/apps/web/src/lib/console-auth-strings.ts @@ -3,27 +3,17 @@ export const consoleAuthChinese = { "Copied": "已复制", "A place for your Agents to work.": "让你的 Agent 在这里工作。", "Connect your machines. Create Agents. Watch work happen.": "连接自己的机器,创建 Agent,让工作在这里发生。", - "Create your administrator account": "创建管理员账户", - "You manage this cloud.": "这片云,由你管理。", - "Use your administrator account to continue.": "使用管理员账户继续。", - "Welcome back": "欢迎回来", - "Administrator username": "管理员用户名", - "Password": "密码", - "Confirm password": "确认密码", - "12–72 bytes. Keep this password somewhere safe.": "12–72 字节,请妥善保管这个密码。", - "Letters, numbers, dots, underscores and hyphens.": "支持英文字母、数字、点、下划线和短横线。", - "Create administrator account": "创建管理员账户", + "Sign in to Parsar Core": "登录 Parsar Core", + "Core key": "Core Key", + "This deployment's Core key. The installer saved it in a private file: {{file}} in the installation directory, by default {{defaultPath}}. It is an administration credential: it cannot call the /v1 Agents API, and the console never keeps it in your browser.": + "这个部署的 Core Key,由安装器保存在安装目录下的私有文件 {{file}} 中,默认是 {{defaultPath}}。它是管理凭据:不能调用 /v1 Agents API,控制台也不会把它保存在你的浏览器里。", "Sign in": "登录", "Signing in…": "正在登录…", - "Creating account…": "正在创建账户…", - "Passwords do not match.": "两次输入的密码不一致。", - "Use a password between 12 and 72 bytes.": "请使用 12–72 字节的密码。", - "Check your sign-in details and try again.": "请检查登录信息后重试。", - "Check the account details and try again.": "请检查账户信息后重试。", - "An administrator already exists. Sign in to continue.": "管理员账户已创建,请登录继续。", + "This Core key is not correct. Check it and try again.": "Core Key 不正确,请检查后重试。", + "Too many attempts. Try again in {{seconds}} seconds.": "尝试次数过多,请 {{seconds}} 秒后再试。", "Too many attempts. Wait a moment before trying again.": "尝试次数过多,请稍后再试。", - "Could not confirm the result. Check the account status before trying again.": "暂时无法确认结果,请先检查账户状态。", - "Check account status": "检查账户状态", + "The console cannot check the key right now. Try again later.": "控制台暂时无法校验 Core Key,请稍后再试。", + "Could not sign in. Try again.": "登录失败,请重试。", "Connecting to your console…": "正在连接控制台…", "Could not connect to your console.": "暂时无法连接控制台。", "Sign out": "退出登录", diff --git a/docs/web/architecture.md b/docs/web/architecture.md index 1459f8a2b..67405a939 100644 --- a/docs/web/architecture.md +++ b/docs/web/architecture.md @@ -35,7 +35,7 @@ The console authenticates the browser, checks the request origin and forwards on allowed management routes. It replaces browser authorization and actor headers, strips browser cookies, and supplies its server-side deployment credential. Core rejects application keys on management routes and deployment credentials on `/v1`. -The forwarded console account name is an audit label, not Core authorization. +The forwarded audit actor label is not Core authorization. Node and daemon connections use `/api/v1` with their own credentials. The reverse proxy sends them directly to Core; the console never forwards them, and they do not diff --git a/docs/web/core-connection.md b/docs/web/core-connection.md index 53ee27355..b151ed0ea 100644 --- a/docs/web/core-connection.md +++ b/docs/web/core-connection.md @@ -32,22 +32,24 @@ application's caller-managed `self_hosted` Runtime, including its own E2B setup. | `CORE_CONSOLE_ADDR` | Console listener address | | `CORE_CONSOLE_ORIGIN` | Exact browser-facing origin used for host and origin checks | | `CORE_CONSOLE_UPSTREAM` | Core HTTP(S) origin, without credentials, query or resource path | -| `CORE_CONSOLE_ADMIN_TOKEN_FILE` | Absolute path to a private regular file containing the deployment credential | -| `CORE_CONSOLE_AUTH_MODE=account` | Enables console account login | -| `CORE_CONSOLE_STATE_DIR` | Private account state directory (login sessions are process-local) | +| `CORE_CONSOLE_CORE_KEY_FILE` | Absolute path to the private regular file containing the Core key | | `CORE_CONSOLE_DIST` | Absolute directory containing the built Web assets | -Account mode exposes `GET /console/auth` and `POST /console/auth/setup`, `/login` -and `/logout`. Initial setup registers the console account; subsequent login uses -a same-origin session cookie. Console accounts do not create Core API users, -Projects, roles or application keys. `GET /console/config` provides safe console +The console exposes `GET /console/auth` and `POST /console/auth/login` and +`/logout`. The administrator signs in with the deployment's Core key, which the +installer writes to `admin/core.key` under the installation directory (by default +`~/.parsar/core/admin/core.key`; see [Core key](../getting-started/operations.md#core-key)). +The server compares it in constant time and answers with a same-origin session +cookie held only in its memory; the key is never logged or returned, and the +browser does not store it. A console restart or a Core key rotation requires +signing in again. There are no console accounts, usernames or setup step, and the +Core key cannot call `/v1`. `GET /console/config` provides safe console configuration to an authenticated browser. Use TLS for remote browser access and loopback listeners for local development. Preserve host/origin checks and the management route allowlist. Browser authorization, cookies and actor headers are replaced or removed before forwarding to Core. -The service reports the authenticated console account as an audit label; a browser -cannot choose that label. Keep deployment, application, node and provider credentials +A browser cannot choose the audit actor label the service reports. Keep deployment, application, node and provider credentials out of `VITE_*`, browser storage, source files, URLs and logs. ## Projects and application keys @@ -70,8 +72,8 @@ work. Ordinary metadata reads cannot recover plaintext. browser-to-console and console-to-Core path. 3. A Project key must work on its public resources and fail on management routes. The deployment credential must fail on `/v1`; `/v1` through the console stays 404. -4. Cross-origin management writes must be rejected. Audit actor labels must reflect - the signed-in console account despite a forged browser header. +4. Cross-origin management writes must be rejected. Audit actor labels must ignore + a forged browser header. 5. Runtime observations and history report execution state separately from startup configuration. Neither a login nor a successful configuration read proves model or sandbox readiness. diff --git a/docs/web/protocol-coverage.md b/docs/web/protocol-coverage.md index 75cd4e15f..df3e199ef 100644 --- a/docs/web/protocol-coverage.md +++ b/docs/web/protocol-coverage.md @@ -15,14 +15,14 @@ the administrator API are defined by the [administrator API contract](../../cont | Interface | Paths | Authentication | Console use | | --- | --- | --- | --- | -| Console server | `/console/auth`, `/console/auth/{setup,login,logout}`, `/console/config` | Console account (session cookie) or legacy Basic authentication | Sign-in and sign-out; non-secret capability flags such as `sandbox_admin` | +| Console server | `/console/auth`, `/console/auth/{login,logout}`, `/console/config` | Core key at sign-in, then the console session cookie | Sign-in with the Core key and sign-out; non-secret capability flags such as `sandbox_admin` | | Administrator API | `/core/v1/admin/**` | Deployment administrator credential, added by the console server | Projects, keys, resource reads and deletion, provenance, summaries, Runtime observations | | Sandbox administration | `/core/v1/sandbox/**` | Deployment administrator credential, added by the console server | Nodes page; fleet and capacity figures on Overview and Sandbox metrics | | Agents API | `/v1/**` | Project API key | Not used. The first-run screen shows a `curl` example for `/v1/agents` with a `$PROJECT_API_KEY` placeholder; the console never sends it | -Browser requests are same-origin and carry only the console sign-in. The browser -never holds or sends the deployment credential, an API key or an `OpenAI-Beta` -header. Responses are validated: a malformed value is reported as a failure, or +Browser requests are same-origin and carry only the console session. The browser +sends the Core key once, in the sign-in request body, and never stores it; it never +holds or sends an API key or an `OpenAI-Beta` header. Responses are validated: a malformed value is reported as a failure, or marked as unrecognised where noted below, and never replaced by a guessed or zero value. diff --git a/docs/web/roadmap.md b/docs/web/roadmap.md index 14ec2c494..edd516007 100644 --- a/docs/web/roadmap.md +++ b/docs/web/roadmap.md @@ -13,8 +13,10 @@ contract. Public Agents API compatibility work is tracked in the - Console login and same-origin `AdminClient` and sandbox management requests; the browser holds no deployment credential or application key. -- First run: administrator setup, the first project and its key shown once, and a - short tour of the console before it opens. +- Sign-in with the deployment's Core key; the browser keeps only the session + cookie. +- First run: after signing in with no project, the first project and its API key + shown once, and a short tour of the console before it opens. - Projects and keys: create, rename, archive, issue with one-time display, revoke; uncertain writes are reported, never replayed. - Resource inspection and permitted deletion; no execution, resource editors, @@ -38,8 +40,8 @@ production console service. Browser acceptance must also cover denied cross-orig writes, absent `/v1` proxying, secret handling and uncertain write outcomes. `apps/web/e2e` covers the browser side against `fixture-console.mjs`, a synthetic -console service: administrator setup and sign-in with no credential in browser -storage; Project creation, one-time key display, revocation and archive; an +console service: Core key sign-in, a refused key and repeated attempts, and sign-out, +with no credential in browser storage; first run from sign-in to the console; Project creation, one-time key display, revocation and archive; an unconfirmed key issue that is reported and never replayed; a refused deletion that keeps Core's reason; the monitor pages and a read-only Session conversation; node enrollment and removal. From a67c1e48f65dbe65e3be273bed3505ce1aae1b9a Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 13:22:46 +0000 Subject: [PATCH 4/6] Treat a console config without sandbox_admin as granted; name the console audit actor --- apps/web/e2e/fixture-console.mjs | 2 +- .../web/src/features/sandbox/console-config.test.ts | 12 ++++-------- apps/web/src/features/sandbox/console-config.ts | 13 +++++++------ docs/web/architecture.md | 2 +- docs/web/core-connection.md | 2 +- 5 files changed, 14 insertions(+), 17 deletions(-) diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index c6b05f77c..3a7ec989c 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -115,7 +115,7 @@ async function consoleRoute(request, response, url) { return send(response, 200, { mode: "login" }, { "set-cookie": `${SESSION_COOKIE.split("=")[0]}=; Path=/; Max-Age=0` }); } if (url.pathname === "/console/config") { - return send(response, 200, { api_keys: true, sandbox_admin: true, node_installer: true, node_installer_sha256: "a".repeat(64) }); + return send(response, 200, { node_installer: true, node_installer_sha256: "a".repeat(64) }); } return error(response, 404, "Not found."); } diff --git a/apps/web/src/features/sandbox/console-config.test.ts b/apps/web/src/features/sandbox/console-config.test.ts index 3aefd63c1..7c0f62ad7 100644 --- a/apps/web/src/features/sandbox/console-config.test.ts +++ b/apps/web/src/features/sandbox/console-config.test.ts @@ -4,7 +4,7 @@ import { sandboxConsoleConfig } from "./console-config"; afterEach(() => vi.unstubAllGlobals()); describe("bundled console capabilities", () => { it("uses the existing console login without sending a project or admin bearer", async () => { - const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ sandbox_admin: true, node_installer: true, node_installer_sha256: "a".repeat(64) }))); + const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ node_installer: true, node_installer_sha256: "a".repeat(64) }))); vi.stubGlobal("fetch", fetch); const controller = new AbortController(); expect(await sandboxConsoleConfig(controller.signal)).toEqual({ sandbox_admin: true, node_installer: true, node_installer_sha256: "a".repeat(64) }); @@ -22,12 +22,8 @@ describe("bundled console capabilities", () => { vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("Bad gateway", { status: 502 }))); await expect(sandboxConsoleConfig(new AbortController().signal)).rejects.toThrow(); }); - it.each([true, false])("preserves explicit API key management capability %s", async (enabled) => { - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ api_keys: enabled })))); - expect((await sandboxConsoleConfig(new AbortController().signal))?.api_keys).toBe(enabled); - }); - it("keeps an absent or malformed API key capability unknown", async () => { - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ api_keys: "false" })))); - expect((await sandboxConsoleConfig(new AbortController().signal))?.api_keys).toBeUndefined(); + it("disables sandbox administration only when the console says so", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ sandbox_admin: false })))); + expect((await sandboxConsoleConfig(new AbortController().signal))?.sandbox_admin).toBe(false); }); }); diff --git a/apps/web/src/features/sandbox/console-config.ts b/apps/web/src/features/sandbox/console-config.ts index 92bf78fb4..73a14b285 100644 --- a/apps/web/src/features/sandbox/console-config.ts +++ b/apps/web/src/features/sandbox/console-config.ts @@ -1,14 +1,16 @@ export interface SandboxConsoleConfig { sandbox_admin: boolean; - api_keys?: boolean; node_installer: boolean; node_installer_sha256: string; } /** - * The console's capability flags. An absent endpoint (404, an older console) - * means no sandbox administration; any other failure is thrown so callers - * report a failed read instead of "not configured". + * The console's capability flags. Signing in with the Core key grants + * administration, so Core reports only the node installer and sandbox + * administration is available unless the console says `sandbox_admin: false`. + * An absent endpoint (404, an older console) means no sandbox administration; + * any other failure is thrown so callers report a failed read instead of + * "not configured". */ export async function sandboxConsoleConfig(signal: AbortSignal): Promise { const response = await fetch("/console/config", { credentials: "include", signal }); @@ -16,8 +18,7 @@ export async function sandboxConsoleConfig(signal: AbortSignal): Promise; return { - sandbox_admin: config.sandbox_admin === true, - ...(typeof config.api_keys === "boolean" ? { api_keys: config.api_keys } : {}), + sandbox_admin: config.sandbox_admin !== false, node_installer: config.node_installer === true && /^[a-f0-9]{64}$/.test(config.node_installer_sha256 ?? ""), node_installer_sha256: config.node_installer_sha256 ?? "", }; diff --git a/docs/web/architecture.md b/docs/web/architecture.md index 67405a939..0faad230e 100644 --- a/docs/web/architecture.md +++ b/docs/web/architecture.md @@ -35,7 +35,7 @@ The console authenticates the browser, checks the request origin and forwards on allowed management routes. It replaces browser authorization and actor headers, strips browser cookies, and supplies its server-side deployment credential. Core rejects application keys on management routes and deployment credentials on `/v1`. -The forwarded audit actor label is not Core authorization. +The forwarded audit actor label is always `console` for console operations (operator scripts calling Core with the Core key directly have none); it is display only, not Core authorization. Node and daemon connections use `/api/v1` with their own credentials. The reverse proxy sends them directly to Core; the console never forwards them, and they do not diff --git a/docs/web/core-connection.md b/docs/web/core-connection.md index b151ed0ea..fc11cf8bd 100644 --- a/docs/web/core-connection.md +++ b/docs/web/core-connection.md @@ -49,7 +49,7 @@ configuration to an authenticated browser. Use TLS for remote browser access and loopback listeners for local development. Preserve host/origin checks and the management route allowlist. Browser authorization, cookies and actor headers are replaced or removed before forwarding to Core. -A browser cannot choose the audit actor label the service reports. Keep deployment, application, node and provider credentials +A browser cannot choose the audit actor label the service reports: console operations always show as `console`. Keep deployment, application, node and provider credentials out of `VITE_*`, browser storage, source files, URLs and logs. ## Projects and application keys From 76ba4bc2f50be15ef9c09493cbfb8041986e9ee4 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 13:33:21 +0000 Subject: [PATCH 5/6] Direct Core key calls leave the audit actor empty --- docs/web/architecture.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/web/architecture.md b/docs/web/architecture.md index 0faad230e..f605326d5 100644 --- a/docs/web/architecture.md +++ b/docs/web/architecture.md @@ -35,7 +35,7 @@ The console authenticates the browser, checks the request origin and forwards on allowed management routes. It replaces browser authorization and actor headers, strips browser cookies, and supplies its server-side deployment credential. Core rejects application keys on management routes and deployment credentials on `/v1`. -The forwarded audit actor label is always `console` for console operations (operator scripts calling Core with the Core key directly have none); it is display only, not Core authorization. +The forwarded audit actor label is always `console` for console operations (operator scripts calling Core with the Core key directly leave it empty); it is display only, not Core authorization. Node and daemon connections use `/api/v1` with their own credentials. The reverse proxy sends them directly to Core; the console never forwards them, and they do not From 0c44ae9edcdeba40698b3ab7bd4690a06f6d9a3f Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 13:52:01 +0000 Subject: [PATCH 6/6] The right Core key always signs in; the audit actor is caller-declared --- apps/web/e2e/access.spec.ts | 3 +++ apps/web/e2e/fixture-console.mjs | 7 ++++--- docs/web/architecture.md | 2 +- docs/web/core-connection.md | 2 +- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/web/e2e/access.spec.ts b/apps/web/e2e/access.spec.ts index cec15d4ff..7c704978b 100644 --- a/apps/web/e2e/access.spec.ts +++ b/apps/web/e2e/access.spec.ts @@ -32,6 +32,9 @@ test("signs in with the Core key, keeps it out of the browser, and signs out and await page.getByRole("button", { name: "Sign out" }).click(); for (let attempt = 0; attempt < 3; attempt++) await signIn(page, "not-the-core-key"); await expect(page.getByRole("alert")).toHaveText("Too many attempts. Try again in 30 seconds."); + // Failed attempts never lock out the right key. + await signIn(page, FIXTURE_CORE_KEY); + await expect(page.getByRole("heading", { name: "Overview", level: 1 })).toBeVisible(); expect(await browserStorage(page)).not.toContain(FIXTURE_CORE_KEY); }); diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index 3a7ec989c..a68cc810c 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -84,16 +84,17 @@ function authError(response, status, message, headers = {}) { send(response, status, { error: message }, headers); } -/** Sign-in with the Core key: a wrong key is refused, and repeated wrong keys lock sign-in for a while. */ +/** Sign-in with the Core key, as the console server does it: the key is compared first, so the right key + * always signs in; only wrong keys count, and repeated wrong keys are refused for a while. */ async function login(request, response) { const auth = state.auth; if (!request.headers["content-type"]?.startsWith("application/json")) return authError(response, 415, "Use application/json"); let input; try { input = await body(request); } catch { return authError(response, 400, "Invalid authentication request"); } if (typeof input?.core_key !== "string" || !input.core_key) return authError(response, 400, "Invalid authentication request"); - const wait = Math.ceil((auth.lockedUntil - Date.now()) / 1000); - if (wait > 0) return authError(response, 429, "Too many authentication attempts", { "retry-after": String(wait) }); if (input.core_key !== CORE_KEY) { + const wait = Math.ceil((auth.lockedUntil - Date.now()) / 1000); + if (wait > 0) return authError(response, 429, "Too many authentication attempts", { "retry-after": String(wait) }); auth.failures += 1; if (auth.failures < LOCKOUT_AFTER) return authError(response, 401, "Invalid Core key"); auth.lockedUntil = Date.now() + LOCKOUT_SECONDS * 1000; diff --git a/docs/web/architecture.md b/docs/web/architecture.md index f605326d5..c113ded25 100644 --- a/docs/web/architecture.md +++ b/docs/web/architecture.md @@ -35,7 +35,7 @@ The console authenticates the browser, checks the request origin and forwards on allowed management routes. It replaces browser authorization and actor headers, strips browser cookies, and supplies its server-side deployment credential. Core rejects application keys on management routes and deployment credentials on `/v1`. -The forwarded audit actor label is always `console` for console operations (operator scripts calling Core with the Core key directly leave it empty); it is display only, not Core authorization. +The audit actor label is declared by the caller and is display only, never Core authorization: the console server declares `console`, and operator scripts calling Core with the Core key directly leave it empty. Node and daemon connections use `/api/v1` with their own credentials. The reverse proxy sends them directly to Core; the console never forwards them, and they do not diff --git a/docs/web/core-connection.md b/docs/web/core-connection.md index fc11cf8bd..ae79ed356 100644 --- a/docs/web/core-connection.md +++ b/docs/web/core-connection.md @@ -49,7 +49,7 @@ configuration to an authenticated browser. Use TLS for remote browser access and loopback listeners for local development. Preserve host/origin checks and the management route allowlist. Browser authorization, cookies and actor headers are replaced or removed before forwarding to Core. -A browser cannot choose the audit actor label the service reports: console operations always show as `console`. Keep deployment, application, node and provider credentials +A browser cannot choose the audit actor label the service reports: the console server declares `console`. The label is caller-declared and display only. Keep deployment, application, node and provider credentials out of `VITE_*`, browser storage, source files, URLs and logs. ## Projects and application keys