Skip to content

feat: scope jobs to the token that submitted them - #83

Merged
cristiangirlea merged 2 commits into
mainfrom
feat/per-client-tokens
Oct 21, 2026
Merged

cristiangirlea merged 2 commits into
mainfrom
feat/per-client-tokens

Conversation

@cristiangirlea

Copy link
Copy Markdown
Owner

Fixes #69, as decided in its comment of 2026-10-21.

What

Per-client tokens on the worker, with every job scoped to the token that submitted it.

  • Token sources, exactly one at start: BRIDGE_TOKEN (the single token, named default, as before), BRIDGE_TOKENS=name=token,..., or BRIDGE_TOKENS_FILE (one name=token per line, blank and # lines ignored, nothing trimmed, a regular file of at most 65536 bytes with mode 0600 or stricter, read once). Names are 1 to 64 characters of [A-Za-z0-9_.-], tokens keep today's rule, at most 64 of them, no duplicate name or value. Any violation, an unreadable or group-readable file, both lists together, or BRIDGE_TOKEN beside either, stops the worker with a message naming the variable and never a token. BRIDGE_TOKEN and BRIDGE_TOKENS leave the environment before the first child starts; the file's content never enters it. An empty variable counts as unset, so compose's ${X:-} forms keep working.
  • Authentication compares the bearer against every configured token with hmac.compare_digest, with no early exit, and yields the token's name as the request's owner.
  • Ownership. GET /v1/jobs/{id} and POST /v1/jobs/{id}/cancel with another owner's token answer the same 404 as an unknown job, byte for byte. Idempotency keys are per owner (the unique index becomes (owner, idempotency_key)). The owner's name, never the value, is kept in jobs.sqlite; a file from before this change gains the column on open, its rows owned by default, and the old key-only index is replaced. The job snapshot is unchanged: no owner field. GET /healthz and GET /metrics answer any configured token. Capacity, concurrency and retention stay shared.
  • Metrics gain an owner label on bridge_jobs_total, bridge_jobs_submitted_total and bridge_submissions_rejected_total, with every configured name's series present at zero; the other families are unchanged.
  • The PHP client and the MCP server are unchanged in code; a single-token deployment behaves as before.

Tests

  • tests/python/test_server.py: TokenSourceTests drive main() through every source and every refusal (short or whitespace token, bad name or entry, duplicate name or value, 65 entries in the list and in the file, a 65537-byte file, a FIFO, group- or other-readable modes, a missing path, a directory, an unreadable file, a non-UTF-8 file, the three exclusivity cases, empty variables counting as unset), asserting the variable is named and no token or token prefix appears; OwnerHttpTests on a two-token server with a state directory: another token's GET and cancel equal the unknown-job 404, the owner's work, the snapshot's key set is exactly the public fields, a shared key is two jobs and each repeat returns its own, health and metrics for every token and 401 for wrong ones, the owner series at zero and moving for the right owner after a job and a 429, and after a restart the raw bytes of jobs.sqlite* hold the name but neither token while the restored job is still 404 for the other token.
  • tests/python/test_jobs.py: OwnerTests at store level, an old-schema file created in the test and migrated (checked again after a second open), the index swap, and the child-environment test extended to BRIDGE_TOKENS and the file's content with a positive control.
  • tests/php/integration.php and tests/integration.py against the new two-token compose peer tokens-worker (two fixed test-only literals, independent of the run's token): 404 for the other token's read and cancel, success for the owner, two jobs for a shared key, 401 for a wrong token; the Python side also checks the owner counters moved.

Files

  • worker/ai_bridge/server.py (token resolution and validation, _authorized returning the owner, the owner passed to the store), worker/ai_bridge/jobs.py (Settings.owners, _owner, the column and its migration, (owner, key) scoping, _owned, owner-keyed counters), worker/ai_bridge/metrics.py (the label), worker/ai_bridge/tasks.py (the test-only test.inspect task reports BRIDGE_TOKENS and a needle in the child's environment).
  • tests/python/test_server.py, tests/python/test_jobs.py, tests/php/integration.php, tests/integration.py.
  • docker/compose.yaml (tokens-worker, the two clients' variables), .github/workflows/tests.yml, AGENTS.md, .claude/skills/check/SKILL.md, .claude/settings.json (Bash and PowerShell twins), README.md (the test layer line and the limits), docs/testing.md (counts and the peer).
  • docs/protocol.md (Tokens section, key scope, old-file rule, the label), docs/security.md (the "every job" sentence replaced by the scope rule), docs/mcp.md (the MCP server's own token name on a shared worker), CHANGELOG.md (Unreleased, Added).
  • Two stale facts corrected in passing, as the house rules allow: docs/integration.md said a token authorizes every job; .claude/rules/php-client.md and .claude/rules/worker.md list the peers and the limits.

Checks

The full command block of AGENTS.md, with the peer added, run as one command on this head (d73d7d8: the slice commit 6a2a0b9 merged with main at 04cb9d2) in a worktree, compose's default image PHP 8.5:

  • --profile test config: ok
  • python-tests: Ran 240 tests, OK
  • php-tests: PASS: 148 PHP contract checks
  • php -l over src, examples, tests/php: no syntax errors (25 files)
  • up -d worker frankenphp fault-worker hostile tokens-worker, php-integration: PASS: 192 total PHP checks including real HTTP failure cases
  • integration: PASS: 12 concurrent isolated jobs through a reused FrankenPHP worker; top_k narrowing; embedding; redaction; invalid and missing-job cases; worker metrics; jobs scoped to their token
  • mcp-fetcher, mcp-tests: Ran 125 tests, OK
  • composer-install: ok (archive mode left to CI)
  • php-fetcher, php-frameworks-fetcher, php-analysis: [OK] No errors
  • php-psr18-tests: PASS: 178 PHP contract checks
  • laravel-tests: Laravel 13.38.0: 94 checks passed; symfony-tests: Symfony 8.0.15: 96 checks passed
  • tls-check: PASS: 7 TLS checks with the example CA; PASS: 3 TLS checks with no CA configured

PHP 8.2, 8.3 and 8.4 are left to CI; model-smoke.yml runs on this PR since worker files are on its paths: list.

Review

Two independent reviews of the diff, both read-only. Codex (another model family, on the inlined diff because its shell was unavailable on this machine) found that the compose peer built its token list from the run's BRIDGE_TOKEN, which a legal token containing a comma would break, and a two-line comment; both fixed (fixed test-only literals for the peer, one-line comment). The delivery reviewer found four defects, all fixed (a collapsed line in a test, the protocol's wording on comment lines in the token file, the worker rule not naming the new limits, a migration re-open test that asserted nothing) and five missing tests, all added (file over the size cap, a FIFO, more than 64 entries in the file form, empty variables as unset, the re-open assertions). Security: nothing open in either review; token values are checked against every response, the metrics body, the SQLite bytes, the child environment and every start-up message.

Considered, not done: Docker secrets mount as 0444 by default and would be refused as group-readable; an operator using them needs mode: 0400 on the secret. Not verified here: PHP 8.2 to 8.4 (CI), two real hosts.

The worker takes its tokens from BRIDGE_TOKEN (named default), BRIDGE_TOKENS
or BRIDGE_TOKENS_FILE, at most 64, each checked at start with a message that
names the variable and never a token, and both token variables leave the
environment before the first child starts. A job belongs to the token that
submitted it: another token reads and cancels it as 404, exactly like an
unknown job, and idempotency keys are per token. The owner name is kept in
jobs.sqlite (an older file gains the column with its rows owned by default)
and is not in the job snapshot; the three metrics counters gain an owner
label. A two-token compose peer carries the PHP and Python integration
checks; the PHP client and the MCP server are unchanged.
@cristiangirlea
cristiangirlea merged commit e77310f into main Oct 21, 2026
7 checks passed
@cristiangirlea
cristiangirlea deleted the feat/per-client-tokens branch October 21, 2026 19:29
@cristiangirlea cristiangirlea mentioned this pull request Oct 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: per-client tokens with job ownership scoped to the submitting token (design note; needs a decision)

1 participant