Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/rules/php-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ paths:
job's pinned image from `.github/workflows/tests.yml` and rerun the same compose commands.
- Tests are `check()` and `rejects()` calls: `tests/php/run.php` runs offline (contract checks and one
transport error), and `tests/php/integration.php` loads it and then drives the client against the real
`worker`, `fault-worker` and `hostile` peers over HTTP. The path through FrankenPHP is
`worker`, `fault-worker`, `tokens-worker` (two tokens) and `hostile` peers over HTTP. The path through FrankenPHP is
`tests/integration.py`. A new behaviour gets a success case, invalid-input cases and, where the worker
is involved, an integration case. Syntax is checked with `php -l` over `src`, `examples` and `tests/php`.
- The client never retries a `POST` on its own (a transport failure leaves the submission uncertain), and
Expand Down
7 changes: 4 additions & 3 deletions .claude/rules/worker.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@ paths:
- `MODEL_NAMES` is what every result reports as `model` and what the MCP server's `BACKENDS` table must
equal (`tests/mcp/test_models.py`); change both together.
- Limits live in three places: concurrency, capacity and retention are `Settings` in `jobs.py` (defaults
from the environment in `server.main`), the body size is `MAX_BODY` in `server.py`, and the field,
count and character limits are the `MAX_*` constants in `tasks.py`; the deadline range is checked in
`JobStore.submit`. `docs/protocol.md` documents them; change the doc with the limit.
from the environment in `server.main`), the body size and the token limits are `MAX_BODY`, `MAX_TOKENS`,
`MAX_TOKENS_FILE` and `TOKEN_NAME` in `server.py`, and the field, count and character limits are the
`MAX_*` constants in `tasks.py`; the deadline range is checked in `JobStore.submit`. `docs/protocol.md`
documents them; change the doc with the limit.
- Tests run offline with `python -m unittest discover -s tests/python` inside `python-tests`
(`network_mode: none`). A change to the model path belongs on `model-smoke.yml`'s `paths:` list, so
the real-model smoke tests run for it; keep that list current when a file moves.
4 changes: 2 additions & 2 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"Bash(docker compose -f docker/compose.yaml run --rm --no-deps laravel-tests)",
"Bash(docker compose -f docker/compose.yaml run --rm --no-deps symfony-tests)",
"Bash(docker compose -f docker/compose.yaml run --rm --no-deps tls-check)",
"Bash(docker compose -f docker/compose.yaml up -d worker frankenphp fault-worker hostile)",
"Bash(docker compose -f docker/compose.yaml up -d worker frankenphp fault-worker hostile tokens-worker)",
"Bash(docker compose -f docker/compose.yaml up -d worker frankenphp)",
"Bash(docker compose -f docker/compose.yaml up -d worker)",
"Bash(docker compose -f docker/compose.yaml up -d tls-proxy)",
Expand Down Expand Up @@ -83,7 +83,7 @@
"PowerShell(docker compose -f docker/compose.yaml run --rm --no-deps laravel-tests)",
"PowerShell(docker compose -f docker/compose.yaml run --rm --no-deps symfony-tests)",
"PowerShell(docker compose -f docker/compose.yaml run --rm --no-deps tls-check)",
"PowerShell(docker compose -f docker/compose.yaml up -d worker frankenphp fault-worker hostile)",
"PowerShell(docker compose -f docker/compose.yaml up -d worker frankenphp fault-worker hostile tokens-worker)",
"PowerShell(docker compose -f docker/compose.yaml up -d worker frankenphp)",
"PowerShell(docker compose -f docker/compose.yaml up -d worker)",
"PowerShell(docker compose -f docker/compose.yaml up -d tls-proxy)",
Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/check/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ docker compose -f docker/compose.yaml --profile test config
docker compose -f docker/compose.yaml run --rm --no-deps python-tests
docker compose -f docker/compose.yaml run --rm --no-deps php-tests
docker compose -f docker/compose.yaml run --rm --no-deps php-tests sh -ec 'find src examples tests/php -name "*.php" -print0 | xargs -0 -n1 php -l'
docker compose -f docker/compose.yaml up -d worker frankenphp fault-worker hostile
docker compose -f docker/compose.yaml up -d worker frankenphp fault-worker hostile tokens-worker
docker compose -f docker/compose.yaml run --rm --no-deps php-integration
docker compose -f docker/compose.yaml run --rm --no-deps integration
docker compose -f docker/compose.yaml --profile test down
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ jobs:
docker compose -f docker/compose.yaml run --rm --no-deps php-tests sh -ec 'find src examples tests/php -name "*.php" -print0 | xargs -0 -n1 php -l'
- name: HTTP and FrankenPHP integration
run: |
docker compose -f docker/compose.yaml up -d worker frankenphp fault-worker hostile
docker compose -f docker/compose.yaml up -d worker frankenphp fault-worker hostile tokens-worker
docker compose -f docker/compose.yaml run --rm --no-deps php-integration
docker compose -f docker/compose.yaml run --rm --no-deps integration
- name: Service logs on failure
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ docker compose -f docker/compose.yaml --profile test config
docker compose -f docker/compose.yaml run --rm --no-deps python-tests
docker compose -f docker/compose.yaml run --rm --no-deps php-tests
docker compose -f docker/compose.yaml run --rm --no-deps php-tests sh -ec 'find src examples tests/php -name "*.php" -print0 | xargs -0 -n1 php -l'
docker compose -f docker/compose.yaml up -d worker frankenphp fault-worker hostile
docker compose -f docker/compose.yaml up -d worker frankenphp fault-worker hostile tokens-worker
docker compose -f docker/compose.yaml run --rm --no-deps php-integration
docker compose -f docker/compose.yaml run --rm --no-deps integration
docker compose -f docker/compose.yaml --profile test down
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ All notable changes to this project are documented in this file. The format foll

### Added

- Per-client tokens on the worker: `BRIDGE_TOKENS=name=token,...` or `BRIDGE_TOKENS_FILE` (one `name=token` per line, mode 0600 or stricter), at most 64, as an alternative to `BRIDGE_TOKEN`, which is the single token named `default`. A bad token, name, file or combination of the three stops the worker at start with a message naming the variable and never a token.
- A job belongs to the token that submitted it: another token's `GET` or `cancel` of it is 404, exactly as for an unknown job, and idempotency keys are per token. The owner's name is kept in `jobs.sqlite` (a file from an earlier version gains the column, its rows owned by `default`) and is not in the job's snapshot. A worker with only `BRIDGE_TOKEN` behaves as before.
- An `owner` label, the token's name, on `bridge_jobs_total`, `bridge_jobs_submitted_total` and `bridge_submissions_rejected_total`, with every configured name's series present at zero.

- `PhpAiBridge\Laravel\BridgeServiceProvider`, registered by Laravel's package discovery, binds one `Client` built from a publishable `config/ai_bridge.php` (`BRIDGE_URL`, `BRIDGE_TOKEN`, `BRIDGE_REQUEST_TIMEOUT_MS`; `vendor:publish --tag=ai-bridge-config`), so configuration caching works.
- `PhpAiBridge\Symfony\PhpAiBridgeBundle` registers `Client` as an autowirable service from a `php_ai_bridge` configuration whose `url`, `token` and `request_timeout_ms` default to the same environment variables.
- Both fail on a missing or invalid setting with a message that names it and never quotes the token. `laravel/framework` and `symfony/http-kernel` are suggested, not required: the library's requirements are unchanged.
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ docker compose -f docker/compose.yaml run --rm --no-deps python-tests
docker compose -f docker/compose.yaml run --rm --no-deps php-tests

# Internal-only HTTP peers, including intentionally broken peers:
docker compose -f docker/compose.yaml up -d worker frankenphp fault-worker hostile
docker compose -f docker/compose.yaml up -d worker frankenphp fault-worker hostile tokens-worker
docker compose -f docker/compose.yaml run --rm --no-deps php-integration
docker compose -f docker/compose.yaml run --rm --no-deps integration

Expand Down Expand Up @@ -141,7 +141,7 @@ CI runs deterministic tests for pushes to main and for pull requests across PHP
## Important limits

- **Durable job table only when `BRIDGE_STATE_DIR` is set:** the worker then keeps its job table (never inputs) in SQLite, so finished results and idempotency keys survive a restart until retention ends and unfinished jobs become `failed` with `service_restarted`; unset, a restart loses everything. Completed results expire after 300 seconds by default; 404 can mean expired, lost or unknown. An optional `idempotency_key` makes resubmitting after an uncertain POST safe while the job is retained. There is still no replay of unfinished jobs, durable delivery or automatic retry.
- **Trusted services only:** one bearer token authorizes all jobs. Your application must authenticate users, associate job IDs with their owners, and authorize reads/cancellation. Random IDs are not authorization.
- **Trusted services only:** a bearer token reaches only the jobs it submitted; give each application its own (`BRIDGE_TOKENS`, [the protocol](docs/protocol.md#tokens)), since they still share one worker's capacity. Your application must authenticate users, associate job IDs with their owners, and authorize reads/cancellation. Random IDs are not authorization.
- **Private HTTP transport:** deploy behind an authenticated TLS boundary for cross-host use; the [integration guide](docs/integration.md#cross-host-use-behind-tls) has a tested example with Caddy in front of the worker. The sample Python HTTP server is not an internet-facing production server.
- **Process isolation is not a sandbox for arbitrary code:** task code is trusted and registered in source. Clients cannot upload Python, import modules or select a shell command. The container limits the entire service, not each job separately.
- **One job per warm child, no sharing across jobs:** the worker keeps `BRIDGE_CONCURRENCY` child processes alive and each keeps the models it has loaded, so only a child's first job of a kind pays for imports and model loading; a child is replaced after a cancellation, a deadline or a crash, and after `BRIDGE_WORKER_MAX_JOBS` jobs (default 1000). Within one rerank job the ONNX backend scores documents 32 pairs per forward pass; there is no batching across jobs, no sharing of a loaded model between children, and no claim of GPU support, multi-host scaling or stateful agents.
Expand Down
20 changes: 20 additions & 0 deletions docker/compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,9 @@ services:
<<: *python-env
FRANKENPHP_URL: http://frankenphp:8080
WORKER_URL: http://worker:8090
TOKENS_WORKER_URL: http://tokens-worker:8090
BRIDGE_APP_TOKEN: test-only-app-client-token-never-use-in-production
BRIDGE_OTHER_TOKEN: test-only-other-client-token-never-use-in-production
command: [python, tests/integration.py]

php-example:
Expand All @@ -268,6 +271,18 @@ services:
BRIDGE_CONCURRENCY: "1"
command: [python, -m, ai_bridge.server, --host, 0.0.0.0]

# Two clients of one worker, each with its own test-only literal token, independent of the run's BRIDGE_TOKEN.
# The environment is written out rather than merged, since BRIDGE_TOKEN beside BRIDGE_TOKENS is a start error.
tokens-worker:
<<: *python
profiles: [test]
networks: [isolated]
environment:
PYTHONDONTWRITEBYTECODE: "1"
PYTHONPATH: /work/worker
BRIDGE_TOKENS: app=test-only-app-client-token-never-use-in-production,other=test-only-other-client-token-never-use-in-production
command: [python, -m, ai_bridge.server, --host, 0.0.0.0]

hostile:
<<: *python
profiles: [test]
Expand All @@ -278,6 +293,11 @@ services:
<<: *php
profiles: [test]
networks: [isolated]
environment:
<<: *php-env
BRIDGE_TOKENS_URL: http://tokens-worker:8090
BRIDGE_APP_TOKEN: test-only-app-client-token-never-use-in-production
BRIDGE_OTHER_TOKEN: test-only-other-client-token-never-use-in-production
command: [php, tests/php/integration.php]

fetcher:
Expand Down
2 changes: 1 addition & 1 deletion docs/integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ Implement the following in your application, for either framework:
4. On a later status request, verify ownership **before** calling `get()`. Handle `queued`/`running`, `succeeded`, `failed`, `timed_out` and `cancelled`. For success, validate using `RerankResult::fromJob()` with the original document count; indexes refer to the original order.
5. Authorize cancellation the same way before calling `cancel()`. A job may complete before cancellation arrives; the returned snapshot is authoritative.

The service token authorizes every job. Do not send it to a browser or rely on random job IDs for authorization. Do not blindly publish the example routes as your production API. Capture required results before retention expires. Without `BRIDGE_STATE_DIR` the worker is in-memory and a restart loses all jobs and results; with it, finished results survive a restart until retention ends, and jobs that were unfinished become `failed` with `service_restarted`. A 404 cannot distinguish expiry, restart loss and an unknown job.
A service token reaches every job submitted with it, whichever of your users it was for; give each application its own ([tokens](protocol.md#tokens)). Do not send it to a browser or rely on random job IDs for authorization. Do not blindly publish the example routes as your production API. Capture required results before retention expires. Without `BRIDGE_STATE_DIR` the worker is in-memory and a restart loses all jobs and results; with it, finished results survive a restart until retention ends, and jobs that were unfinished become `failed` with `service_restarted`. A 404 cannot distinguish expiry, restart loss and an unknown job.

When a submission's outcome is uncertain (`transport_error`, or the PHP process died before it stored the job ID), resubmit with the same idempotency key instead of a fresh request: the worker returns the job the first request created, in whatever state it is now, or creates it if the first request never arrived. Pick the key per unit of work (for example a hash of your own record ID and the task) and store it before the first POST:

Expand Down
6 changes: 3 additions & 3 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ On Linux the fixture directory must be writable by container UID 65532 for the b

| Variable | Meaning |
| --- | --- |
| `BRIDGE_TOKEN` | The worker's service token, required. Read from the environment only; never returned. |
| `BRIDGE_TOKEN` | The worker's service token, required. Read from the environment only; never returned. On a worker shared with an application, this is the MCP server's own entry in the worker's token list (`BRIDGE_TOKENS` or `BRIDGE_TOKENS_FILE`), named for example `mcp`, so an injected agent sees only its own jobs; compose's `mcp-worker` is dedicated and keeps a single token. |
| `BRIDGE_URL` | The worker origin, default `http://127.0.0.1:8090`. |
| `BRIDGE_MCP_ROOT` | The one directory whose files `documents_path` may name. Unset means paths are refused. |
| `BRIDGE_MCP_TIMEOUT_MS` | Per-call wait before cancelling, 100–300000, default 60000. |
Expand Down Expand Up @@ -127,10 +127,10 @@ docker compose -f docker/compose.yaml run --rm -i -T mcp-model

## Trust model

Nothing about the worker's trust model changes: a private network, one bearer token, never internet-facing. Two things are new and must be understood:
Nothing about the worker's trust model changes: a private network, a bearer token per client, never internet-facing. Two things are new and must be understood:

- **Over stdio there is no authentication.** The host process launches the server and inherits its trust; the server holds `BRIDGE_TOKEN` from its environment. Running this server over HTTP or SSE would be a new, unsolved authorisation surface and is out of scope; the bearer token does not cover it.
- **An agent can now submit jobs, and an agent can be prompt-injected.** It cannot read the token, but it can fill the worker's capacity or submit junk. Point the MCP server at a **dedicated worker**, never the one serving a production PHP application; the compose `mcp` service does this with `mcp-worker`. Capacity, concurrency and retention are per worker.
- **An agent can now submit jobs, and an agent can be prompt-injected.** It cannot read the token, but it can fill the worker's capacity or submit junk. Point the MCP server at a **dedicated worker**, never the one serving a production PHP application; the compose `mcp` service does this with `mcp-worker`. Capacity, concurrency and retention are per worker. Where a worker must be shared, give the MCP server its own token (`mcp=...` in the worker's `BRIDGE_TOKENS`): the agent then cannot read or cancel the application's jobs, which answer it 404, though it can still fill the shared capacity.

Paths are confined to `BRIDGE_MCP_ROOT`, including an index's vectors file after symlinks are resolved; the server never writes files, and only the operator-run `mcp-index` builder writes, into the directory it is told to. Results contain indexes, scores, snippets of the agent's own files, masked text and spans; the original documents are not echoed by the worker.

Expand Down
Loading
Loading