跳转至

中文

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: /call and /shutdown must carry the request header X-Offipy-Protocol: offipy-http/v1.
  • Missing or mismatched → 400 with error_code: "protocol" (mapped back to ProtocolError).
  • 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.protocol of the /status response reports the server's protocol version, letting the client probe it (client._probe uses 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, default false): 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 → 400 invalid_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_target on a non-destructive op → 400 invalid_argument (strictly rejected, not silently ignored).
  • follow_active on a read-only op that does not declare accepts_follow_active is 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).
  • quit accepts 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) → 400 invalid_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_CACHE lives 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_id is used instead); doc_id is a stable identifier within the session, not the user-editable name; format book<hex> / doc<hex> / pres<hex> — high-entropy and opaque (secrets.token_hex(8)), not sequentially enumerable; null when there is no target.
  • data: the operation result (the raw value for read ops; null for void ops).
  • result: a compatibility alias for data (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_code maps one-to-one to the domain exceptions (see exceptions.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_code across all three entry points (Python / RPC / MCP). Whatever the HTTP status code is, the semantics are always carried by the body's error_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 error and trace is redacted too (#67): absolute paths (Windows/POSIX/UNC/file://) and doc_id values 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), and file:// 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 deprecated in the schema, both success and failure responses carry a warning field (see docs/deprecation.en.md).
  • destructive confirmation: destructive ops carrying the overwrite parameter get unified overwrite protection via paths.ensure_writable — if the target file already exists and overwrite=false, → FileConflictError (error_code: "file_conflict"). expected_target provides target binding for destructive ops: resolve-once — the three keys {doc_id} / {name} / {path} (combinable) resolve the target via get_target(doc_id=...); after validating name/path, the resolved doc_id is injected into the method call (eliminating "validate A, execute B"); an empty object or one containing unknown keys is rejected outright as invalid_argument, and a binding failure is target_not_found (see SECURITY.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, so GET /status never 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.