@arnilo/prism 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +12 -11
  3. package/dist/agent-approval.d.ts +4 -0
  4. package/dist/agent-approval.js +5 -1
  5. package/dist/agent-run-lifecycle.js +29 -5
  6. package/dist/agent-run-state.d.ts +13 -1
  7. package/dist/agent-run-state.js +12 -2
  8. package/dist/agent-session/helpers.js +6 -1
  9. package/dist/agent-session/session/assemble.js +126 -5
  10. package/dist/agent-session/session/persist.d.ts +16 -0
  11. package/dist/agent-session/session/persist.js +58 -2
  12. package/dist/agent-session/session/provider-round.d.ts +3 -3
  13. package/dist/agent-session/session/provider-round.js +12 -6
  14. package/dist/agent-session/session/tool-round.js +4 -1
  15. package/dist/agent-session/session/types.d.ts +12 -1
  16. package/dist/agent-session/session.d.ts +2 -0
  17. package/dist/agent-session/session.js +2 -0
  18. package/dist/checkpoints.js +7 -11
  19. package/dist/contracts-core/content.d.ts +5 -0
  20. package/dist/contracts-core/loop.d.ts +42 -0
  21. package/dist/contracts-core/run-limits.d.ts +2 -0
  22. package/dist/contracts-protocol.d.ts +15 -3
  23. package/dist/contracts-run-state.d.ts +26 -5
  24. package/dist/evidence-grounding.d.ts +29 -0
  25. package/dist/evidence-grounding.js +162 -0
  26. package/dist/host-composition.d.ts +13 -0
  27. package/dist/host-composition.js +33 -2
  28. package/dist/index.d.ts +6 -2
  29. package/dist/index.js +3 -1
  30. package/dist/provider-events.d.ts +3 -1
  31. package/dist/provider-events.js +2 -2
  32. package/dist/providers/transport.d.ts +3 -1
  33. package/dist/providers/transport.js +36 -0
  34. package/dist/redaction.js +18 -2
  35. package/dist/run-bundle.d.ts +89 -0
  36. package/dist/run-bundle.js +149 -0
  37. package/dist/testing/state-concurrency-conformance.js +5 -12
  38. package/docs/ag-ui.md +5 -0
  39. package/docs/agent-loops.md +33 -0
  40. package/docs/agent-session-runtime.md +4 -3
  41. package/docs/coding-security.md +1 -0
  42. package/docs/coding-tools.md +0 -1
  43. package/docs/compaction-observational-memory.md +1 -1
  44. package/docs/connected-apps.md +116 -0
  45. package/docs/context-and-skills.md +13 -0
  46. package/docs/core.md +1 -1
  47. package/docs/diagrams.md +6 -6
  48. package/docs/document-reader.md +9 -9
  49. package/docs/documents.md +32 -11
  50. package/docs/durable-runs.md +87 -0
  51. package/docs/enterprise-postgres-state.md +4 -0
  52. package/docs/execution-timeline.md +6 -0
  53. package/docs/guardrails.md +33 -0
  54. package/docs/history/079-messaging-primitive-review.md +391 -0
  55. package/docs/history/080-messaging-followon-primitive-review.md +234 -0
  56. package/docs/history/081-connected-apps-primitive-review.md +74 -0
  57. package/docs/history/083-prism-work-primitive-review.md +84 -0
  58. package/docs/history/084-primitive-review.md +96 -0
  59. package/docs/history/085-honesty-and-cut-primitive-review.md +91 -0
  60. package/docs/history/README.md +5 -0
  61. package/docs/history/release-handoffs.md +38 -0
  62. package/docs/host-compositions.md +8 -6
  63. package/docs/host-security.md +2 -2
  64. package/docs/index.md +47 -29
  65. package/docs/live-testing.md +5 -3
  66. package/docs/mcp-tools.md +1 -0
  67. package/docs/messaging-channel-operations.md +166 -0
  68. package/docs/messaging-channels.md +150 -0
  69. package/docs/migrate-to-0.8.md +124 -0
  70. package/docs/migration.md +30 -0
  71. package/docs/model-registry.md +12 -2
  72. package/docs/openapi-tools.md +1 -1
  73. package/docs/operations.md +1 -3
  74. package/docs/options-index.md +34 -2
  75. package/docs/peer-dependencies.md +6 -6
  76. package/docs/postgres-persistence.md +1 -1
  77. package/docs/provider-layer.md +2 -2
  78. package/docs/provider-packages.md +20 -20
  79. package/docs/providers/neuralwatt.md +5 -1
  80. package/docs/rag.md +1 -1
  81. package/docs/release-and-install.md +52 -46
  82. package/docs/run-bundle.md +92 -0
  83. package/docs/runs-and-usage.md +14 -0
  84. package/docs/server.md +2 -0
  85. package/docs/sheets.md +9 -9
  86. package/docs/signal-channel.md +112 -0
  87. package/docs/speech.md +5 -1
  88. package/docs/sqlite-persistence.md +1 -1
  89. package/docs/telegram-channel.md +157 -0
  90. package/docs/testing.md +2 -2
  91. package/docs/wiki.md +1 -1
  92. package/docs/work-artifacts-and-review.md +1 -1
  93. package/docs/work-connectors.md +9 -9
  94. package/docs/work-sandbox.md +115 -0
  95. package/docs/work-tools.md +38 -16
  96. package/package.json +5 -3
  97. package/templates/business-worker/manifest.json +2 -1
  98. package/templates/business-worker/src/agent.ts.tmpl +1 -1
  99. package/templates/business-worker/src/tests/agent.test.ts.tmpl +1 -1
package/docs/sheets.md CHANGED
@@ -1,8 +1,8 @@
1
- # Spreadsheets, CSV parsing, and typed schema inference (`@arnilo/prism-office/sheets`)
1
+ # Spreadsheets, CSV parsing, and typed schema inference (`@arnilo/prism-work/sheets`)
2
2
 
3
3
  ## What it does
4
4
 
5
- The `@arnilo/prism-office/sheets` package provides fail-closed, high-fidelity spreadsheet (XLSX) and delimiter-separated (CSV/TSV/PSV) data ingestion with automatic dialect sniffing, typed column schema inference, and **strict financial decimal safety**.
5
+ The `@arnilo/prism-work/sheets` package provides fail-closed, high-fidelity spreadsheet (XLSX) and delimiter-separated (CSV/TSV/PSV) data ingestion with automatic dialect sniffing, typed column schema inference, and **strict financial decimal safety**.
6
6
 
7
7
  ### Headline Guarantee: Strict Financial Decimal Safety
8
8
 
@@ -10,7 +10,7 @@ The `@arnilo/prism-office/sheets` package provides fail-closed, high-fidelity sp
10
10
  > **Zero Float Coercion on Decimal Paths**:
11
11
  > In financial and enterprise data processing, floating-point rounding errors (IEEE-754 `double`) silently distort monetary totals, balance ledgers, and transaction reconciliations.
12
12
  >
13
- > In `@arnilo/prism-office/sheets`:
13
+ > In `@arnilo/prism-work/sheets`:
14
14
  > - Money-like and decimal values are **never converted to JavaScript numbers (`Number()`, `parseFloat()`, or unary `+`)**.
15
15
  > - All decimal and currency values are parsed, normalized, and emitted as exact canonical decimal strings: `{ type: "decimal", value: "1234.56" }`.
16
16
  > - Currency markers (`$`, `€`, `£`, `¥`, `₹`, `CHF`, `USD`, `EUR`, etc.) and accounting parentheses `($1,234.56)` are normalized safely into canonical strings (`"-1234.56"`).
@@ -29,7 +29,7 @@ The `@arnilo/prism-office/sheets` package provides fail-closed, high-fidelity sp
29
29
 
30
30
  ## When to use it
31
31
 
32
- Use `@arnilo/prism-office/sheets` when autonomous agents, data pipelines, or enterprise workflows need to:
32
+ Use `@arnilo/prism-work/sheets` when autonomous agents, data pipelines, or enterprise workflows need to:
33
33
  1. Ingest untrusted customer XLSX or CSV files with strict, unbypassable byte, row, column, and sheet caps.
34
34
  2. Parse tabular financial records, invoices, ledgers, or pricing sheets with mathematical decimal precision guarantees.
35
35
  3. Automatically determine CSV delimiters, quotes, and headers without manual dialect configuration.
@@ -151,7 +151,7 @@ TXN-1003,"Hardware Device","£ 2,500.00",2500.00,"$ 0.00"
151
151
  ## Implementation example
152
152
 
153
153
  ```ts
154
- import { parseWorkbook, parseCsv, type SheetsTelemetry } from "@arnilo/prism-office/sheets";
154
+ import { parseWorkbook, parseCsv, type SheetsTelemetry } from "@arnilo/prism-work/sheets";
155
155
 
156
156
  // 1. Parse XLSX workbook with custom caps
157
157
  const xlsxBytes = new Uint8Array([...]); // Untrusted file bytes
@@ -194,7 +194,7 @@ console.log(`Revenue value:`, csvResult.rows[1][2]);
194
194
  ## Extension and configuration notes
195
195
 
196
196
  ### Sub-package Pinning
197
- To avoid pulling in CLI frameworks or extraneous dependencies, `@arnilo/prism-office/sheets` directly pins the exact underlying modular packages:
197
+ To avoid pulling in CLI frameworks or extraneous dependencies, `@arnilo/prism-work/sheets` directly pins the exact underlying modular packages:
198
198
  - `@office-open/xlsx@0.12.3`
199
199
  - `@office-open/xml@0.12.3`
200
200
 
@@ -211,7 +211,7 @@ const telemetry: SheetsTelemetry = {
211
211
  ```
212
212
 
213
213
  ### Self-Hosting & Operational Notes
214
- - **Zero Network & Storage Dependencies**: `@arnilo/prism-office/sheets` does not write files or contact network services. Host engines own persistence, storage buckets, and lake datasets.
214
+ - **Zero Network & Storage Dependencies**: `@arnilo/prism-work/sheets` does not write files or contact network services. Host engines own persistence, storage buckets, and lake datasets.
215
215
  - **Fail-Closed Container Gating**: Malicious or non-standard files are rejected before allocation or XML decompression occurs.
216
216
 
217
217
  ## Security and performance notes
@@ -224,6 +224,6 @@ const telemetry: SheetsTelemetry = {
224
224
 
225
225
  ## Related APIs
226
226
 
227
- - [`@arnilo/prism-office/documents`](./documents.md): Specification-compliant OpenXML document generation, parsing, patching, and preview rendering for DOCX, XLSX, and PPTX.
228
- - [`@arnilo/prism-coding-tools/document-reader`](./document-reader.md): Bounded literal text extraction from PDF and DOCX documents for coding agent tools.
227
+ - [`@arnilo/prism-work/documents`](./documents.md): Specification-compliant OpenXML document generation, parsing, patching, and preview rendering for DOCX, XLSX, and PPTX.
228
+ - [`@arnilo/prism-work/document-reader`](./document-reader.md): Bounded literal text extraction from PDF and DOCX documents for coding agent tools.
229
229
  - [`@arnilo/prism-core/governance/observability`](./observability.md): OpenTelemetry instrumentation and trace adapters.
@@ -0,0 +1,112 @@
1
+ # Signal channel (experimental)
2
+
3
+ ## What it does
4
+
5
+ `@arnilo/prism-channels/signal` is a Node-only, experimental adapter for an **externally supervised** [signal-cli v0.14.8](https://github.com/AsamK/signal-cli/tree/v0.14.8) Unix-socket daemon. It is not an official Signal bot API, does not make a bot official, and must be enabled only after an operator records the acceptable-use and GPL distribution decisions required for its deployment.
6
+
7
+ Importing or constructing the adapter does nothing: it never downloads, spawns, registers, links, relinks, configures, or health-checks `signal-cli`; opens no network connection; and has no environment or credential lookup. Host owns that lifecycle, account, state directory, encryption, backup/retention and process supervisor.
8
+
9
+ ## When to use it
10
+
11
+ Use only after an operator records acceptable-use and GPL distribution decisions for an already-operated Signal account. Not an official bot API; not for groups, topics, attachments, identity linking, bulk messaging (including `MessagingRuntime.notify`, which is unicast to one existing DM binding only), automated account creation, or streaming previews (Signal has no Bot API draft equivalent, so this adapter ignores `MessagingRuntimeOptions.onAssistantDelta`).
12
+
13
+ ```bash
14
+ npm install @arnilo/prism @arnilo/prism-channels
15
+ ```
16
+
17
+ ## Inputs / request
18
+
19
+ `SignalAdapterOptions`: host `connectionId`, absolute private `socketPath`, daemon `account`, `signalCliVersion` (must equal `SIGNAL_CLI_VERSION`), `policy` attestation, and service-owned `checkpoints`/`leases`/`cursorOwnership`. `signal-cli` is a host-operated binary, not an npm peer.
20
+
21
+ ## Host setup
22
+
23
+ Use only a private, host-selected Unix socket on the same Node/Linux host. Do not expose a TCP or HTTP bridge. Protect socket parent/state directories with service-only permissions (for example `0700`) and protect backups. Signal transport decrypts at `signal-cli`; Prism stores and model providers receive plaintext according to host policy.
24
+
25
+ The supported daemon shape is manual receive mode, selected by the host for the pinned CLI version:
26
+
27
+ ```sh
28
+ signal-cli --data-dir /private/prism-signal -a +15550001111 \
29
+ daemon --socket=/private/run/prism-signal.sock --receive-mode=manual
30
+ ```
31
+
32
+ `signal-cli` is GPLv3 and its upstream compatibility can change after Signal service changes. It requires the pinned release's documented JRE/native dependencies (v0.14.8 documents JRE 25). Account registration/relinking may disrupt existing clients; never automate it from Prism or chat input. Consult current Signal terms and operator policy before enabling automated replies; terms/policy can prohibit an intended deployment.
33
+
34
+ ## Request/response example
35
+
36
+ ```ts
37
+ import { createMessagingRuntime } from "@arnilo/prism-channels";
38
+ import { createSignalAdapter, SIGNAL_CLI_VERSION } from "@arnilo/prism-channels/signal";
39
+
40
+ const adapter = createSignalAdapter({
41
+ connectionId: "support-signal",
42
+ socketPath: "/private/run/prism-signal.sock",
43
+ account: "+15550001111", // host daemon account, never taken from a message
44
+ signalCliVersion: SIGNAL_CLI_VERSION,
45
+ policy: {
46
+ acceptableUse: "operator_approved",
47
+ gplDistribution: "operator_approved",
48
+ termsVersion: "2026-09-review", // host's recorded policy review, not a Signal approval
49
+ },
50
+ checkpoints: serviceCheckpoints,
51
+ leases: serviceLeases,
52
+ cursorOwnership: { tenantId: "signal-service" },
53
+ // multiAccount: true, // only for a daemon where account is supplied on RPC requests
54
+ });
55
+
56
+ const runtime = createMessagingRuntime({
57
+ authorize: hostAuthorizeSignalSender, // verify observed Signal UUID -> Prism identity/grant
58
+ resolveAgent: hostResolveAgent,
59
+ deliver: (reply) => adapter.send(reply),
60
+ checkpoints: userCheckpoints,
61
+ leases: userLeases,
62
+ });
63
+
64
+ await adapter.start((event) => runtime.admit(event));
65
+ // Shutdown: await adapter.stop(); await runtime.stop();
66
+ ```
67
+
68
+ ## Implementation example
69
+
70
+ [`examples/signal-agent.ts`](../examples/signal-agent.ts) contains the same host composition helper.
71
+
72
+ `start()` acquires `prism.channels.v1.signal.receiver`, probes its checkpoint writer, connects the persistent Unix socket, then calls only `subscribeReceive`. It renews its receiver lease; loss fails closed by pausing intake. `stop()` unsubscribes when possible, closes its socket, aborts reconnection and releases only its lease. A second receiver under the same service ownership cannot subscribe.
73
+
74
+ The adapter accepts manual `params.result.envelope` notifications only when they name the selected account and contain a direct text `dataMessage` from a valid Signal UUID. It ignores receipts, sync echoes, reactions, stories, edits, groups (including group topics, which have no supported thread mapping) and attachments before `admit`. UUID (not display name or phone number) becomes the external actor/conversation ID; the runtime must still authorize it. An account mismatch pauses the adapter before admission. `health()` reports independent bounded `bridge`, `subscription`, and `account` states for host monitoring without message text, phone numbers, socket path or key material.
75
+
76
+ ## Outputs / response / events
77
+
78
+ `send()` accepts only replies for its configured `connectionId` and a UUID destination fixed by the runtime's authorized binding. It calls only `send`; chat text cannot choose an RPC method, account, socket, recipient or identity action. Replies are plain text and chunked at 2,000 UTF-16 code units without splitting surrogate pairs.
79
+
80
+ A server-issued approval control is rendered as plain reply text:
81
+
82
+ ```text
83
+ Reply /approve <opaque-token> to allow once.
84
+ Reply /deny <opaque-token> to deny.
85
+ ```
86
+
87
+ Only the shared runtime validates and consumes that opaque token. Free-form text remains untrusted input; no Signal reaction, attachment, identity-trust or linking action is exposed.
88
+
89
+ Known daemon errors map to a bounded reason (`signal_rate_limited`, `signal_captcha_required`, `signal_relink_required`, `signal_identity_changed`, or `signal_rpc_error`). A changed/untrusted identity stops normal sends until an operator verifies it and current authorization is re-established. Timeout/socket loss after a write — or a failure after an earlier chunk — throws an ambiguous-delivery error; the durable reply journal records `delivery_unknown` and must not automatically resend it.
90
+
91
+ ## Extension and configuration notes
92
+
93
+ | Setting | Default | Range |
94
+ | --- | ---: | ---: |
95
+ | JSON-RPC line/frame | 128 KiB | 1 KiB–512 KiB |
96
+ | Pending RPC requests | 16 | 1–64 |
97
+ | Pending admitted notifications | 32 | 1–128 |
98
+ | RPC timeout | 10 s | 100 ms–60 s |
99
+ | Reconnect delay | 1 s | 1 ms–60 s (exponential, 30 s default max) |
100
+ | Receiver lease | 90 s | 5 s–5 min |
101
+ | Outbound chunk | 2,000 UTF-16 units | fixed |
102
+
103
+ The adapter pauses/unsubscribes on journal admission capacity/storage failure, malformed/oversized frames, account mismatch, or lease loss. It reconnects with bounded backoff after an established bridge loss and rechecks writer readiness before subscribing. It has **no documented application acknowledgment or replay log**: a daemon notification received before a crash and before durable commit can be lost. This adapter does not claim lossless intake, exactly-once execution, message read status, or end-to-end encryption through a model/provider.
104
+
105
+ ## Security and performance notes
106
+
107
+ Construction is inert: no spawn, TCP/HTTP, environment lookup, or credential read. Chat text cannot choose RPC method, account, socket, recipient, or identity action. UUID (not phone or display name) is the only observed actor id. A changed identity stops sends until an operator verifies it. Receive-to-commit can lose a notification; treat that as a documented loss window, not a retry.
108
+
109
+ ## Related APIs
110
+
111
+ - [Messaging channels](messaging-channels.md): authorization and durable replies.
112
+ - [Messaging channel operations](messaging-channel-operations.md): recovery and operator review.
package/docs/speech.md CHANGED
@@ -14,7 +14,11 @@ and `runTranscriptionConformance` from `@arnilo/prism/testing/provider-conforman
14
14
  ## When to use it
15
15
 
16
16
  Use it for one-shot voice output and batch/stream transcription where the host
17
- owns playback, capture, and audio storage. Do not use it for interactive
17
+ owns playback, capture, and audio storage. Messaging hosts wrap these providers
18
+ and hand the wrappers to a channel adapter — Telegram `transcribe` turns an
19
+ inbound voice note into turn text and `synthesize` adds a voice note next to a
20
+ final reply ([telegram-channel.md](telegram-channel.md)); `@arnilo/prism-channels`
21
+ never imports this package and never stores audio. Do not use it for interactive
18
22
  bidirectional voice — that is the Realtime session contract
19
23
  ([`RealtimeSession`](public-contracts.md)), which keeps its own
20
24
  `audio_delta`/`transcript_delta` events. Streaming here is one-directional:
@@ -54,7 +54,7 @@ import { createSqlitePersistence } from "@arnilo/prism-core/sessions/sqlite";
54
54
  | `SessionStore.readBranchPath` | Recursive ancestor query from `leafId` (or latest leaf) in root→leaf order. |
55
55
  | `RunLedger.append*` | Inserts run/event/tool/usage rows; events receive monotonic per-run `sequence` values. |
56
56
  | `ProductionPersistenceStore.query*` | Parameterized cursor pagination on indexed columns. |
57
- | `checkpoints` | Generic versioned `CheckpointStore` backed by `prism_checkpoints`; ownership, CAS/fencing checks, bounded pagination, and workflow suspended/denied/schedule/state/replay values without a schema migration. |
57
+ | `checkpoints` | Generic versioned `CheckpointStore` backed by `prism_checkpoints`; ownership, CAS/fencing checks, bounded pagination, and workflow suspended/denied/schedule/state/replay values without a schema migration. A load or delete under a non-matching ownership scope reads as absent and a cross-scope write fails as a generic `ERR_PRISM_CHECKPOINT_CONFLICT` (plan 080 Task 3) — no ownership-shaped existence oracle. |
58
58
  | `leases` | Atomic `LeaseStore` backed by `prism_leases`; database-clock expiry, opaque renew/release token, monotonic takeover fence. |
59
59
  | `close()` | Closes the underlying database when the adapter opened it. |
60
60
 
@@ -0,0 +1,157 @@
1
+ # Telegram channel
2
+
3
+ ## What it does
4
+
5
+ `@arnilo/prism-channels/telegram` is framework-free Telegram Bot API transport for text, with bounded attachment handling. It uses native `fetch`; importing it or creating a factory starts no listener, poller, credential lookup, environment lookup, or network request.
6
+
7
+ ## When to use it
8
+
9
+ Use for official Telegram Bot API text and bounded media. Private DMs are the default; group/supergroup text (including forum topics) is opt-in per adapter with `allowGroups: true` and stays subject to host `authorize`. Optional ephemeral streaming previews (`sendDrafts`) are private-chat only. Opt-in host hooks add voice transcription, document extraction and speech synthesis; images reach a model only when it declares image input. Not for Secret Chats, unbounded or stored media, or model-selected destinations.
10
+
11
+ Install peer and channel package:
12
+
13
+ ```bash
14
+ npm install @arnilo/prism @arnilo/prism-channels
15
+ ```
16
+
17
+ ## Inputs / request
18
+
19
+ `TelegramAdapterOptions` (polling): host `connectionId`, `botToken` (`CredentialValueSource`), service-owned `checkpoints`/`leases`/`cursorOwnership`, optional `allowGroups`, `sendDrafts`, `maxAttachmentBytes`, `transcribe`, `synthesize` and `extractDocumentText`. `TelegramWebhookHandlerOptions` add independent `webhookSecret` and the `admit` callback (intake only — outbound sends, drafts and `fetchAttachment` use an adapter instance, which works without `start()`). Importing or constructing starts no network.
20
+
21
+ ## Request/response example
22
+
23
+ Polling is explicit and restart-safe only with a service-owned `CheckpointStore` cursor and `LeaseStore` receiver lease. `start()` verifies the token with `getMe`, refuses to poll if `getWebhookInfo` reports an existing webhook, then runs 30-second `getUpdates` requests. It accepts private `message.text`/`caption` updates from non-bot senders, plus `group`/`supergroup` text and media when `allowGroups: true`. `photo`, `voice` and `document` become bounded attachment *refs* — see [Attachments and voice](#attachments-and-voice). Service, edit and bot-echo updates are acknowledged without agent execution, as are channel/anonymous-admin `sender_chat` messages and forum topics that fail the host grant.
24
+
25
+ A parsed group message keeps the admission keys transport-exact: `externalConversationId` is the chat id (negative for groups), `externalActorId` is `from.id`, and `message_thread_id` becomes `threadId`. Only `claims` carry untrusted context (`chatType`, `isForum`, `forwarded`), and `authorize` receives them so a host can refuse forwarded messages or non-forum groups. Threads and senders are separate sessions and separate grants: a grant for `(chat A, topic 7, user X)` admits neither user Y, chat B, nor topic 8.
26
+
27
+ ```ts
28
+ import { createMessagingRuntime } from "@arnilo/prism-channels";
29
+ import { createTelegramAdapter } from "@arnilo/prism-channels/telegram";
30
+
31
+ const adapter = createTelegramAdapter({
32
+ connectionId: "support-telegram",
33
+ botToken: hostCredentials.telegramBotToken, // string, function, or CredentialValueSource
34
+ checkpoints: serviceCheckpoints,
35
+ leases: serviceLeases,
36
+ cursorOwnership: { tenantId: "service" }, // service scope, not a Telegram claim
37
+ allowGroups: true, // opt-in groups/topics; still deny-by-default at authorize()
38
+ maxAttachmentBytes: 1024 * 1024, // per `getFile` download; hard ceiling 4 MiB
39
+ transcribe: (audio, format) => transcription.transcribe({ model: "whisper-1", audio, format }), // voice -> turn text
40
+ synthesize: (text) => speech.synthesize({ model: "tts-1", input: text }), // text -> voice note, final replies only
41
+ extractDocumentText: (bytes, mimeType) => hostExtractor(bytes, mimeType), // documents are inert without this
42
+ });
43
+
44
+ const runtime = createMessagingRuntime({
45
+ // Exact observed tuple: chat id, optional topic id, sender id. Display names are never keys.
46
+ authorize: ({ externalConversationId, threadId, externalActorId, claims }) =>
47
+ claims?.forwarded === true
48
+ ? false
49
+ : (hostGrants.get(externalConversationId, threadId, externalActorId) ?? false),
50
+ resolveAgent: hostResolveAgent,
51
+ deliver: (reply) => adapter.send(reply),
52
+ onAssistantDelta: (delta) => adapter.sendDraft(delta), // opt-in previews; needs sendDrafts: true
53
+ fetchAttachment: (ref) => adapter.fetchAttachment(ref), // lets a model that declares image input receive the image
54
+ checkpoints: userCheckpoints,
55
+ leases: userLeases,
56
+ });
57
+
58
+ await adapter.start((event) => runtime.admit(event));
59
+ // shutdown: await adapter.stop(); await runtime.stop();
60
+ ```
61
+
62
+ [`examples/telegram-agent.ts`](../examples/telegram-agent.ts) exposes same composition as a host-supplied helper.
63
+
64
+ The adapter awaits `runtime.admit()` before advancing `offset`. Return the admission result from the callback: `capacity`, `unavailable`, and `stopped` keep Telegram's update unacknowledged; all other settled dispositions advance the durable cursor. The operation journal then deduplicates any replay before model or tool work.
65
+
66
+ A connection receiver lease (`prism.channels.v1.telegram.receiver`) is held for the poll loop. A second poller or a webhook worker using the same `connectionId`, service ownership and `LeaseStore` receives no work. This fences stored intake state; it cannot retract an already accepted platform request from a paused process.
67
+
68
+ ## Implementation example
69
+
70
+ Mount `createTelegramWebhookHandler` on a host HTTPS route. It is a Web-standard `(Request) => Promise<Response>` handler; Prism does not create a listener, configure DNS/TLS, or call `setWebhook` for you.
71
+
72
+ ```ts
73
+ import { createTelegramWebhookHandler } from "@arnilo/prism-channels/telegram";
74
+
75
+ const telegramWebhook = createTelegramWebhookHandler({
76
+ connectionId: "support-telegram",
77
+ botToken: hostCredentials.telegramBotToken,
78
+ webhookSecret: hostSecrets.telegramWebhookSecret, // 1–256 URL-safe characters
79
+ admit: (event) => runtime.admit(event),
80
+ leases: serviceLeases,
81
+ cursorOwnership: { tenantId: "service" },
82
+ allowGroups: true, // must match the poller you are replacing
83
+ });
84
+
85
+ // Example host adapter: route only this fixed HTTPS path to telegramWebhook(request).
86
+ ```
87
+
88
+ Configure Telegram deliberately after the route, TLS and independent secret exist. Do not set `drop_pending_updates` and do not remove an active webhook merely to switch modes.
89
+
90
+ ```sh
91
+ curl --fail-with-body \
92
+ -H 'content-type: application/json' \
93
+ -d '{"url":"https://bot.example/telegram","secret_token":"<independent-secret>","allowed_updates":["message","callback_query"]}' \
94
+ "https://api.telegram.org/bot<BOT_TOKEN>/setWebhook"
95
+ ```
96
+
97
+ The handler requires `POST` + JSON, verifies `X-Telegram-Bot-Api-Secret-Token` with a fixed-length hash comparison before body parsing, bounds body bytes, acquires the same receiver lease, and calls `admit` before returning `204`. Bad secret/body returns `401`/`400`/`413`; lease, capacity, stopped runtime, or journal unavailability returns `503` so Telegram retries. Untrusted but final inputs (bot/media/unknown sender/anonymous admin, or group updates when `allowGroups` is off) return `204` without an agent call. `allowGroups` is parsed identically in both modes; set it the same on the webhook worker and the poller you replaced.
98
+
99
+ `callback_query` is promptly acknowledged with `answerCallbackQuery` and only accepts the runtime's `p:a:<opaque-token>` / `p:d:<opaque-token>` callback shape from a non-bot sender in an admitted private, group, or topic conversation (a topic callback keeps its `message_thread_id` as `threadId`). The adapter maps it to an `approval` event; callback data never becomes model input. The runtime validates its short-lived server-side binding before core resume.
100
+
101
+ ## Outputs / response / events
102
+
103
+ `send()` accepts only replies addressed to its own `connectionId`; the runtime fixes `externalConversationId` and the reply `threadId` from the authorized binding, so a topic answer posts to that topic's `message_thread_id` and the model cannot choose either. It sends plain text, chunks at 3,500 UTF-16 code units without splitting surrogate pairs, and retries documented `retry_after` delays up to five attempts. A bounded server-issued control set renders as an inline keyboard on the final chunk; arbitrary model text cannot create buttons. There is no Markdown parse mode.
104
+
105
+ Known API failures return `{ delivered: false, reason }`. A network/timeout outcome, or a later failure after a prior chunk was accepted, rejects with a bounded ambiguous-delivery error. The common durable reply journal records thrown delivery as `delivery_unknown`; do not automatically resend it. Bot token URLs are never included in adapter errors and redirects are forbidden.
106
+
107
+ Telegram's `getUpdates` and webhooks are mutually exclusive. Updates remain available for at most 24 hours; the cursor is an ordering/acknowledgment record, not an exactly-once agent execution guarantee. Bots are not Secret Chats.
108
+
109
+ ### Streaming previews (drafts)
110
+
111
+ With `sendDrafts: true` and `MessagingRuntimeOptions.onAssistantDelta: (delta) => adapter.sendDraft(delta)`, the adapter renders the answer while it is produced through Bot API `sendMessageDraft`: same chat and `message_thread_id` as the bound turn, `draft_id` fixed at one preview slot per chat, text capped to the last 3,500 UTF-16 code units. Previews are coalesced — one request in flight, the latest partial wins — so a token stream never floods the API, and failures are ignored because a draft is only a preview. Cancel, `stop()` and run end stop further previews. Drafts are private-chat only: group and supergroup chats are skipped even when `allowGroups` and `sendDrafts` are both on. No draft is ever a `ChannelReply`: nothing is journaled, no controls or attachments can ride along, and the terminal `sendMessage` remains the only message the user keeps.
112
+
113
+ ### Attachments and voice
114
+
115
+ A `photo`, `voice` or `document` message is parsed into at most eight `ChannelAttachmentRef` values on the event: `kind`, `transportFileId` (Telegram `file_id`), optional `mimeType`, `byteLength` and `fileName`. A photo collapses to its largest size. The event never carries bytes, a URL, a caption-as-identity, or a model-chosen id, and nothing here can be addressed by model output.
116
+
117
+ Before `admit`, the adapter runs the one bounded fetch the host opted into (Bot API `getFile` plus a capped download):
118
+
119
+ - `voice` + host `transcribe` → the transcript becomes ordinary turn text (`caption`, then transcript). The audio is discarded after the call.
120
+ - `document` + host `extractDocumentText` → the extracted text becomes turn text. Without the hook the document stays inert: the message admits with an attachment ref and no text, and the runtime answers with a bounded "cannot be processed" notice instead of a model call.
121
+ - `photo` is never fetched by the adapter. The runtime fetches it (through `fetchAttachment`) only when the resolved agent's model declares `image` input, then sends it as one image content block next to the caption; otherwise the turn is refused with the same notice and no model call. `image` is the only attachment kind that becomes model input — audio/documents are text-only because Prism receives their host-produced text.
122
+
123
+ Oversize fails closed twice: the runtime denies an event whose *declared* sizes exceed `maxAttachmentBytes` (`denied: oversized`, no fetch, no model call), and the adapter refuses to buffer a body whose `Content-Length` or streamed size exceeds the same cap. Failures (network, credential, API, malformed `file_path`) yield no bytes, so the turn is refused rather than run without the attachment.
124
+
125
+ Outbound, `synthesize` adds a Bot API `sendVoice` next to the text for `final` replies: same bound chat and `message_thread_id`, OGG/OPUS, MP3 or M4A formats only, best-effort — a synthesis or upload failure never fails or re-sends the delivered text reply. Notices stay text-only. Nothing hangs a voice note on model-selected destinations.
126
+
127
+ ## Extension and configuration notes
128
+
129
+ | Setting | Default | Range |
130
+ | --- | ---: | ---: |
131
+ | Poll timeout | 30 s | 1–50 s |
132
+ | Poll batch | 100 | 1–100 |
133
+ | Receiver lease | 90 s | poll timeout + 10 s–5 min |
134
+ | Webhook/API body | 128 KiB | 1 KiB–1 MiB |
135
+ | Outbound chunk | 3,500 UTF-16 units | fixed (under Telegram's 4,096 limit) |
136
+ | Known-send attempts | 5 | fixed |
137
+ | Group/topic text (`allowGroups`) | off | boolean |
138
+ | Streaming previews (`sendDrafts`) | off | boolean (private chats only) |
139
+ | Attachment bytes (`maxAttachmentBytes`) | 1 MiB | 1 B–4 MiB |
140
+ | Voice transcription (`transcribe`) | off | host `TranscriptionProvider` wrapper |
141
+ | Speech synthesis (`synthesize`) | off | host `SpeechProvider` wrapper (final replies) |
142
+ | Document text (`extractDocumentText`) | off | host extractor |
143
+
144
+ ## Security and performance notes
145
+
146
+ The webhook secret is independent of the bot token and compared with a fixed-length hash before body parsing. Redirects are forbidden; bot-token URLs never appear in adapter errors. Polling and webhooks are mutually exclusive. Model text cannot create inline buttons.
147
+
148
+ Groups and topics are off by default and never self-authorize: the adapter only parses an observed `(chat id, topic id?, user id)` and the host grant decides, so a group membership claim is not an identity. Chat titles, usernames and display names are never actor ids. Bots, `sender_chat` (channel posts and anonymous admins), and non-text updates are dropped before admission; forwarded messages are admitted but flagged in `claims` so the grant can refuse them. In groups Telegram privacy mode limits what a bot receives (commands and replies to it) unless an operator disables it in BotFather; Prism does not work around that platform behavior. Signal stays DM-only.
149
+
150
+ Drafts are display-only and cannot become input: they carry no controls or attachments, the destination chat/thread always comes from the bound event (never from model output or from the delta), text is redacted with the same redactor as finals, and the 3,500-unit cap keeps the request inside Bot API limits. Since `sendDraft` is fire-and-forget and failures are swallowed, a draft can never fail a turn, mask a delivery failure, or replace the journaled final reply.
151
+
152
+ Media is bounded on every axis. Only the current event's refs are ever fetched (`fetchAttachment` takes a ref, and the runtime only passes refs of the event it is running), the cap is enforced twice (declared size and streamed body), and downloads use the same fixed origin with redirects forbidden. Bytes live only for the fetch call: they are never journaled (`ChannelOperationRecord` and `ChannelReplyRecord` store text only), never logged, and never written to disk. A `file_id` is not a capability — the bot proves nothing to Telegram by holding one, and a caller-supplied or model-invented id yields at most one bounded request inside the cap. Hosts wire `transcribe`/`synthesize`/`extractDocumentText` to their own provider wrappers; `@arnilo/prism-channels` never depends on `@arnilo/prism-providers`.
153
+
154
+ ## Related APIs
155
+
156
+ - [Messaging channels](messaging-channels.md): authorization, durable operations and reply recovery.
157
+ - [Web-standard server handler](server.md): mounting a `Request`/`Response` handler in a Node or framework host.
package/docs/testing.md CHANGED
@@ -21,14 +21,14 @@ Documents how the hermetic suite runs, which stage a new suite belongs to, and t
21
21
  | build race | `scripts/phase23-build-race.test.mjs` |
22
22
  | workspace suites | `npm run test --workspaces --if-present` |
23
23
 
24
- Protected-environment legs (Postgres, PTY, NATS, live credentials) are not part of `npm test`; they fail closed with one canonical `BLOCKED GATE <id> requires=<names> evidence=<surface> hint=<how to unblock>` record and a non-zero exit when their infrastructure is absent (registry and audit: `node scripts/blocked-gate.mjs`). Retired phase freeze/release gates live in `scripts/` for audit but are deliberately kept out of the chain. 0.7.0 host-completeness packed proof is `scripts/fixtures/e2e-070-host-completeness-journey.mjs` (same packed consumer as the full-surface journey) plus `scripts/host-completeness-evidence.test.mjs`; live legs stay skip-not-fail. R16/R17 stay blocked until plans 077/074 ship.
24
+ Protected-environment legs (Postgres, PTY, NATS, live credentials) are not part of `npm test`; they fail closed with one canonical `BLOCKED GATE <id> requires=<names> evidence=<surface> hint=<how to unblock>` record and a non-zero exit when their infrastructure is absent (registry and audit: `node scripts/blocked-gate.mjs`). A successful `PRISM_TEST_POSTGRES_URL=… npm run test:postgres` first removes stale evidence, then writes gitignored `scripts/postgres-evidence.json` with only current `gitHead`, capture time, and TAP counts; release evidence accepts it only at the same `HEAD`. Retired phase freeze/release gates live in `scripts/` for audit but are deliberately kept out of the chain. 0.7.0 host-completeness packed proof is `scripts/fixtures/e2e-070-host-completeness-journey.mjs` (same packed consumer as the full-surface journey) plus `scripts/host-completeness-evidence.test.mjs`; live legs stay skip-not-fail. R16/R17 stay blocked until plans 077/074 ship.
25
25
 
26
26
  ## Isolation rules
27
27
 
28
28
  - **Scratch roots come from the OS.** A suite that writes anything creates its root with `mkdtempSync(join(tmpdir(), "prism-…"))` and removes it in `after()`. Never rely on `process.cwd()` for write targets: the same suite runs with different working directories (workspace stage vs. root stage), so a cwd-relative root silently writes into the repository.
29
29
  - **Pass explicit roots.** Wiki, memory, and store helpers default `workspaceRoot` to `process.cwd()`; suites pass their scratch root (and a `wikiRoot` relative to it) instead of accepting the default.
30
30
  - **Tracked fixtures stay byte-identical.** `packages/memory/.wiki/` is a tracked wiki fixture and `docs/` is a tracked corpus. `scripts/wiki-scratch-isolation.test.mjs` runs the wiki suites from the package and from the repository root and fails if the tracked fixture hashes change, if a new file appears inside the fixture, if `<repo>/.wiki/` is scaffolded, or if the old cwd-relative scratch directories reappear.
31
- - **Gates never write inside the repository.** A gate asserts against tracked content and spawns suites in temporary directories only. A gate that spawns `node --test` must strip `NODE_TEST_CONTEXT`/`NODE_TEST_WORKER_ID` from the child environment (an inherited value makes the nested runner skip every file and still exit 0) and assert the child reported a non-zero pass count.
31
+ - **Gates never write inside the repository.** A gate asserts against tracked content and spawns suites in temporary directories only. A gate that spawns `node --test` must strip `NODE_TEST_CONTEXT`/`NODE_TEST_WORKER_ID` from the child environment (an inherited value makes the nested runner skip every file and still exit 0) and assert the child reported a non-zero pass count. The wiki gate additionally uses `--test-isolation=none`: all nested files run in its one runner process, avoiding process-worker IPC deserialization without retrying failures.
32
32
  - **Wait by polling, not by sleeping.** Async browser state (download quarantine, idle reaping) is not awaitable from the outside — `manager.ts` settles it on a fire-and-forget listener promise — so a fixed sleep is a race that loses under CPU load and fails the assertion for a reason unrelated to the behavior under test. Suites poll observable state through `waitFor(read, ok, label, { timeoutMs, intervalMs })` in `packages/web-tools/src/browser/__tests__/wait-for.ts`, which returns as soon as the state appears and otherwise throws naming the label and the last observed value. Fixed sleeps remain only where real elapsed time is the subject of the test (idle TTLs).
33
33
 
34
34
  ## Related APIs
package/docs/wiki.md CHANGED
@@ -85,7 +85,7 @@ The staging primitive behind `/wiki-ingest`, `wiki_ingest`, and the CLI. Accepts
85
85
  | :--- | :--- |
86
86
  | Text-like files and `text` | Decoded as UTF-8 (RAG text/markdown/html parsers) |
87
87
  | Uncompressed PDF | Parsed by the RAG PDF parser (bounded pages/bytes) |
88
- | Compressed PDF / DOCX | Throws a named error unless the host supplies `options.extractDocument` (e.g. wire `createDocumentReader()` from `@arnilo/prism-coding-tools/document-reader`) |
88
+ | Compressed PDF / DOCX | Throws a named error unless the host supplies `options.extractDocument` (e.g. wire `createDocumentReader()` from `@arnilo/prism-work/document-reader`) |
89
89
  | `url` | `assertSsrfAllowedUrl` runs first (private/link-local hosts rejected before any fetch); then the host `fetchUrl` hook supplies the bytes/text — missing or empty hook output fails closed. Staged filename comes from the hook, the URL extension (`doc.pdf`), or `source.md` |
90
90
  | Images | Staged as-is; stub extract points at the staged `source.*` — no OCR; view the file |
91
91
  | Unknown binary | Fails closed unless `extractDocument` claims it |
@@ -97,7 +97,7 @@ export const handler = createArtifactHandler({ service: artifacts, authorize: ho
97
97
 
98
98
  ## Business action drafts and editable approvals (0.7.0)
99
99
 
100
- Business tools (e.g. mail, calendar, documents in `@arnilo/prism-core/integrations/work`) record mutations through durable `WorkDraftStore` drafts before execution. Human reviewers can approve, deny, or edit draft payloads directly:
100
+ Business tools (e.g. mail, calendar, documents in `@arnilo/prism-work/connectors`) record mutations through durable `WorkDraftStore` drafts before execution. Human reviewers can approve, deny, or edit draft payloads directly:
101
101
  - AG-UI clients advertise and send `approveWithEdits` with revised arguments (`editedArgs`/`modifiedArguments`).
102
102
  - The server resume endpoint accepts `{ decision: "approve", modifiedArguments: { ... } }` under CAS `expectedVersion`.
103
103
  - If arguments are modified, a new draft revision is created with bumped revision number and payload digest. The previous revision's approval is invalidated and the mutation requires approval for the revised content.
@@ -1,33 +1,33 @@
1
1
  # Work connectors
2
2
 
3
- Least-privilege Microsoft 365 and Google Workspace connectors live in `@arnilo/prism-core/integrations/work`.
3
+ Least-privilege Microsoft 365 and Google Workspace connectors live in `@arnilo/prism-work/connectors`.
4
4
 
5
5
  ## Principles
6
6
 
7
- 1. **Host-pinned binary** — Prism never downloads or shells an untrusted CLI path.
8
- 2. **Hard-coded argv templates** — models choose typed tool args; they never supply command strings.
7
+ 1. **Host-pinned binary or HTTP adapter** — Prism never downloads or shells an untrusted CLI path; HTTP adapters use fixed origins and pinned fetch.
8
+ 2. **Hard-coded operation maps** — models choose typed tool args; they never supply command strings or request URLs.
9
9
  3. **Draft-then-approve & durable resumption** — mutations create a draft with tracked revisions and payload digests; side effects run only after host approval binds to that exact revision; durable checkpoint persistence survives process restart.
10
10
  4. **Idempotent retries** — `IdempotencyStore` keyed by identity + operation key.
11
11
  5. **Isolated config** — per-identity `configDir` (CLI `HOME`); no credential argv.
12
- 6. **Shared result shapes** — mail/calendar/file/task list/get tools normalize onto `WorkMailMessage` / `WorkCalendarEvent` / `WorkFileItem` / `WorkTaskItem` without hiding provider-specific ops.
12
+ 6. **Shared result shapes** — mail/calendar/file/task list/get tools normalize onto `WorkMailMessage` / `WorkCalendarEvent` / `WorkFileItem` / `WorkTaskItem` without hiding provider-specific ops. Binary file gets return only untrusted artifact/path metadata plus hash and byte length.
13
13
 
14
14
  ## Microsoft 365
15
15
 
16
- See [Work tools](work-tools.md). Adapter: `createMicrosoft365CliAdapter` / subpath `@arnilo/prism-core/integrations/work/microsoft365`.
16
+ See [Work tools](work-tools.md). Adapters: `createMicrosoft365CliAdapter` or `createMicrosoft365HttpAdapter` from `@arnilo/prism-work/connectors`.
17
17
 
18
- Uses [@pnp/cli-microsoft365](https://pnp.github.io/cli-microsoft365/) commands such as `outlook message list|get`, `outlook mail send`, `outlook event list|add`, `file list|add`, `spo file sharinglink add`. To Do / Planner / Teams remain capability-gated.
18
+ The CLI adapter uses [@pnp/cli-microsoft365](https://pnp.github.io/cli-microsoft365/) commands such as `outlook message list|get`, `outlook mail send`, `outlook event list|add`, `file list|add|copy`, `spo file sharinglink add`. The HTTP adapter maps its fixed operation set to `graph.microsoft.com` through pinned fetch; tokens reach it only in `Authorization`. `m365_file_get` accepts only an item ID and downloads via fixed `/me/drive/items/{id}/content` into a scanned artifact and/or contained sandbox path; it never emits bytes to model context. Upload drafts accept a host path, artifact ref, or contained sandbox path and bind the content hash before approval. It requires direct Graph Drive-item URLs for one-request file list/upload/copy and rejects arbitrary SharePoint links. To Do / Planner / Teams remain capability-gated.
19
19
 
20
20
  ## Google Workspace
21
21
 
22
- See [Work tools](work-tools.md). Adapter: `createGoogleWorkspaceCliAdapter` / subpath `@arnilo/prism-core/integrations/work/google-workspace`.
22
+ See [Work tools](work-tools.md). Adapters: `createGoogleWorkspaceCliAdapter` or `createGoogleWorkspaceHttpAdapter` from `@arnilo/prism-work/connectors`.
23
23
 
24
- Uses [`@googleworkspace/cli` (`gws`)](https://github.com/googleworkspace/cli): `gmail users messages list|get`, `gmail +send`, `calendar events list|insert`, `drive files list|create`, `drive permissions create`, `tasks tasks *`. Docs/Sheets/Slides create remain capability-gated. Discovery `schema` and `auth`/`login`/`setup` are forbidden from Prism argv.
24
+ The CLI adapter uses [`@googleworkspace/cli` (`gws`)](https://github.com/googleworkspace/cli): `gmail users messages list|get`, `gmail +send`, `calendar events list|insert`, `drive files list|create`, `drive permissions create`, `tasks tasks *`, and capability-gated Docs/Sheets/Slides create and fixed update commands. The HTTP adapter maps the same typed operations to Gmail, Calendar, Drive, Tasks, Docs, Sheets, and Slides REST origins with `pinnedFetch`; `gws_file_get` accepts only an item ID and uses fixed `Drive files.get?alt=media`, returning only scanned artifact/path metadata. Its fixed allowlist excludes model-supplied URLs. Docs/Sheets/Slides updates accept only replace/insert text or string-matrix values, never a model-provided batch request array. Discovery `schema` and `auth`/`login`/`setup` are forbidden from Prism argv.
25
25
 
26
26
  Drive **knowledge synchronization** (RAG import of file text + host-mapped ACL via `changes.list`) is not this CLI adapter. Use `createGoogleDriveConnector` / `syncKnowledge` from `@arnilo/prism-memory/rag` — see [Knowledge synchronization](knowledge-sync.md).
27
27
 
28
28
  ## Scoped OAuth establishment (0.0.14)
29
29
 
30
- Hosts establish, refresh, and revoke scoped OAuth credentials for these workloads through the existing `OAuthProvider` / credential-store seams (`@arnilo/prism-core/credentials/node`): `createMicrosoft365OAuthProvider` / `createGoogleWorkspaceOAuthProvider` (PKCE + device code), least-privilege scope bundles per capability (`resolveMicrosoft365Scopes` / `resolveGoogleWorkspaceScopes`, read vs mutation). Connectors consume a per-identity token via a late-bound `tokenProvider` injected as an env var — never argv, never model context; revocation fails closed. See [Credential storage](credential-storage.md) and [Work tools](work-tools.md).
30
+ Hosts establish, refresh, and revoke scoped OAuth credentials for these workloads through the existing `OAuthProvider` / credential-store seams (`@arnilo/prism-core/credentials/node`): `createMicrosoft365OAuthProvider` / `createGoogleWorkspaceOAuthProvider` (PKCE + device code), least-privilege scope bundles per capability (`resolveMicrosoft365Scopes` / `resolveGoogleWorkspaceScopes`, read vs mutation). Connectors consume a per-identity token via a late-bound `tokenProvider`: CLI adapters inject it into env and HTTP adapters send it only as `Authorization` — never argv or model context; revocation fails closed. See [Credential storage](credential-storage.md) and [Work tools](work-tools.md).
31
31
 
32
32
  ## Out of scope
33
33
 
@@ -0,0 +1,115 @@
1
+ # Work sandbox (`@arnilo/prism-work/sandbox`)
2
+
3
+ ## What it does
4
+
5
+ Host-built Docker image and in-process composition for document work. `WORK_SANDBOX_IMAGE` is a digest-pinned fixture (`name@sha256:<64-hex>`); hosts replace the zero digest after `docker build`. `createWorkComposition({ sandbox })` takes an injected `DisposableSandbox` (from `createDockerSandbox`, never forked here), copies its capability attestation, wires `createOfficeTools` filesystem mode plus `work_exec`, and optionally host-side `createWorkTools`. Connectors stay on the host. Default env has no `M365_*` / `GOOGLE_*` keys.
6
+
7
+ ## When to use it
8
+
9
+ Use when Office parse/generate and Python/LibreOffice/poppler scripts must run inside a network-none container. Do **not** put Graph/Gmail tokens in the sandbox. Do not import `@arnilo/prism-coding-tools` from this package — the host constructs `createDockerSandbox({ image: WORK_SANDBOX_IMAGE, user: "65532:65532", network: { mode: "none" } })` and injects the session.
10
+
11
+ ## Inputs / request
12
+
13
+ `createWorkComposition(options)`:
14
+
15
+ | Field | Meaning |
16
+ | --- | --- |
17
+ | `sandbox` | Injected adapter with `execFile` and optional `readFile`/`writeFile`/`root`. Required. |
18
+ | `connectors?` | `WorkToolsOptions` for host-side M365/GWS tools. |
19
+ | `office?` | Extra `createOfficeTools` options (caps, artifacts, redactor). |
20
+ | `reader?` | Host-built `DocumentReader`; exposed on the composition, not turned into tools. |
21
+ | `filesystem?` | `WorkSandboxFilesystem` when the sandbox has no `readFile`/`writeFile`. |
22
+ | `env?` | Extra container env. `M365_*` and `GOOGLE_*` names throw. |
23
+
24
+ Image build context: `packages/prism-work` (`sandbox/Dockerfile` + `sandbox/soffice.sh` + `vendor/hermes-agent`). Vendored Hermes scripts land at `/opt/prism-work/skills`.
25
+
26
+ ## Outputs / response / events
27
+
28
+ `{ tools, composition }`. `tools` always include `office_*` and `work_exec`. `composition.capabilities` is a frozen copy of the sandbox attestation (malformed metadata → every field `false`). `networkIsolated` is true only when the sandbox attests it — Docker reports that solely for `network: { mode: "none" }`. `composition.execFile` strips token env names and forces LibreOffice `-env:UserInstallation=file:///tmp/lo-profile` without `--accept` / macro flags.
29
+
30
+ ## Request/response example
31
+
32
+ ```ts
33
+ import { createDockerSandbox } from "@arnilo/prism-coding-tools/security";
34
+ import { createWorkComposition, WORK_SANDBOX_IMAGE } from "@arnilo/prism-work/sandbox";
35
+
36
+ const sandbox = await createDockerSandbox({
37
+ docker: "/usr/bin/docker",
38
+ image: WORK_SANDBOX_IMAGE, // replace zeros with the host-built digest
39
+ sourceRoot: workdir,
40
+ user: "65532:65532",
41
+ network: { mode: "none" },
42
+ });
43
+ const { tools, composition } = createWorkComposition({ sandbox, connectors });
44
+ ```
45
+
46
+ ## Implementation example
47
+
48
+ ```ts
49
+ import { createWorkComposition, WORK_SANDBOX_IMAGE } from "@arnilo/prism-work/sandbox";
50
+
51
+ const { tools, composition } = createWorkComposition({
52
+ sandbox: fakeDisposableSandbox, // tests inject this; no Docker
53
+ });
54
+ composition.capabilities.networkIsolated; // copied, never invented
55
+ ```
56
+
57
+ Build:
58
+
59
+ ```bash
60
+ docker build -f packages/prism-work/sandbox/Dockerfile -t prism-work-sandbox packages/prism-work
61
+ docker image inspect --format '{{index .RepoDigests 0}}' prism-work-sandbox
62
+ ```
63
+
64
+ ## Extension and configuration notes
65
+
66
+ - Do not fork `createDockerSandbox`. Image pull/build stays outside Prism (`--pull=never`).
67
+ - Connectors optional and host-side. Bytes move via sandbox import/export and office filesystem tools.
68
+ - `work_exec` is argv-only (`file` + `args`); no model-supplied shell string.
69
+ - Protected image check: `PRISM_TEST_WORK_SANDBOX=1` runs `scripts/work-sandbox-image.test.mjs` and sandbox recalc/render/legacy-convert tests. Default `npm test` does not spawn `soffice`.
70
+
71
+ ## Recalc and visual QA
72
+
73
+ In-process `SheetModel` does not evaluate formulas. Cached values are filled only by LibreOffice in this image (`network: none`, isolated `/tmp/lo-profile`, deleted with the container). Vendored scripts:
74
+
75
+ ```bash
76
+ python3 /opt/prism-work/skills/skills/productivity/xlsx/scripts/xlsx_recalc.py /workspace/out.xlsx --timeout 60
77
+ python3 /opt/prism-work/skills/skills/productivity/powerpoint/scripts/pptx_render.py /workspace/deck.pptx --outdir /workspace/render
78
+ ```
79
+
80
+ Equivalent argv (wrapper already injects `-env:UserInstallation=file:///tmp/lo-profile --headless`):
81
+
82
+ ```bash
83
+ soffice --headless -env:UserInstallation=file:///tmp/lo-profile --convert-to pdf --outdir /tmp/out /workspace/out.xlsx
84
+ pdftoppm -png -r 100 /tmp/out/out.pdf /tmp/out/page
85
+ ```
86
+
87
+ External workbook links cannot be fetched. Recalc then fails closed: formula stays, cached value missing or error — Prism does not invent a number. `soffice` timeout ≤ 60 s.
88
+
89
+ ## Legacy convert
90
+
91
+ Prism AST still refuses non-ZIP packages. Convert OLE `.doc` / `.xls` / `.ppt` inside this image (`network: none`, isolated `/tmp/lo-profile`, macros refused), then `office_parse`:
92
+
93
+ ```bash
94
+ soffice --headless -env:UserInstallation=file:///tmp/lo-profile --convert-to docx --outdir /workspace /workspace/legacy.doc
95
+ soffice --headless -env:UserInstallation=file:///tmp/lo-profile --convert-to xlsx --outdir /workspace /workspace/legacy.xls
96
+ soffice --headless -env:UserInstallation=file:///tmp/lo-profile --convert-to pptx --outdir /workspace /workspace/legacy.ppt
97
+ ```
98
+
99
+ Do not enable macros. Encrypted OOXML stays dropped. No in-process OLE parser.
100
+
101
+ ## Security and performance notes
102
+
103
+ - Default network none. Composition does not claim isolation the sandbox did not attest.
104
+ - Token env keys denied by name (`M365_*`, `GOOGLE_*`) at composition construct and `execFile`.
105
+ - LibreOffice wrapper: private `/tmp/lo-profile`, `--headless`, no macro enable, `--accept` refused. No listening socket. Legacy convert uses the same wrapper.
106
+ - Zip bombs: existing office parse caps; sandbox export uses existing export caps.
107
+ - Image build is CI/protected, not default unit tests. Composition construct is in-process with a fake sandbox.
108
+
109
+ ## Related APIs
110
+
111
+ - [Coding security](coding-security.md) — `createDockerSandbox` digest pin, user, network none
112
+ - [Work tools](work-tools.md) — host-side connectors
113
+ - [Documents](documents.md) — `createOfficeTools`
114
+ - [Document reader](document-reader.md) — optional `reader` injection
115
+ - [Context and skills](context-and-skills.md) — `loadWorkSkills()` (`docx`, `xlsx`, `powerpoint`, `pdf`)