offipy HTTP Protocol (P2-8)
offipy drives real Office through a local HTTP server (default 127.0.0.1:8890). This document defines the request/response contract, authentication, and version handshake of the offipy-http/v1 protocol. The implementation lives in src/offipy/server.py (server side) and src/offipy/client.py (client side).
Version Handshake
- Protocol name constant:
offipy-http/v1(server._PROTOCOL/client.PROTOCOL). - Request side:
/calland/shutdownmust carry the request headerX-Offipy-Protocol: offipy-http/v1. - Missing or mismatched → 400 with
error_code: "protocol"(mapped back toProtocolError). - This is a request-side handshake: when an old client connects to a new server (or vice versa), protocol negotiation fails rather than silently mismatching.
- Server side: the
result.protocolof the/statusresponse reports the server's protocol version, letting the client probe it (client._probeuses this to decide whether the server is "ours").
Endpoints
| Endpoint | Method | Auth | Description |
|---|---|---|---|
/ping |
GET | No | Health check; returns {"ok": true, "result": "pong"} and exposes no data |
/status |
GET | Yes | Read-only snapshot of process / protocol / session identifier / target identity |
/call |
POST | Yes | Executes one RPC operation (COM ops are enqueued on the worker queue and run serially) |
/shutdown |
POST | Yes | Authenticated graceful shutdown (after replying, shutdown is triggered by a separate thread; no reliance on force-killing by pid) |
All other paths return 404.
Authentication
All protected endpoints require Authorization: Bearer <token>. Token sources: the OFFIPY_SERVER_TOKEN environment variable takes precedence; otherwise a persistent user_data_dir()/token file is used. A validation failure yields only 401 and does not kill the server (a token mismatch is a configuration problem, not a process problem).
Listener Security Boundary
By default the server accepts only the exact loopback addresses 127.0.0.1 and
::1. An empty host means INADDR_ANY (all interfaces) in socketserver, and
localhost may resolve to a non-loopback address through hosts/DNS, so neither is
accepted as a default loopback binding. allow_remote=True / --unsafe-allow-remote
is retained only for explicit test or trusted-LAN compatibility scenarios; remote
listening has no TLS, so the token and document contents may be sniffed and it must
not be enabled in production.
/call Request
POST /call
Authorization: Bearer <token>
Content-Type: application/json
X-Offipy-Protocol: offipy-http/v1
{"app": "excel", "op": "set_cell",
"args": {"sheet": 1, "cell": "A1", "value": 100, "doc_id": "book<hex>"},
"request_id": "2f9a5c5e-0000-4000-8000-000000000001"}
| Field | Description |
|---|---|
app |
excel / word / ppt (unknown → 400 invalid_argument) |
op |
Must be an op in the schema allowlist (server._OPS is derived from schema.py; unknown → 400 invalid_argument) |
args |
Keyword arguments passed through to the App method (including the doc_id target); path-like arguments (path/out, etc.) are absolutized by the client against the caller's CWD. Destructive ops can also carry the transport parameters expected_target / follow_active (see below) |
request_id |
Optional; the caller-held idempotency identifier (uuid4 string). When present, the server dedupes/merges/replays the cache by request_id + payload hash (see "Idempotency"); when absent, the non-idempotent legacy path is used |
Boundary checks (all fail fast in the handler thread, never touching COM):
- Content-Type must be application/json, otherwise 415.
- The request body is capped at 16MB; exceeding it returns 413.
- A negative Content-Length → 400 (read(negative) would swallow the connection buffer).
- args that is not an object (list/str) → 400 invalid_argument.
Transport parameters (target binding)
expected_target / follow_active are transport parameters: passed through by the client, not part of
the App method signature; the server dispatch pops them, resolves a target, and injects a doc_id.
expected_target is meaningful only for destructive/export ops (schema.supports_expected_target);
follow_active is meaningful for destructive ops and for read-only ops that declare
accepts_follow_active (get_cell / read_range / read_doc_text / read_slide_texts /
read_slide_summary) — #25 aligns read-only ops with destructive semantics for write-then-read flows:
follow_active(bool, optional, defaultfalse): explicitly declares "follow the currently active document" — the server resolves the current active target in real time and injects its doc_id; with no active target →TargetNotFoundError(it never silently lands on any document).expected_target(object, optional): target binding —{"doc_id"}/{"name"}/{"path"}, combinable, resolve-once: the server resolves the target doc_id from the binding keys, validates, then injects it into the method call; an empty object / unknown keys → 400invalid_argument; binding failure →target_not_found.
Precedence: expected_target > follow_active > explicit doc_id (the first two overwrite any doc_id already
in args). In normal use, provide just one. Constraints:
expected_targeton a non-destructive op → 400invalid_argument(strictly rejected, not silently ignored).follow_activeon a read-only op that does not declareaccepts_follow_activeis silently ignored (popped); on one that declares it, the active document is resolved in real time and its doc_id injected (no active target →TargetNotFoundError).quitaccepts neither (it has no doc_id target).
Idempotency (request_id, P0-2 Plan A)
When request_id is present, the server enables the idempotency path within the lifetime of the
current server process — "timeout retries never re-execute":
- Payload hash binding:
sha256(json.dumps({"app","op","args"}, sort_keys=True)). The same request_id with a different payload (argument drift) → 400invalid_argument(caller bug, not a silent return of a stale result). - In-flight merge: concurrent/retried calls with the same request_id have non-owner threads wait on the
owner (via
entry.event.wait); nothing is re-enqueued or re-executed. - Result cache: once the owner finishes, the result is cached (LRU cap 512, TTL 600s, on the same scale as
the timeout window); retries with the same request_id replay the cached response with
cached: true. Eviction only removes non-inflight entries. - Timeout: the owner waiting past
_CALL_TIMEOUT→ 504, but the entry stays inflight — same-id retries still merge and never double-write. A full COM queue → 503 and the entry is rolled back (same-id retries rebuild it rather than merging into a never-completing deadlock). - Calls without request_id use the legacy path: no cache, no dedupe, no merge.
- Lifetime boundary:
_REQUEST_ID_CACHElives only in the current server process. After a server crash, restart, or cache eviction, the outcome may be unknown. Do not blindly replay a destructive operation; read the target document state before deciding whether to retry.
Client side: client.request/call auto-generates a uuid4 by default and carries it on the request; the
response echoes the request_id for the caller to verify. Timeout responses also echo it, and
RemoteCallError.request_id exposes the ID (including an auto-generated one). On timeout, retry with the
same request_id.
/call Response
Responses follow the OperationResult contract (src/offipy/result.py). This is an HTTP-only contract — the Python API and MCP each have their own return shapes (Python returns the method's raw value; MCP returns the data payload). For a comparison across the three entry points, see docs/api.en.md.
Success (HTTP 200):
{"ok": true, "operation": "excel.set_cell", "resource_id": "excel:book:book2f9a5c5e1a2b3c4d",
"message": "ok", "data": null, "result": null,
"request_id": "2f9a5c5e-0000-4000-8000-000000000001"}
resource_id:"<app>:<kind>:<doc_id>"identifies the document the operation acted on (the raw COM object is never exposed;resource_idis used instead); doc_id is a stable identifier within the session, not the user-editable name; formatbook<hex>/doc<hex>/pres<hex>— high-entropy and opaque (secrets.token_hex(8)), not sequentially enumerable;nullwhen there is no target.data: the operation result (the raw value for read ops;nullfor void ops).result: a compatibility alias fordata(gradual migration for older clients).request_id: the idempotency echo — returned verbatim when the request carried one, for the caller to verify / retry.
When an idempotent call with a request_id hits the cache, the response additionally carries "cached": true
(same request_id + same payload retries don't re-execute; the original response is replayed).
Failure (HTTP status code is mapped from error_code, see table; codes not listed fall back to 500):
{"ok": false, "operation": "excel.set_cell", "resource_id": null,
"error": "TargetNotFoundError: 没有打开的工作簿", "error_code": "target_not_found",
"trace": ["TargetNotFoundError: 没有打开的工作簿"],
"request_id": "2f9a5c5e-0000-4000-8000-000000000001"}
error_codemaps one-to-one to the domain exceptions (seeexceptions.py):
| error_code | Domain exception | HTTP status |
|---|---|---|
invalid_argument |
InvalidArgumentError |
400 |
protocol |
ProtocolError |
400 |
target_not_found |
TargetNotFoundError |
404 |
file_conflict |
FileConflictError |
409 |
com_operation |
ComOperationError (preserves the hresult field) |
502 |
internal |
No mapping (unknown / ordinary exceptions) | 500 |
- The client maps the response back to the corresponding domain exception from
error_codeacross all three entry points (Python / RPC / MCP). Whatever the HTTP status code is, the semantics are always carried by the body'serror_code; the status code only lets monitoring / proxy layers classify failures semantically. trace: a redacted exception-chain message list (["Type: message", ...]) — for locating the exception type and chain only; it contains no file paths / line numbers / source snippets (server information-leak protection).-
The message content of both
errorandtraceis redacted too (#67): absolute paths (Windows/POSIX/UNC/file://) anddoc_idvalues inside exception messages are replaced with[REDACTED]; raw paths are never passed through. Any form is covered: Windows paths containing spaces (C:\Users\John Doe\...), any POSIX root (/data,/workspace, etc. — no hardcoded root whitelist), andfile://URLs on any platform are redacted by absolute-path shape (#75/#78). Redaction cuts the path at business-text boundaries (CJK / quote / doc_id / string end) without swallowing trailing text;../and./relative paths are not mis-redacted (#79). -
The client maps a response back to the corresponding domain exception via
error_code, keeping the three entry points (Python/RPC/MCP) in sync.
Additional Fields
- warning: when an op is marked
deprecatedin the schema, both success and failure responses carry awarningfield (seedocs/deprecation.en.md). - destructive confirmation: destructive ops carrying the
overwriteparameter get unified overwrite protection viapaths.ensure_writable— if the target file already exists andoverwrite=false, →FileConflictError(error_code: "file_conflict").expected_targetprovides target binding for destructive ops: resolve-once — the three keys{doc_id}/{name}/{path}(combinable) resolve the target viaget_target(doc_id=...); after validating name/path, the resolveddoc_idis injected into the method call (eliminating "validate A, execute B"); an empty object or one containing unknown keys is rejected outright asinvalid_argument, and a binding failure istarget_not_found(seeSECURITY.en.md).
/status Response
{"ok": true, "result": {
"version": "0.10.2",
"protocol": "offipy-http/v1",
"session_id": "<uuid4>",
"pid": 28776,
"python": "3.12.10",
"started_at": 1785858307.49,
"targets": {"excel": null, "word": null, "ppt": {"app": "ppt", "doc_id": "pres2f9a5c5e1a2b3c4d", "name": "...", "path": "..."}}
}}
session_id: the current server session identifier (uuid4), written into every operation log entry (oplog.jsonl), for distinguishing / tracing across instances.targets: a read-only identity snapshot of each App's currently active document, cached by the worker thread; the handler never touches COM, soGET /statusnever launches Office as a side effect of probing.
Operation Log (P2-3)
After every /call, the server appends a JSONL entry to user_data_dir()/oplog.jsonl:
{"ts": "...", "session_id": "<uuid4>", "app": "excel", "op": "set_cell",
"ok": true, "error_code": null, "duration_ms": 12, "resource_id": "excel:book:book2f9a5c5e1a2b3c4d"}
args are never written to disk (sanitized/redacted). The log rotates at ~5MB (keeping a .1 backup). Reading: offipy log / offipy log --tail N.