Manager → task server authentication
May 21, 2026 · View on GitHub
How the spawned manager agent authenticates back to the local Bernstein task server to create child worker tasks.
This page exists because the auth handshake between the manager subprocess and
the in-process task server is the single most common silent-stall failure in
multi-task plans (see issue #1261). Operators see "agent alive, no children
created, watchdog kills the server after 60 s" - the underlying cause is
almost always a missing or stripped bearer token on the manager's POST /tasks call.
TL;DR
| What | Where | Lifetime |
|---|---|---|
| Per-session bearer (zero-trust) | .sdd/runtime/agent_tokens/{session_id}.token (mode 0600) | 4 h (task-scoped) |
| Legacy fallback bearer | BERNSTEIN_AUTH_TOKEN env var | Process lifetime |
| JWT signing secret | .sdd/auth/jwt_secret | Until manually rotated |
| Auth opt-out | BERNSTEIN_AUTH_DISABLED=1 | Process lifetime - logs loud WARN |
Auth is ENABLED by default. A spawned manager that does not present a
valid token to POST /tasks receives HTTP 401 and produces no children.
Overview
The task server is a local FastAPI process (default http://127.0.0.1:8052)
that owns the durable task store. The manager agent runs in its own git
worktree as a child subprocess of the orchestrator. When the manager
decomposes a goal into worker subtasks it must POST /tasks against the
server to register them - that request goes through the same auth
middleware as any other write call (SSOAuthMiddleware in
src/bernstein/core/security/auth_middleware.py).
Three credential paths exist; the manager may use whichever the orchestrator provisions:
- Per-session zero-trust JWT (default). Issued at spawn time, written to
a
0600file inside.sdd/runtime/agent_tokens/, scoped to the manager's task IDs. - Legacy static bearer. Set via
BERNSTEIN_AUTH_TOKEN; propagated to the agent subprocess through the env isolation allowlist. - Disabled (
BERNSTEIN_AUTH_DISABLED=1). Every request passes through; the middleware logs a one-shot SECURITY warning on startup.
Components
┌───────────────┐ spawn (subprocess) ┌──────────────────────────────┐
│ Orchestrator ├────────────────────────►│ Manager agent (worktree) │
│ (CLI process) │ │ session_id=manager-xxxxxxxx │
└──────┬────────┘ └──────────────┬───────────────┘
│ │
│ create JWT, write to │ read token file,
│ .sdd/runtime/agent_tokens/{sid}.token │ add Bearer header
│ │
│ build_filtered_env() → ▼
│ BERNSTEIN_AUTH_TOKEN if set ┌──────────────────────┐
│ BERNSTEIN_HOOK_SECRET │ POST /tasks │
│ │ Authorization: ... │
▼ └──────────┬───────────┘
┌────────────────────────────────────────────────────────────▼──────────┐
│ Task server (FastAPI on 127.0.0.1:8052) │
│ SSOAuthMiddleware → AuthService.validate_token / legacy compare │
└───────────────────────────────────────────────────────────────────────┘
Source pointers (read these if you need to debug from code):
| File | What it does |
|---|---|
src/bernstein/core/agents/spawner_core.py (_issue_agent_token, _render_auth_section) | Mints the per-session JWT, writes it 0600, injects path into the manager prompt. |
src/bernstein/adapters/env_isolation.py (_BASE_ALLOWLIST) | Allowlist for cross-process env inheritance. Contains BERNSTEIN_AUTH_TOKEN, BERNSTEIN_HOOK_SECRET, BERNSTEIN_AUTH_DISABLED. |
src/bernstein/core/security/auth.py (AuthService, create_jwt, verify_jwt) | JWT mint/verify (HS256), legacy bearer constant-time compare. |
src/bernstein/core/security/auth_middleware.py (SSOAuthMiddleware, auth_disabled_via_opt_out) | Server-side gate. Implements the three-strategy chain (SSO JWT → agent JWT → legacy bearer). |
src/bernstein/core/server/server_app.py (create_app) | Resolves BERNSTEIN_AUTH_TOKEN at boot, wires legacy_auth_token and AgentIdentityStore into middleware. |
src/bernstein/core/routes/auth.py | /auth/providers, /auth/cli/device, /auth/cli/token (device-flow for human CLI logins - not used by spawned agents). |
Where the token lives at runtime
Per-session JWT (default path)
.sdd/
└── runtime/
└── agent_tokens/
└── manager-e0838d38.token # mode 0600, contents = raw JWT
- Created: at agent spawn, inside
AgentSpawner._issue_agent_token. - Format: HS256 JWT signed with the secret from
.sdd/auth/jwt_secret(auto-generated on first run; persisted across restarts). - Claims:
sub=session_id,role,task_ids=[…],iat,exp,jti. - Expiry: 4 h for task-scoped tokens (240 s LIST default 14400 s); 24 h for unrestricted (manager / orchestrator) tokens.
- Revocation: deleted from disk and revoked in
AgentIdentityStorewhen the agent is reaped. - How the agent finds it: the absolute path is interpolated into the
manager's prompt via
_render_auth_section(seespawner_core.py:200). The agent is instructed to read the file and addAuthorization: Bearer $(cat <path>)to every task server request.
Legacy static bearer
- Env var:
BERNSTEIN_AUTH_TOKEN. - Read by server at:
create_app()inserver_app.py(line ~909). - Read by agent: present in
build_filtered_env()'s_BASE_ALLOWLIST(env_isolation.py:121) so subprocesses inherit it automatically. - When used: backwards compatibility for deployments that pre-date the per-session JWT flow.
JWT signing secret
.sdd/
└── auth/
└── jwt_secret # mode 0600, base64url 32-byte secret
Rotating this file invalidates all outstanding agent tokens. Restart the
orchestrator after rotation so the in-memory AuthService re-reads it.
What can go wrong (and how to fix it)
Symptom 1 - Manager spawned with stripped env → silent 401s → watchdog kill
This is the canonical #1261 failure.
Indicators:
.sdd/runtime/hooks/manager-*.jsonlshows the manager probing/tmp/openapi.jsonand looking for*.tokenfiles on disk..sdd/runtime/server.logshows 200 OKs to hook posts but no successfulPOST /tasksrequest from the manager session.- Orchestrator log ends with
Server unresponsive for 60s - killing for restart. - No child tasks created, no files written for the actual goal.
Cause: Either the per-session token file was not generated (look for
Issued zero-trust token for session manager-* in agent.log), or the
token file path never reached the manager (it should be embedded in the
prompt - grep the prompt log for Task Server Authentication).
Fix sequence:
- Confirm the token file exists:
ls -la .sdd/runtime/agent_tokens/manager-*.token- should be
-rw-------and non-empty.
- should be
- Confirm the manager prompt contains the auth section:
grep -A4 "Task Server Authentication" .sdd/runtime/hooks/manager-*.jsonl. - As a temporary workaround, set
BERNSTEIN_AUTH_TOKENbeforebernstein runso the legacy fallback path activates - it is inherited through the env allowlist regardless of whether the per-session token reaches the agent prompt.
Symptom 2 - Auth accidentally left disabled
BERNSTEIN_AUTH_DISABLED=1 (or auth.enabled: false in bernstein.yaml)
makes the middleware accept every request, including from any unrelated
process on 127.0.0.1. The orchestrator emits one SECURITY WARN at startup
and never repeats it.
Indicators:
bernstein statusshows manager creating children but you don't remember configuring auth..sdd/runtime/orchestrator.logline on startup:SECURITY: Bernstein auth is DISABLED - every request is accepted without a Bearer token.
Fix: unset BERNSTEIN_AUTH_DISABLED (or remove from .env), drop
auth.enabled: false from bernstein.yaml, restart.
Symptom 3 - Token rotation between manager spawn and POST
If .sdd/auth/jwt_secret is rotated (or .sdd/runtime/agent_tokens/ is
wiped) while a manager is mid-flight, that manager's cached token still
points at the old file path - cat returns empty / file-not-found, and the
resulting Bearer header is rejected with 401.
Indicators: mid-run 401 Unauthorized on POST /tasks after the
manager had previously succeeded.
Fix: never rotate jwt_secret or clear agent_tokens/ while a run is
in flight. Use bernstein stop first.
bernstein doctor checks
Run bernstein doctor for a top-level diagnostic. The relevant checks for
this flow are:
| Check | What it verifies |
|---|---|
| Adapter auth (claude / codex / gemini) | The agent provider credentials. Does NOT verify Bernstein's internal task-server auth - these are different layers. |
.sdd/auth/jwt_secret present | Per-session JWT issuance can succeed. |
.sdd workspace | .sdd/runtime/, .sdd/auth/ exist with correct modes. |
bernstein doctor does not yet have a dedicated check for the
manager-to-server bearer flow. Operators investigating #1261-class
failures should run the diagnostic commands below by hand.
Diagnostic commands
Run these from the project root while a stuck bernstein run is in
progress. Each command prints the minimum signal needed to localise the
failure.
1. Is the per-session token actually on disk?
ls -la .sdd/runtime/agent_tokens/
Expected - at least one manager-*.token, mode 0600, non-zero size:
-rw------- 1 you staff 237 May 16 14:32 manager-e0838d38.token
If empty or absent: the spawner failed to mint a token. Check
agent.log for Zero-trust token issuance failed for manager-….
2. Does the server accept the token?
TOKEN=$(cat .sdd/runtime/agent_tokens/manager-*.token | head -n 1)
curl -s -o /dev/null -w "HTTP %{http_code}\n" \
-H "Authorization: Bearer $TOKEN" \
http://127.0.0.1:8052/tasks
Expected: HTTP 200. Anything else (especially HTTP 401) means the token
is rejected - re-check the JWT secret hasn't been rotated underneath.
3. Is auth even enabled?
curl -s http://127.0.0.1:8052/auth/providers | jq
Example healthy response:
{
"oidc_enabled": false,
"saml_enabled": false,
"legacy_token_enabled": true,
"device_flow_enabled": false
}
If legacy_token_enabled: false AND no SSO providers AND
BERNSTEIN_AUTH_DISABLED is unset - the server boot did not pick up any
auth backend. Check BERNSTEIN_AUTH_TOKEN is exported in the shell that
launched bernstein run.
4. Did the manager prompt receive the token path?
grep -A6 "Task Server Authentication" .sdd/runtime/hooks/manager-*.jsonl | head -20
Expected - a block like:
## Task Server Authentication
Your agent token is stored at (do NOT print or log its contents):
.sdd/runtime/agent_tokens/manager-e0838d38.token
Include this header in **all** task server requests:
-H "Authorization: Bearer $(cat .sdd/runtime/agent_tokens/manager-e0838d38.token)"
If this block is missing the manager never learned the token path and will spend its tokens probing the OpenAPI spec instead of POSTing tasks.
5. Did the manager actually try to authenticate?
grep -E "POST /tasks|401" .sdd/runtime/server.log | tail -20
A healthy run shows POST /tasks 200. A #1261-style failure shows either
no POST /tasks at all, or POST /tasks 401.
6. What does the manager subprocess have in its environment?
PID=$(cat .sdd/runtime/pids/manager-*.pid 2>/dev/null | head -n 1)
ps -p "$PID" -o pid,command
# On Linux:
tr '\0' '\n' < /proc/"$PID"/environ | grep -E "BERNSTEIN_"
# On macOS (requires sudo): ps eww -p "$PID"
Expected for legacy-bearer mode: BERNSTEIN_AUTH_TOKEN=… is present.
Expected for per-session JWT mode: BERNSTEIN_AUTH_TOKEN may be absent -
the agent is meant to read the .token file path from its prompt.
Related issues
- #1261 - Manager agent cannot authenticate to task server to spawn
child workers (multi-task plans hang). External POC evaluator reported
the manager spent its turn budget probing
/tmp/openapi.jsonandfind … -name "*.token"instead of constructing an authenticated request. This page exists because that issue explicitly asked the auth mechanism be exposed more prominently. The underlying bug (the internalManagerAgent.planHTTP client insrc/bernstein/core/orchestration/manager.pydoes not yet attach a Bearer header when posting subtasks) is tracked separately.
Related pages
- Security hardening - production lockdown checklist.
- Credential scoping (default-on) - per-agent credential allowlist, complements the env-inheritance rules used here.
- Capability matrix - which roles hold which permissions in the RBAC model the auth middleware enforces.