From 066ea9eff0d6a5ee0b9456328dacda5ca67ffb67 Mon Sep 17 00:00:00 2001 From: Marcus Schiesser Date: Mon, 3 Aug 2026 16:43:24 +0200 Subject: [PATCH 1/2] feat(runtime): add Zig credential effects --- changelog.d/zig-credential-effects.md | 1 + docs/src/app/docs/capabilities/page.mdx | 40 +- docs/src/app/docs/native-ui/page.mdx | 19 + skill-data/native-ui/SKILL.md | 31 ++ src/root.zig | 6 + src/runtime/effects.zig | 537 ++++++++++++++++++++++- src/runtime/effects_credential_tests.zig | 312 +++++++++++++ src/runtime/root.zig | 6 + src/runtime/session_journal.zig | 4 + src/runtime/session_replay.zig | 53 +++ src/runtime/tests.zig | 1 + src/runtime/ui_app.zig | 1 + 12 files changed, 1006 insertions(+), 5 deletions(-) create mode 100644 changelog.d/zig-credential-effects.md create mode 100644 src/runtime/effects_credential_tests.zig diff --git a/changelog.d/zig-credential-effects.md b/changelog.d/zig-credential-effects.md new file mode 100644 index 000000000..a92af3de6 --- /dev/null +++ b/changelog.d/zig-credential-effects.md @@ -0,0 +1 @@ +feature: **Zig credential effects**: `UiApp.update` can set, get, and delete `(service, account)` secrets through typed effects, with fake-executor and replay support, secure effect-buffer wiping, and credential reads rejected before OS access while session recording is active so secret bytes never enter effect-result journals. diff --git a/docs/src/app/docs/capabilities/page.mdx b/docs/src/app/docs/capabilities/page.mdx index f87f28f2c..e32dbb992 100644 --- a/docs/src/app/docs/capabilities/page.mdx +++ b/docs/src/app/docs/capabilities/page.mdx @@ -1,6 +1,6 @@ # Capabilities -Native SDK capabilities are native OS services and app events exposed through `PlatformServices`, runtime methods, lifecycle events, and — for apps that [embed web content](/docs/frontend) — guarded bridge commands. Native code reaches them directly; web content does not receive capability access by default. In a [`UiApp`](/docs/app-model), clipboard access rides the effects channel (`fx.writeClipboard` / `fx.readClipboard`) so `update` never needs a runtime handle. +Native SDK capabilities are native OS services and app events exposed through `PlatformServices`, runtime methods, lifecycle events, and — for apps that [embed web content](/docs/frontend) — guarded bridge commands. Native code reaches them directly; web content does not receive capability access by default. In a [`UiApp`](/docs/app-model), clipboard and credential-store access ride the effects channel (`fx.writeClipboard` / `fx.readClipboard`, `fx.setCredential` / `fx.getCredential` / `fx.deleteCredential`) so `update` never needs a runtime handle. Web content itself is declare-to-use: an app ships the embedded web layer only when it declares web intent — `"webview"` in `.capabilities`, a `.frontend` block, a `.shell` webview view, or a web engine resolved to Chromium (`.web_engine = "chromium"` in app.zon, or the `-Dweb-engine`/`--web-engine` flags) — and an app that declares none of them builds native-only, where any attempt to create a webview fails with a teaching error instead of loading a layer the app never asked for. The [`webview_layer`](/docs/app-zon) manifest field overrides the inference in either direction. Native-only builds shed the platform web stack for real: the Windows executable carries no `WebView2Loader.dll` reference, and the Linux host neither links WebKitGTK nor requires `libwebkitgtk` on user machines. @@ -82,7 +82,7 @@ Web content itself is declare-to-use: an app ships the embedded web layer only w Credential store - runtime.setCredential(options) / runtime.getCredential(key) / runtime.deleteCredential(key) + runtime.setCredential(options) / runtime.getCredential(key, buffer) / runtime.deleteCredential(key); Zig UiApp: fx.setCredential / fx.getCredential / fx.deleteCredential native-sdk.credentials.set / native-sdk.credentials.get / native-sdk.credentials.delete credentials macOS system WebView and macOS Chromium through Keychain; Linux system WebView through Secret Service/libsecret when available; Windows system WebView through Credential Manager @@ -106,6 +106,42 @@ Web content itself is declare-to-use: an app ships the embedded web layer only w Unsupported platform hosts reject with the standard unsupported-service error. +### Credential effects in Zig UiApp + +The runtime methods remain appropriate for code that already owns a runtime handle. Inside a Zig `UiApp.update`, use the typed effects instead. `service` namespaces credentials for an app or integration; `account` selects an identity or slot within that namespace. Both are part of the lookup key, so keep them stable between set, get, and delete. + +```zig +pub const Msg = union(enum) { + save_api_key, + load_api_key, + forget_api_key, + credential_result: native_sdk.EffectCredentialResult, +}; + +.save_api_key => fx.setCredential(.{ + .key = credential_fx_key, + .service = "com.example.notes.openai", + .account = "default", + .secret = model.pending_api_key, + .on_result = Effects.credentialMsg(.credential_result), +}), +.load_api_key => fx.getCredential(.{ + .key = credential_fx_key, + .service = "com.example.notes.openai", + .account = "default", + .on_result = Effects.credentialMsg(.credential_result), +}), +.forget_api_key => fx.deleteCredential(.{ + .key = credential_fx_key, + .service = "com.example.notes.openai", + .account = "default", + .on_result = Effects.credentialMsg(.credential_result), +}), +.credential_result => |result| model.applyCredentialResult(result), +``` + +A successful get exposes `result.secret` only during that `update` call; copy it only if the app truly needs to retain it. Results distinguish `ok`, `not_found`, `failed`, `rejected`, `recording_unsupported`, and `cancelled`. Credential reads are rejected with `recording_unsupported` before the OS credential store is touched whenever session recording is active. Set/delete remain available, but their secret bytes never enter effect-result journal records. This boundary does not redact secrets that an app independently places in ordinary input events or other recorded data. + ## Audio streaming and the track cache `fx.playAudio` resolves its source in a fixed order: the local `path` first; when it is absent or missing, the `url` — where a verified cache entry at `cache_path` plays as a plain local file (no network), and anything else streams progressively (playback starts while bytes are still arriving, never download-then-play) while the same bytes fill the cache for the next play. `expected_bytes` (the track's known size, from a manifest) is the integrity gate: a cache entry or a finished download whose size disagrees never plays — it is discarded and re-streamed. While a stream waits for bytes, position events carry `buffering = true`: an honest third UI state, distinct from playing and paused, since the transport is not paused but nothing is coming out of the speakers. Seek and volume work mid-stream; a network failure mid-stream (or offline with a cold cache) delivers one explicit `.failed` Msg. diff --git a/docs/src/app/docs/native-ui/page.mdx b/docs/src/app/docs/native-ui/page.mdx index ae6e49b90..9abb92010 100644 --- a/docs/src/app/docs/native-ui/page.mdx +++ b/docs/src/app/docs/native-ui/page.mdx @@ -432,6 +432,25 @@ Files ride the same channel: `fx.writeFile` / `fx.readFile` persist app state .saved => |result| model.noteSaved(result.outcome), ``` +Secrets use the same typed update-side shape without an app-specific Keychain bridge. `fx.setCredential`, `fx.getCredential`, and `fx.deleteCredential` address an OS credential by the stable `(service, account)` pair and deliver one `EffectCredentialResult`: + +```zig +.load_token => fx.getCredential(.{ + .key = credential_key, + .service = "com.example.notes.openai", + .account = "default", + .on_result = Effects.credentialMsg(.credential_result), +}), +.credential_result => |result| switch (result.outcome) { + .ok => model.useToken(result.secret), // valid only during this update + .not_found => model.askForToken(), + .recording_unsupported => model.explainRecordingRestriction(), + else => model.noteCredentialFailure(result.outcome), +}, +``` + +Set inputs are copied before the platform call and effect-owned secret buffers are securely wiped. A successful get's secret is drain scratch: copy only what must outlive the update. Session recording rejects gets before touching the credential store and journals only operation/outcome metadata for set and delete; it never journals a credential effect's secret bytes. Ordinary UI input events are outside that guarantee, so do not treat the effects boundary as general-purpose secret redaction. + Failure and overflow are always visible: a spawn that cannot run delivers an exit Msg with reason `rejected`, a fetch that cannot run delivers a response Msg with outcome `rejected`, and a file effect that cannot run delivers a result Msg with outcome `rejected`; dropped or truncated lines carry counts and flags; `cancel` kills and reaps the process and always ends in exactly one `cancelled` exit Msg, with no further line Msgs after it. Tests use the fake executor (`effects.executor = .fake`) to assert on spawn, fetch, and file requests and feed synthetic lines, stderr (`feedStderr`, collect spawns), exits, responses, and file results back deterministically — set it before the first frame and `init_fx` boot spawns are recorded too. See `examples/effects-probe`. For timestamps, the facade owns the clocks (Zig 0.16 puts `std.time` behind `std.Io`, which `update` never sees): `native_sdk.nowMs()` / `nowNanoseconds()` read the wall clock and `monotonicMs()` / `monotonicNanoseconds()` the duration clock. Time-dependent logic stores the `native_sdk.Clock` seam in the model (`.system` by default) so tests substitute a deterministic `native_sdk.TestClock` and advance it by hand. diff --git a/skill-data/native-ui/SKILL.md b/skill-data/native-ui/SKILL.md index 8fca6b661..beea8eecb 100644 --- a/skill-data/native-ui/SKILL.md +++ b/skill-data/native-ui/SKILL.md @@ -692,6 +692,36 @@ Clipboard rules: - Writes are text/plain and replace the clipboard whole; rich-data clipboard stays on the runtime API (`runtime.writeClipboardData`). - In the fake executor: `pendingClipboardAt(0)` records `key`/`op`/`text` for assertions; `feedClipboardResult(key, .ok, "pasted")` answers a read, `feedClipboardResult(key, .ok, "")` acknowledges a write; failure outcomes pass through as fed. Under the real executor the test harness's null platform records the write — assert `harness.null_platform.lastClipboardData()`. +`fx.setCredential` / `fx.getCredential` / `fx.deleteCredential` expose the platform credential store directly to Zig `UiApp.update`; do not add an app-specific Keychain bridge. Each credential is addressed by a stable `(service, account)` pair and produces exactly one typed terminal: + +```zig +pub const Msg = union(enum) { + load_token, + credential_result: native_sdk.EffectCredentialResult, +}; + +.load_token => fx.getCredential(.{ + .key = credential_key, + .service = "com.example.notes.openai", + .account = "default", + .on_result = Effects.credentialMsg(.credential_result), +}), +.credential_result => |result| switch (result.outcome) { + .ok => model.useToken(result.secret), // COPY if it must outlive this update + .not_found => model.askForToken(), + .recording_unsupported => model.explainRecordingRestriction(), + else => model.noteCredentialFailure(result.outcome), +}, +``` + +Credential rules: + +- `service`, `account`, and set `secret` are required, NUL-free, copied at call time, and bounded by `max_effect_credential_service_bytes` (128), `max_effect_credential_account_bytes` (256), and `max_effect_credential_secret_bytes` (4096). Never truncate secrets. +- Outcomes are `.ok`, `.not_found`, `.failed`, `.rejected`, `.recording_unsupported`, and `.cancelled`. All three operations share the 16 general effect slots and key namespace. +- A successful get's `result.secret` is valid only during its receiving update. Set copies and delivered get buffers are securely wiped before release. +- Session recording rejects get before touching the OS store and never puts secret bytes in credential effect records. Set/delete still run and journal metadata. This does not redact a secret the app independently places in ordinary input events or other recorded data. +- In the fake executor: `pendingCredentialAt(0)` records `key`/`op`/`service`/`account` and a set's `secret`; `feedCredentialResult(key, .ok, "token")` answers a get, while set/delete feeds ignore the bytes. + `fx.startTimer` / `fx.cancelTimer` are key-based timers on the same channel — an auto-refresh, a poll, a debounce — one-shot or repeating, each fire delivered as one `on_fire` Msg. Timers are their own fixed table (16, `max_effect_timers`) and their own key namespace: they consume none of the 16 effect slots and never collide with spawn/fetch/file keys: ```zig @@ -805,6 +835,7 @@ The `.wake` platform event is how live platforms marshal worker completions onto > - Fetch: `pendingFetchAt(index: usize) ?FetchRequest` · `feedResponse(key: u64, status: u16, body: []const u8)` · `feedResponseOutcome(key: u64, outcome: EffectFetchOutcome, status: u16, body: []const u8)`. > - Files: `pendingFileAt(index: usize) ?FileRequest` · `feedFileResult(key: u64, outcome: EffectFileOutcome, bytes: []const u8)`. > - Clipboard: `pendingClipboardAt(index: usize) ?ClipboardRequest` · `feedClipboardResult(key: u64, outcome: EffectClipboardOutcome, text: []const u8)`. +> - Credentials: `pendingCredentialAt(index: usize) ?CredentialRequest` · `feedCredentialResult(key: u64, outcome: EffectCredentialOutcome, secret: []const u8)`. > - Host commands: `pendingHostAt(index: usize) ?HostRequest` · `feedHostResult(key: u64, ok: bool, bytes: []const u8)`. > - Timers: `pendingTimerAt(index: usize) ?TimerRequest` · `fireTimer(key: u64)` (one-shot slots retire after the fire). > - Audio (one channel): `pendingAudio() ?AudioRequest` · `feedAudioEvent(kind: EffectAudioEventKind, position_ms: u64, duration_ms: u64, playing: bool)` · `feedAudioEventBuffering(kind, position_ms, duration_ms, playing, buffering: bool)` · `feedAudioSpectrum(bands: [32]u8, position_ms: u64, duration_ms: u64)` · `audioSnapshot() AudioSnapshot`. diff --git a/src/root.zig b/src/root.zig index 8b057ea75..0550057b7 100644 --- a/src/root.zig +++ b/src/root.zig @@ -68,6 +68,12 @@ pub const EffectClipboardOp = runtime.EffectClipboardOp; pub const EffectClipboardOutcome = runtime.EffectClipboardOutcome; pub const EffectClipboardResult = runtime.EffectClipboardResult; pub const max_effect_clipboard_bytes = runtime.max_effect_clipboard_bytes; +pub const EffectCredentialOp = runtime.EffectCredentialOp; +pub const EffectCredentialOutcome = runtime.EffectCredentialOutcome; +pub const EffectCredentialResult = runtime.EffectCredentialResult; +pub const max_effect_credential_service_bytes = runtime.max_effect_credential_service_bytes; +pub const max_effect_credential_account_bytes = runtime.max_effect_credential_account_bytes; +pub const max_effect_credential_secret_bytes = runtime.max_effect_credential_secret_bytes; pub const TimerMode = runtime.TimerMode; pub const EffectTimer = runtime.EffectTimer; pub const EffectTimerOutcome = runtime.EffectTimerOutcome; diff --git a/src/runtime/effects.zig b/src/runtime/effects.zig index 2ab31881f..d566a0515 100644 --- a/src/runtime/effects.zig +++ b/src/runtime/effects.zig @@ -69,6 +69,7 @@ const canvas_limits = @import("canvas_limits.zig"); const platform = @import("../platform/root.zig"); const runtime_clock = @import("clock.zig"); const pty_transport = @import("pty.zig"); +const validation = @import("validation.zig"); /// Maximum in-flight effects (spawn slots / worker threads). pub const max_effects: usize = 16; @@ -190,6 +191,13 @@ pub const default_channel_wake_join_deadline_ms: u64 = 5_000; /// must never pass for the whole one). pub const max_effect_clipboard_bytes: usize = platform.max_clipboard_data_bytes; +/// Credential-effect bounds are the platform credential-store bounds. +/// Requests over these limits are rejected before the platform service +/// is called; secrets are never truncated. +pub const max_effect_credential_service_bytes: usize = platform.max_credential_service_bytes; +pub const max_effect_credential_account_bytes: usize = platform.max_credential_account_bytes; +pub const max_effect_credential_secret_bytes: usize = platform.max_credential_secret_bytes; + /// Maximum bytes of a host request's (or fire-and-forget host send's) /// name. Mirrors the transpiled-core command wire format, whose name /// field carries a one-byte length. @@ -541,6 +549,42 @@ pub const EffectClipboardResult = struct { dropped_before: u32 = 0, }; +/// Which secure credential-store operation a credential effect performs. +pub const EffectCredentialOp = enum { set, get, delete }; + +/// The terminal outcome of one credential effect. +pub const EffectCredentialOutcome = enum { + /// The operation completed. A successful get returns the credential + /// in `EffectCredentialResult.secret` for the current update only. + ok, + /// No credential exists for the requested service/account pair. + not_found, + /// The platform credential store refused or could not complete the + /// operation. + failed, + /// Validation, capacity, duplicate-key, or service binding rejected + /// the request before it ran. + rejected, + /// Reads are deliberately unavailable while session recording is + /// active so secrets can never enter a journal. + recording_unsupported, + /// `cancel(key)` ended delivery. A real set/delete may already have + /// completed because credential services run synchronously. + cancelled, +}; + +/// Payload for credential-effect Msg constructors. Exactly one terminal +/// result is delivered per accepted operation. `secret` is non-empty only +/// for a successful get and is valid only during the update that receives +/// it; copy what the model needs and avoid retaining it longer than needed. +pub const EffectCredentialResult = struct { + key: u64, + op: EffectCredentialOp = .get, + outcome: EffectCredentialOutcome = .ok, + secret: []const u8 = "", + dropped_before: u32 = 0, +}; + /// Payload for `on_result` Msg constructors of host requests /// (`hostRequest` — the generic named host call behind transpiled /// cores' `request` wire records). Exactly one terminal per request, @@ -1743,6 +1787,10 @@ pub const EffectResultKind = enum(u8) { /// opposite of the channel records' inline argument. Exit records /// ride `code`/`exit_reason` plus the pty fields. pty = 15, + /// One secure credential-store terminal. Credential secrets never + /// ride `payload`: get is rejected while recording, and set/delete + /// records contain only operation and outcome metadata. + credential = 16, }; /// Journaled wall-clock reads buffered for replay (`Effects.wallMs`). @@ -1815,6 +1863,8 @@ pub const EffectResultRecord = struct { file_outcome: EffectFileOutcome = .ok, clipboard_op: EffectClipboardOp = .write, clipboard_outcome: EffectClipboardOutcome = .ok, + credential_op: EffectCredentialOp = .get, + credential_outcome: EffectCredentialOutcome = .ok, timer_timestamp_ns: u64 = 0, timer_outcome: EffectTimerOutcome = .fired, /// `.clock` records: the wall-clock value `Effects.wallMs` returned. @@ -2401,6 +2451,7 @@ pub fn Effects(comptime Msg: type) type { pub const ResponseMsgFn = *const fn (response: EffectResponse) Msg; pub const FileMsgFn = *const fn (result: EffectFileResult) Msg; pub const ClipboardMsgFn = *const fn (result: EffectClipboardResult) Msg; + pub const CredentialMsgFn = *const fn (result: EffectCredentialResult) Msg; pub const TimerMsgFn = *const fn (timer: EffectTimer) Msg; pub const AudioMsgFn = *const fn (event: EffectAudio) Msg; pub const VideoMsgFn = *const fn (event: EffectVideo) Msg; @@ -2468,6 +2519,17 @@ pub fn Effects(comptime Msg: type) type { }.make; } + /// Comptime Msg constructor for credential effects: + /// `credentialMsg(.credential_result)` builds a Msg whose payload + /// is `native_sdk.EffectCredentialResult`. + pub fn credentialMsg(comptime tag: std.meta.Tag(Msg)) CredentialMsgFn { + return struct { + fn make(result: EffectCredentialResult) Msg { + return @unionInit(Msg, @tagName(tag), result); + } + }.make; + } + /// Comptime Msg constructor for `on_fire` of fx timers: /// `timerMsg(.refresh_tick)` builds /// `Msg{ .refresh_tick = timer }` — the variant's payload type @@ -2709,6 +2771,44 @@ pub fn Effects(comptime Msg: type) type { text: []const u8 = "", }; + pub const SetCredentialOptions = struct { + /// Caller-chosen identity in the shared keyed-effect space. + key: u64, + /// Credential-store namespace. Both fields are required, + /// copied at call time, and must contain no NUL bytes. + service: []const u8, + account: []const u8, + /// Secret bytes to store. Copied at call time and wiped from + /// effect-owned memory after the platform call. + secret: []const u8, + on_result: ?CredentialMsgFn = null, + }; + + pub const GetCredentialOptions = struct { + key: u64, + service: []const u8, + account: []const u8, + on_result: ?CredentialMsgFn = null, + }; + + pub const DeleteCredentialOptions = struct { + key: u64, + service: []const u8, + account: []const u8, + on_result: ?CredentialMsgFn = null, + }; + + /// A recorded credential request exposed by the fake executor. + /// Slices point into effect-owned slot storage until completion. + pub const CredentialRequest = struct { + key: u64, + op: EffectCredentialOp, + service: []const u8, + account: []const u8, + /// Present only for `.set`; empty for `.get` and `.delete`. + secret: []const u8 = "", + }; + pub const HostRequestOptions = struct { /// Caller-chosen identity. Same key space and slots as /// spawn/fetch/file/clipboard, but with the request key @@ -3311,9 +3411,9 @@ pub fn Effects(comptime Msg: type) type { /// thereby retires) it. const SlotState = enum(u8) { idle, running, done, draining }; - const SlotKind = enum(u8) { spawn, fetch, file, clipboard, host, image }; + const SlotKind = enum(u8) { spawn, fetch, file, clipboard, credential, host, image }; - const EntryKind = enum(u8) { line, exit, response, file, clipboard, host, image, channel, pty }; + const EntryKind = enum(u8) { line, exit, response, file, clipboard, credential, host, image, channel, pty }; const Entry = struct { kind: EntryKind = .line, @@ -3348,6 +3448,11 @@ pub fn Effects(comptime Msg: type) type { /// `line_len`. clipboard_op: EffectClipboardOp = .write, clipboard_outcome: EffectClipboardOutcome = .ok, + /// `.credential` entries use the slot buffer only for a get + /// result. Set secrets are wiped immediately after the + /// synchronous platform call and never reach this entry. + credential_op: EffectCredentialOp = .get, + credential_outcome: EffectCredentialOutcome = .ok, /// `.host` entries: which route the result takes (true = the /// ok arm). The bytes stay in the slot's heap buffer (taken /// at drain, like a fetch body) with their length in @@ -3398,6 +3503,7 @@ pub fn Effects(comptime Msg: type) type { response_fn: ?ResponseMsgFn = null, file_fn: ?FileMsgFn = null, clipboard_fn: ?ClipboardMsgFn = null, + credential_fn: ?CredentialMsgFn = null, host_fn: ?HostMsgFn = null, image_fn: ?ImageMsgFn = null, /// `.line` entries whose payload exceeds the inline buffer @@ -3424,6 +3530,13 @@ pub fn Effects(comptime Msg: type) type { response: struct { response: EffectResponse, response_fn: ?ResponseMsgFn }, file: struct { result: EffectFileResult, file_fn: ?FileMsgFn }, clipboard: struct { result: EffectClipboardResult, clipboard_fn: ?ClipboardMsgFn }, + credential: struct { + result: EffectCredentialResult, + credential_fn: ?CredentialMsgFn, + /// Deterministic validation/capacity refusals regenerate + /// during replay and therefore must not be journal-fed. + regenerates: bool, + }, timer: struct { timer: EffectTimer, timer_fn: ?TimerMsgFn }, /// `.host` terminals produced on the loop thread: rejections /// (`rejected = true`, static bytes, regenerated under @@ -3487,6 +3600,7 @@ pub fn Effects(comptime Msg: type) type { .response => |*entry| entry.response.dropped_before +|= count, .file => |*entry| entry.result.dropped_before +|= count, .clipboard => |*entry| entry.result.dropped_before +|= count, + .credential => |*entry| entry.result.dropped_before +|= count, // EffectTimer carries no drop counter; a repeating // timer's next fire replaces the lost one anyway. .timer => {}, @@ -3530,6 +3644,7 @@ pub fn Effects(comptime Msg: type) type { .response => |entry| entry.response.dropped_before, .file => |entry| entry.result.dropped_before, .clipboard => |entry| entry.result.dropped_before, + .credential => |entry| entry.result.dropped_before, .timer => 0, .audio => 0, .pty => 0, @@ -3813,6 +3928,7 @@ pub fn Effects(comptime Msg: type) type { on_response: ?ResponseMsgFn = null, on_file: ?FileMsgFn = null, on_clipboard: ?ClipboardMsgFn = null, + on_credential: ?CredentialMsgFn = null, on_host: ?HostMsgFn = null, /// Set by `cancel` before any kill attempt; read by the /// worker so a cancel that lands before the process spawns @@ -3899,6 +4015,12 @@ pub fn Effects(comptime Msg: type) type { file_op: EffectFileOp = .read, // ---- clipboard-only fields (kind == .clipboard) ---- clipboard_op: EffectClipboardOp = .write, + // ---- credential-only fields (kind == .credential) ---- + credential_op: EffectCredentialOp = .get, + credential_service_storage: [max_effect_credential_service_bytes]u8 = undefined, + credential_service_len: usize = 0, + credential_account_storage: [max_effect_credential_account_bytes]u8 = undefined, + credential_account_len: usize = 0, // ---- image-only fields (kind == .image) ---- on_image: ?ImageMsgFn = null, /// The local source path (the URL rides `url_storage`, a @@ -3973,6 +4095,14 @@ pub fn Effects(comptime Msg: type) type { return slot.url_storage[0..slot.url_len]; } + fn credentialService(slot: *const Slot) []const u8 { + return slot.credential_service_storage[0..slot.credential_service_len]; + } + + fn credentialAccount(slot: *const Slot) []const u8 { + return slot.credential_account_storage[0..slot.credential_account_len]; + } + /// A host request's service name (they share the fetch URL /// storage — a slot is one occupancy at a time). fn hostName(slot: *const Slot) []const u8 { @@ -4414,6 +4544,10 @@ pub fn Effects(comptime Msg: type) type { /// when the next response or file result drains, or at /// `deinit`). drain_fetch_body: ?[]u8 = null, + /// Most recently delivered credential-read bytes. Kept alive + /// through the receiving update, then securely zeroed and freed + /// before the next credential delivery or at deinit. + drain_credential_secret: ?[]u8 = null, /// The collect buffer of the most recently delivered collect /// exit, keeping `EffectExit.output` valid while `update` runs /// (freed when the next collect exit drains, or at `deinit`). @@ -4881,6 +5015,7 @@ pub fn Effects(comptime Msg: type) type { self.clearQueue(); for (&self.slots) |*slot| { if (slot.fetch_buffer) |buffer| { + if (slot.kind == .credential) std.crypto.secureZero(u8, buffer); self.allocator.free(buffer); slot.fetch_buffer = null; } @@ -4897,6 +5032,11 @@ pub fn Effects(comptime Msg: type) type { self.allocator.free(buffer); self.drain_fetch_body = null; } + if (self.drain_credential_secret) |buffer| { + std.crypto.secureZero(u8, buffer); + self.allocator.free(buffer); + self.drain_credential_secret = null; + } if (self.drain_collect_output) |buffer| { self.allocator.free(buffer); self.drain_collect_output = null; @@ -7277,6 +7417,198 @@ pub fn Effects(comptime Msg: type) type { self.wakeHost(); } + /// Store a secret in the platform credential store. The service, + /// account, and secret are copied before the synchronous platform + /// call; the effect-owned secret copy is wiped immediately after + /// that call returns. + pub fn setCredential(self: *Self, options: SetCredentialOptions) void { + const credential: platform.Credential = .{ + .service = options.service, + .account = options.account, + .secret = options.secret, + }; + validation.validateCredential(credential) catch { + return self.rejectCredential(options.key, .set, options.on_result); + }; + self.startCredential(options.key, .set, options.service, options.account, options.secret, options.on_result); + } + + /// Read a secret from the platform credential store. Reads are + /// rejected while session recording is bound, before any platform + /// service is called, so journal files cannot capture credentials. + pub fn getCredential(self: *Self, options: GetCredentialOptions) void { + const credential_key: platform.CredentialKey = .{ + .service = options.service, + .account = options.account, + }; + validation.validateCredentialKey(credential_key) catch { + return self.rejectCredential(options.key, .get, options.on_result); + }; + self.startCredential(options.key, .get, options.service, options.account, "", options.on_result); + } + + /// Delete one service/account credential. A missing credential is + /// reported as `.not_found`, not collapsed into platform failure. + pub fn deleteCredential(self: *Self, options: DeleteCredentialOptions) void { + const credential_key: platform.CredentialKey = .{ + .service = options.service, + .account = options.account, + }; + validation.validateCredentialKey(credential_key) catch { + return self.rejectCredential(options.key, .delete, options.on_result); + }; + self.startCredential(options.key, .delete, options.service, options.account, "", options.on_result); + } + + fn startCredential( + self: *Self, + key: u64, + op: EffectCredentialOp, + service: []const u8, + account: []const u8, + secret: []const u8, + on_result: ?CredentialMsgFn, + ) void { + self.reclaimSlots(); + const fake = self.executor == .fake; + if (!fake and self.services == null) return self.rejectCredential(key, op, on_result); + if (self.keyOccupiedUntilDelivery(key)) return self.rejectCredential(key, op, on_result); + const slot_index = self.findIdleSlot() orelse return self.rejectCredential(key, op, on_result); + + const buffer_len: usize = switch (op) { + .set => @max(secret.len, 1), + .get => max_effect_credential_secret_bytes, + .delete => 1, + }; + const buffer = self.allocator.alloc(u8, buffer_len) catch { + return self.rejectCredential(key, op, on_result); + }; + + const slot = &self.slots[slot_index]; + slot.generation = self.next_generation; + self.next_generation +%= 1; + if (self.next_generation == 0) self.next_generation = 1; + slot.key = key; + slot.kind = .credential; + slot.credential_op = op; + slot.on_line = null; + slot.on_exit = null; + slot.on_response = null; + slot.on_file = null; + slot.on_clipboard = null; + slot.on_credential = on_result; + slot.on_host = null; + slot.cancel_requested.store(false, .release); + slot.dropped_pending = 0; + slot.dropped_total = 0; + @memcpy(slot.credential_service_storage[0..service.len], service); + slot.credential_service_len = service.len; + @memcpy(slot.credential_account_storage[0..account.len], account); + slot.credential_account_len = account.len; + if (slot.line_buffer) |old| { + self.allocator.free(old); + slot.line_buffer = null; + } + if (slot.fetch_buffer) |old| { + std.crypto.secureZero(u8, old); + self.allocator.free(old); + } + slot.fetch_buffer = buffer; + slot.payload_len = if (op == .set) secret.len else 0; + if (op == .set) @memcpy(buffer[0..secret.len], secret); + slot.body_len = 0; + slot.fake = fake; + slot.state.store(.running, .release); + + if (fake) return; + + // Preserve the same occupied-key window replay gets from + // its parked fake request, but never touch the OS store. + // The metadata-only terminal is the record that later + // retires replay's park. + if (op == .get and self.journal != null) { + var entry: Entry = .{ + .kind = .credential, + .slot_index = @intCast(slot_index), + .generation = slot.generation, + .key = key, + .credential_op = .get, + .credential_outcome = .recording_unsupported, + .credential_fn = on_result, + }; + slot.state.store(.draining, .release); + if (!self.enqueue(&entry)) { + self.releaseCredentialSlot(slot); + self.deliverLoopCredential(.{ + .key = key, + .op = .get, + .outcome = .recording_unsupported, + }, on_result, false); + } + self.wakeHost(); + return; + } + + const services = self.services.?; + const credential_key: platform.CredentialKey = .{ + .service = slot.credentialService(), + .account = slot.credentialAccount(), + }; + var outcome: EffectCredentialOutcome = .ok; + var result_len: usize = 0; + switch (op) { + .set => { + services.setCredential(.{ + .service = credential_key.service, + .account = credential_key.account, + .secret = buffer[0..slot.payload_len], + }) catch { + outcome = .failed; + }; + std.crypto.secureZero(u8, buffer); + }, + .get => { + if (services.getCredential(credential_key, buffer)) |value| { + if (value.len <= buffer.len) { + result_len = value.len; + if (value.ptr != buffer.ptr) @memcpy(buffer[0..value.len], value); + } else { + outcome = .failed; + } + } else |err| switch (err) { + error.CredentialNotFound => outcome = .not_found, + else => outcome = .failed, + } + }, + .delete => services.deleteCredential(credential_key) catch |err| switch (err) { + error.CredentialNotFound => outcome = .not_found, + else => outcome = .failed, + }, + } + slot.body_len = result_len; + var entry: Entry = .{ + .kind = .credential, + .slot_index = @intCast(slot_index), + .generation = slot.generation, + .key = key, + .line_len = @intCast(result_len), + .credential_op = op, + .credential_outcome = outcome, + .credential_fn = on_result, + }; + slot.state.store(.draining, .release); + if (!self.enqueue(&entry)) { + const fallback_outcome: EffectCredentialOutcome = if (op == .get and result_len > 0) .failed else outcome; + self.releaseCredentialSlot(slot); + self.deliverLoopCredential(.{ + .key = key, + .op = op, + .outcome = fallback_outcome, + }, on_result, false); + } + self.wakeHost(); + } + /// Fire-and-forget named host command: hand `name` + `payload` /// to the bound host services and move on — no key, no result, /// no Msg. This is the delivery path of a transpiled core's @@ -7516,6 +7848,19 @@ pub fn Effects(comptime Msg: type) type { self.deliverLoopClipboard(.{ .key = key_copy, .op = op, .outcome = .cancelled }, clipboard_fn); return; } + if (slot.kind == .credential) { + // No credential-store call happened in fake mode. + const credential_fn = slot.on_credential; + const op = slot.credential_op; + const key_copy = slot.key; + self.releaseCredentialSlot(slot); + self.deliverLoopCredential(.{ + .key = key_copy, + .op = op, + .outcome = .cancelled, + }, credential_fn, false); + return; + } if (slot.kind == .image) { // No cascade ran: retire the slot and surface the // terminal result now. @@ -8924,6 +9269,22 @@ pub fn Effects(comptime Msg: type) type { }); return clipboard_fn(entry.result); }, + .credential => |entry| { + // Regenerating pre-executor refusals are + // deliberately absent from the journal. All + // executor-truth terminals journal metadata + // even without a handler so replay can retire + // its parked fake slot. + if (!entry.regenerates) self.journalNote(.{ + .kind = .credential, + .key = entry.result.key, + .dropped = entry.result.dropped_before, + .credential_op = entry.result.op, + .credential_outcome = entry.result.outcome, + }); + const credential_fn = entry.credential_fn orelse continue; + return credential_fn(entry.result); + }, .timer => |entry| { const timer_fn = entry.timer_fn orelse continue; self.journalNote(.{ @@ -9405,6 +9766,55 @@ pub fn Effects(comptime Msg: type) type { const clipboard_fn = entry.clipboard_fn orelse continue; return clipboard_fn(result); }, + .credential => { + if (entry.generation != slot.generation) continue; + // Keep a successful get alive through its update, + // but wipe the previous get before replacing it. + if (self.drain_credential_secret) |old| { + std.crypto.secureZero(u8, old); + self.allocator.free(old); + } + self.drain_credential_secret = slot.fetch_buffer; + slot.fetch_buffer = null; + var result: EffectCredentialResult = if (cancelled) + .{ .key = entry.key, .op = entry.credential_op, .outcome = .cancelled } + else + .{ + .key = entry.key, + .op = entry.credential_op, + .outcome = entry.credential_outcome, + .secret = if (entry.credential_op == .get and entry.credential_outcome == .ok) + if (self.drain_credential_secret) |buffer| buffer[0..entry.line_len] else "" + else + "", + .dropped_before = entry.dropped_before, + }; + // This is defense in depth: getCredential refuses + // before touching the store whenever a recorder is + // bound. Even a manually fed fake result cannot + // smuggle a secret into that journal. + if (self.journal != null and result.secret.len != 0) { + if (self.drain_credential_secret) |buffer| std.crypto.secureZero(u8, buffer); + result.secret = ""; + result.outcome = .recording_unsupported; + } + self.journalNote(.{ + .kind = .credential, + .key = result.key, + .dropped = result.dropped_before, + .credential_op = result.op, + .credential_outcome = result.outcome, + }); + const credential_fn = entry.credential_fn orelse { + if (self.drain_credential_secret) |buffer| { + std.crypto.secureZero(u8, buffer); + self.allocator.free(buffer); + self.drain_credential_secret = null; + } + continue; + }; + return credential_fn(result); + }, .host => { // One terminal per host occupancy, mirroring // `.response`: a mismatched generation means the @@ -10324,6 +10734,86 @@ pub fn Effects(comptime Msg: type) type { self.wakeHost(); } + /// Number of active credential requests parked by the fake + /// executor (including session replay). + pub fn pendingCredentialCount(self: *Self) usize { + var count: usize = 0; + for (&self.slots) |*slot| { + if (slot.fake and slot.kind == .credential and slot.state.load(.acquire) == .running) count += 1; + } + return count; + } + + /// The `index`-th parked fake credential request in slot order. + pub fn pendingCredentialAt(self: *Self, index: usize) ?CredentialRequest { + var seen: usize = 0; + for (&self.slots) |*slot| { + if (!(slot.fake and slot.kind == .credential and slot.state.load(.acquire) == .running)) continue; + if (seen == index) return .{ + .key = slot.key, + .op = slot.credential_op, + .service = slot.credentialService(), + .account = slot.credentialAccount(), + .secret = if (slot.credential_op == .set) slot.fetchPayload() else "", + }; + seen += 1; + } + return null; + } + + /// Feed a fake credential terminal. `secret` is accepted only + /// for a successful get; over-bound data fails whole. Session + /// replay uses this with metadata-only recorded results, because + /// live recording never permits a successful credential read. + pub fn feedCredentialResult( + self: *Self, + key: u64, + outcome: EffectCredentialOutcome, + secret: []const u8, + ) error{EffectNotFound}!void { + const slot_index = self.findActiveFakeSlot(key, .credential) orelse return error.EffectNotFound; + const slot = &self.slots[slot_index]; + const buffer = slot.fetch_buffer orelse return error.EffectNotFound; + var delivered_len: usize = 0; + var delivered_outcome = outcome; + if (slot.credential_op == .get and outcome == .ok) { + if (secret.len > max_effect_credential_secret_bytes or secret.len > buffer.len) { + delivered_outcome = .failed; + } else { + delivered_len = secret.len; + @memcpy(buffer[0..delivered_len], secret); + } + } else if (slot.credential_op == .set) { + // The request copy is no longer needed once its fake + // executor answer arrives. + std.crypto.secureZero(u8, buffer); + } + slot.body_len = delivered_len; + var entry: Entry = .{ + .kind = .credential, + .slot_index = @intCast(slot_index), + .generation = slot.generation, + .key = slot.key, + .line_len = @intCast(delivered_len), + .credential_op = slot.credential_op, + .credential_outcome = delivered_outcome, + .credential_fn = slot.on_credential, + }; + slot.state.store(.draining, .release); + if (!self.enqueue(&entry)) { + const credential_fn = slot.on_credential; + const op = slot.credential_op; + const fallback_outcome: EffectCredentialOutcome = if (op == .get and delivered_len > 0) .failed else delivered_outcome; + self.releaseCredentialSlot(slot); + self.deliverLoopCredential(.{ + .key = entry.key, + .op = op, + .outcome = fallback_outcome, + }, credential_fn, false); + } + self.wakeHost(); + } + /// Number of recorded (still-active) fake image-load requests. pub fn pendingImageLoadCount(self: *Self) usize { var count: usize = 0; @@ -10632,6 +11122,32 @@ pub fn Effects(comptime Msg: type) type { }, clipboard_fn); } + fn rejectCredential(self: *Self, key: u64, op: EffectCredentialOp, credential_fn: ?CredentialMsgFn) void { + self.deliverLoopCredential(.{ + .key = key, + .op = op, + .outcome = .rejected, + }, credential_fn, true); + } + + /// Queue a loop-thread credential terminal. Secret bytes never + /// ride this path. Non-regenerating records stage even without a + /// Msg handler because replay needs their metadata terminal. + fn deliverLoopCredential( + self: *Self, + result: EffectCredentialResult, + credential_fn: ?CredentialMsgFn, + regenerates: bool, + ) void { + std.debug.assert(result.secret.len == 0); + if (credential_fn == null and regenerates) return; + self.deliverPending(.{ .credential = .{ + .result = result, + .credential_fn = credential_fn, + .regenerates = regenerates, + } }); + } + /// Queue a terminal clipboard result produced on the loop /// thread (rejections, fake cancels, feed fallbacks) for the /// next drain. Text here is always empty. @@ -11440,6 +11956,21 @@ pub fn Effects(comptime Msg: type) type { slot.state.store(.idle, .release); } + /// Credential slots may own a set copy or a retrieved secret; + /// wipe the full allocation before returning it to the allocator. + fn releaseCredentialSlot(self: *Self, slot: *Slot) void { + if (slot.fetch_buffer) |buffer| { + std.crypto.secureZero(u8, buffer); + self.allocator.free(buffer); + slot.fetch_buffer = null; + } + if (slot.line_buffer) |buffer| { + self.allocator.free(buffer); + slot.line_buffer = null; + } + slot.state.store(.idle, .release); + } + /// Free a spawn slot's collect and line buffers (if any) and /// return it to `.idle` (spawn-time failures, fake cancels, and /// feed fallbacks). Loop-thread only. @@ -12137,7 +12668,7 @@ pub fn Effects(comptime Msg: type) type { fn slotTerminalUndelivered(slot: *const Slot) bool { return switch (slot.kind) { .spawn => slot.collect_buffer != null or slot.exit_undelivered, - .fetch, .file, .clipboard, .host, .image => slot.fetch_buffer != null, + .fetch, .file, .clipboard, .credential, .host, .image => slot.fetch_buffer != null, }; } diff --git a/src/runtime/effects_credential_tests.zig b/src/runtime/effects_credential_tests.zig new file mode 100644 index 000000000..795d369d0 --- /dev/null +++ b/src/runtime/effects_credential_tests.zig @@ -0,0 +1,312 @@ +//! Zig `UiApp` credential-effect coverage. Platform credential storage +//! already exists; these tests cover the typed effect API, fake executor, +//! session-journal boundary, and real null-platform round trips. + +const std = @import("std"); +const geometry = @import("geometry"); +const app_manifest = @import("app_manifest"); +const core = @import("core.zig"); +const ui_app_model = @import("ui_app.zig"); +const effects_mod = @import("effects.zig"); +const session_journal = @import("session_journal.zig"); + +const canvas_label = "credential-canvas"; +const credential_views = [_]app_manifest.ShellView{ + .{ .label = canvas_label, .kind = .gpu_surface, .fill = true, .gpu_backend = .metal }, +}; +const credential_windows = [_]app_manifest.ShellWindow{.{ + .label = "main", + .title = "Credentials", + .width = 400, + .height = 300, + .views = &credential_views, +}}; +const credential_scene: app_manifest.ShellConfig = .{ .windows = &credential_windows }; + +const CredentialModel = struct { + result_count: usize = 0, + last_op: ?effects_mod.EffectCredentialOp = null, + last_outcome: ?effects_mod.EffectCredentialOutcome = null, + secret: [128]u8 = undefined, + secret_len: usize = 0, + + fn record(model: *CredentialModel, result: effects_mod.EffectCredentialResult) void { + model.result_count += 1; + model.last_op = result.op; + model.last_outcome = result.outcome; + model.secret_len = @min(result.secret.len, model.secret.len); + @memcpy(model.secret[0..model.secret_len], result.secret[0..model.secret_len]); + } + + fn secretSlice(model: *const CredentialModel) []const u8 { + return model.secret[0..model.secret_len]; + } +}; + +const CredentialMsg = union(enum) { + set, + get, + delete, + stop, + result: effects_mod.EffectCredentialResult, +}; + +const CredentialApp = ui_app_model.UiApp(CredentialModel, CredentialMsg); +const CredentialEffects = CredentialApp.Effects; +const credential_key: u64 = 73; + +var test_service: []const u8 = "dev.native-sdk.credentials"; +var test_account: []const u8 = "default"; +var test_secret: []const u8 = "test-secret"; + +fn credentialUpdate(model: *CredentialModel, msg: CredentialMsg, fx: *CredentialEffects) void { + switch (msg) { + .set => fx.setCredential(.{ + .key = credential_key, + .service = test_service, + .account = test_account, + .secret = test_secret, + .on_result = CredentialEffects.credentialMsg(.result), + }), + .get => fx.getCredential(.{ + .key = credential_key, + .service = test_service, + .account = test_account, + .on_result = CredentialEffects.credentialMsg(.result), + }), + .delete => fx.deleteCredential(.{ + .key = credential_key, + .service = test_service, + .account = test_account, + .on_result = CredentialEffects.credentialMsg(.result), + }), + .stop => fx.cancel(credential_key), + .result => |result| model.record(result), + } +} + +fn credentialView(ui: *CredentialApp.Ui, model: *const CredentialModel) CredentialApp.Ui.Node { + return ui.column(.{ .gap = 4, .padding = 8 }, .{ + ui.text(.{}, ui.fmt("{d} results", .{model.result_count})), + }); +} + +const Harness = struct { + harness: *core.TestHarness(), + app_state: *CredentialApp, + app: core.App, + + fn create() !Harness { + const harness = try core.TestHarness().create(std.testing.allocator, .{ .size = geometry.SizeF.init(400, 300) }); + errdefer harness.destroy(std.testing.allocator); + harness.null_platform.gpu_surfaces = true; + const app_state = try std.testing.allocator.create(CredentialApp); + errdefer std.testing.allocator.destroy(app_state); + app_state.* = CredentialApp.init(std.heap.page_allocator, .{}, .{ + .name = "effects-credentials", + .scene = credential_scene, + .canvas_label = canvas_label, + .update_fx = credentialUpdate, + .view = credentialView, + }); + const app = app_state.app(); + try harness.start(app); + try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_frame = .{ + .label = canvas_label, + .size = geometry.SizeF.init(400, 300), + .scale_factor = 1, + .frame_index = 1, + .timestamp_ns = 1_000_000, + .nonblank = true, + } }); + return .{ .harness = harness, .app_state = app_state, .app = app }; + } + + fn destroy(self: *Harness) void { + self.app_state.deinit(); + std.testing.allocator.destroy(self.app_state); + self.harness.destroy(std.testing.allocator); + } + + fn drainWakes(self: *Harness) !void { + var nudged = false; + while (self.harness.null_platform.takeWake()) |_| nudged = true; + if (nudged) try self.harness.runtime.dispatchPlatformEvent(self.app, .wake); + } +}; + +test "fake credential executor copies requests and feeds typed results" { + var h = try Harness.create(); + defer h.destroy(); + const fx = &h.app_state.effects; + fx.executor = .fake; + + var service = [_]u8{ 's', 'v', 'c' }; + var account = [_]u8{ 'a', 'c', 'c', 't' }; + var secret = [_]u8{ 't', 'o', 'k', 'e', 'n' }; + test_service = &service; + test_account = &account; + test_secret = &secret; + try h.app_state.dispatch(&h.harness.runtime, 1, .set); + @memset(&service, 'x'); + @memset(&account, 'y'); + @memset(&secret, 'z'); + + try std.testing.expectEqual(@as(usize, 1), fx.pendingCredentialCount()); + const request = fx.pendingCredentialAt(0).?; + try std.testing.expectEqual(effects_mod.EffectCredentialOp.set, request.op); + try std.testing.expectEqualStrings("svc", request.service); + try std.testing.expectEqualStrings("acct", request.account); + try std.testing.expectEqualStrings("token", request.secret); + try fx.feedCredentialResult(credential_key, .ok, "ignored"); + try h.drainWakes(); + try std.testing.expectEqual(effects_mod.EffectCredentialOutcome.ok, h.app_state.model.last_outcome.?); + try std.testing.expectEqual(@as(usize, 0), h.app_state.model.secret_len); + + test_service = "svc"; + test_account = "acct"; + try h.app_state.dispatch(&h.harness.runtime, 1, .get); + try fx.feedCredentialResult(credential_key, .ok, "retrieved-token"); + try h.drainWakes(); + try std.testing.expectEqual(effects_mod.EffectCredentialOp.get, h.app_state.model.last_op.?); + try std.testing.expectEqualStrings("retrieved-token", h.app_state.model.secretSlice()); + + try h.app_state.dispatch(&h.harness.runtime, 1, .delete); + try fx.feedCredentialResult(credential_key, .not_found, "ignored"); + try h.drainWakes(); + try std.testing.expectEqual(effects_mod.EffectCredentialOutcome.not_found, h.app_state.model.last_outcome.?); + try std.testing.expectEqual(@as(usize, 0), h.app_state.model.secret_len); +} + +test "credential validation duplicate keys and fake cancellation are explicit" { + var h = try Harness.create(); + defer h.destroy(); + const fx = &h.app_state.effects; + fx.executor = .fake; + + test_service = ""; + test_account = "default"; + test_secret = "secret"; + try h.app_state.dispatch(&h.harness.runtime, 1, .set); + try h.drainWakes(); + try std.testing.expectEqual(effects_mod.EffectCredentialOutcome.rejected, h.app_state.model.last_outcome.?); + try std.testing.expectEqual(@as(usize, 0), fx.pendingCredentialCount()); + + test_service = "dev.native-sdk.credentials"; + try h.app_state.dispatch(&h.harness.runtime, 1, .set); + try h.app_state.dispatch(&h.harness.runtime, 1, .get); + try h.drainWakes(); + try std.testing.expectEqual(effects_mod.EffectCredentialOp.get, h.app_state.model.last_op.?); + try std.testing.expectEqual(effects_mod.EffectCredentialOutcome.rejected, h.app_state.model.last_outcome.?); + try std.testing.expectEqual(@as(usize, 1), fx.pendingCredentialCount()); + + try h.app_state.dispatch(&h.harness.runtime, 1, .stop); + try h.drainWakes(); + try std.testing.expectEqual(effects_mod.EffectCredentialOutcome.cancelled, h.app_state.model.last_outcome.?); + try std.testing.expectEqual(@as(usize, 0), fx.pendingCredentialCount()); +} + +test "real credential effects set get delete and report missing values" { + var h = try Harness.create(); + defer h.destroy(); + test_service = "com.example.notes.openai"; + test_account = "default"; + test_secret = "sk-local-test"; + + try h.app_state.dispatch(&h.harness.runtime, 1, .set); + try h.drainWakes(); + try std.testing.expectEqual(effects_mod.EffectCredentialOutcome.ok, h.app_state.model.last_outcome.?); + try std.testing.expectEqualStrings(test_service, h.harness.null_platform.lastCredentialService()); + try std.testing.expectEqualStrings(test_account, h.harness.null_platform.lastCredentialAccount()); + try std.testing.expectEqualStrings(test_secret, h.harness.null_platform.lastCredentialSecret()); + + try h.app_state.dispatch(&h.harness.runtime, 1, .get); + try h.drainWakes(); + try std.testing.expectEqualStrings(test_secret, h.app_state.model.secretSlice()); + + try h.app_state.dispatch(&h.harness.runtime, 1, .delete); + try h.drainWakes(); + try std.testing.expectEqual(effects_mod.EffectCredentialOutcome.ok, h.app_state.model.last_outcome.?); + try h.app_state.dispatch(&h.harness.runtime, 1, .get); + try h.drainWakes(); + try std.testing.expectEqual(effects_mod.EffectCredentialOutcome.not_found, h.app_state.model.last_outcome.?); + try std.testing.expectEqual(@as(usize, 0), h.app_state.model.secret_len); +} + +test "session recording rejects reads and journals no secret bytes" { + var h = try Harness.create(); + defer h.destroy(); + test_service = "com.example.notes.openai"; + test_account = "default"; + test_secret = "journal-secret-canary"; + + const Capture = struct { + var records: [4]effects_mod.EffectResultRecord = undefined; + var count: usize = 0; + fn note(context: *anyopaque, record: effects_mod.EffectResultRecord) void { + _ = context; + records[count] = record; + count += 1; + } + }; + Capture.count = 0; + var context: u8 = 0; + h.app_state.effects.bindJournal(.{ .context = &context, .record_fn = Capture.note }); + + // Writes remain available, but their secret never enters the effect + // record. Reads return a metadata-only terminal without consulting + // the store. + try h.app_state.dispatch(&h.harness.runtime, 1, .set); + try h.drainWakes(); + try h.app_state.dispatch(&h.harness.runtime, 1, .get); + try h.drainWakes(); + + try std.testing.expectEqual(@as(usize, 2), Capture.count); + try std.testing.expectEqual(effects_mod.EffectCredentialOp.set, Capture.records[0].credential_op); + try std.testing.expectEqualStrings("", Capture.records[0].payload); + try std.testing.expectEqual(effects_mod.EffectCredentialOp.get, Capture.records[1].credential_op); + try std.testing.expectEqual(effects_mod.EffectCredentialOutcome.recording_unsupported, Capture.records[1].credential_outcome); + try std.testing.expectEqualStrings("", Capture.records[1].payload); + try std.testing.expectEqual(effects_mod.EffectCredentialOutcome.recording_unsupported, h.app_state.model.last_outcome.?); + try std.testing.expectEqual(@as(usize, 0), h.app_state.model.secret_len); + + var encoded_storage: [512]u8 = undefined; + for (Capture.records[0..Capture.count]) |record| { + const encoded = try session_journal.encodeEffect(record, &encoded_storage); + try std.testing.expect(std.mem.indexOf(u8, encoded, test_secret) == null); + } +} + +test "credential journal codec round trips metadata without secret material" { + const secret = "codec-secret-canary"; + var buffer: [512]u8 = undefined; + const encoded = try session_journal.encodeEffect(.{ + .kind = .credential, + .key = 99, + .credential_op = .get, + .credential_outcome = .recording_unsupported, + }, &buffer); + try std.testing.expect(std.mem.indexOf(u8, encoded, secret) == null); + const decoded = try session_journal.decodeEffect(encoded); + try std.testing.expectEqual(effects_mod.EffectResultKind.credential, decoded.kind); + try std.testing.expectEqual(@as(u64, 99), decoded.key); + try std.testing.expectEqual(effects_mod.EffectCredentialOp.get, decoded.credential_op); + try std.testing.expectEqual(effects_mod.EffectCredentialOutcome.recording_unsupported, decoded.credential_outcome); + try std.testing.expectEqualStrings("", decoded.payload); +} + +test "session replay parks credential reads until the recorded rejection feeds" { + var h = try Harness.create(); + defer h.destroy(); + const fx = &h.app_state.effects; + fx.armReplay(); + test_service = "com.example.notes.openai"; + test_account = "default"; + + try h.app_state.dispatch(&h.harness.runtime, 1, .get); + try std.testing.expectEqual(@as(usize, 1), fx.pendingCredentialCount()); + try fx.feedCredentialResult(credential_key, .recording_unsupported, ""); + try h.drainWakes(); + try std.testing.expectEqual(effects_mod.EffectCredentialOutcome.recording_unsupported, h.app_state.model.last_outcome.?); + try std.testing.expectEqual(@as(usize, 0), fx.pendingCredentialCount()); +} diff --git a/src/runtime/root.zig b/src/runtime/root.zig index e5c0a4d57..0e2429f16 100644 --- a/src/runtime/root.zig +++ b/src/runtime/root.zig @@ -88,6 +88,12 @@ pub const EffectClipboardOp = runtime_effects.EffectClipboardOp; pub const EffectClipboardOutcome = runtime_effects.EffectClipboardOutcome; pub const EffectClipboardResult = runtime_effects.EffectClipboardResult; pub const max_effect_clipboard_bytes = runtime_effects.max_effect_clipboard_bytes; +pub const EffectCredentialOp = runtime_effects.EffectCredentialOp; +pub const EffectCredentialOutcome = runtime_effects.EffectCredentialOutcome; +pub const EffectCredentialResult = runtime_effects.EffectCredentialResult; +pub const max_effect_credential_service_bytes = runtime_effects.max_effect_credential_service_bytes; +pub const max_effect_credential_account_bytes = runtime_effects.max_effect_credential_account_bytes; +pub const max_effect_credential_secret_bytes = runtime_effects.max_effect_credential_secret_bytes; pub const TimerMode = runtime_effects.TimerMode; pub const EffectTimer = runtime_effects.EffectTimer; pub const EffectTimerOutcome = runtime_effects.EffectTimerOutcome; diff --git a/src/runtime/session_journal.zig b/src/runtime/session_journal.zig index fdb2e9cf6..b81dae034 100644 --- a/src/runtime/session_journal.zig +++ b/src/runtime/session_journal.zig @@ -1023,6 +1023,8 @@ pub fn encodeEffect(record: EffectResultRecord, buffer: []u8) JournalError![]con try cursor.writeEnum(record.file_outcome); try cursor.writeEnum(record.clipboard_op); try cursor.writeEnum(record.clipboard_outcome); + try cursor.writeEnum(record.credential_op); + try cursor.writeEnum(record.credential_outcome); try cursor.writeInt(u64, record.timer_timestamp_ns); try cursor.writeEnum(record.timer_outcome); try cursor.writeInt(i64, record.clock_wall_ms); @@ -1090,6 +1092,8 @@ pub fn decodeEffect(bytes: []const u8) JournalError!EffectResultRecord { .file_outcome = try cursor.readEnum(runtime_effects.EffectFileOutcome), .clipboard_op = try cursor.readEnum(runtime_effects.EffectClipboardOp), .clipboard_outcome = try cursor.readEnum(runtime_effects.EffectClipboardOutcome), + .credential_op = try cursor.readEnum(runtime_effects.EffectCredentialOp), + .credential_outcome = try cursor.readEnum(runtime_effects.EffectCredentialOutcome), .timer_timestamp_ns = try cursor.readInt(u64), .timer_outcome = try cursor.readEnum(runtime_effects.EffectTimerOutcome), .clock_wall_ms = try cursor.readInt(i64), diff --git a/src/runtime/session_replay.zig b/src/runtime/session_replay.zig index b1a03a3a7..e06fe2181 100644 --- a/src/runtime/session_replay.zig +++ b/src/runtime/session_replay.zig @@ -228,6 +228,17 @@ pub fn replaySession( ); return error.ReplayDamagedRecord; } + // Credential journals are metadata-only by construction. + // A successful get is impossible while recording, and no + // operation ever writes secret bytes to payload. Refuse a + // hand-edited record instead of normalizing it silently. + if (effect.kind == .credential and credentialRecordDamaged(effect)) { + std.debug.print( + "replay refused after event {d}: credential record for key {d} claims .{s}/.{s} with {d} payload bytes - credential journals are metadata-only and recorded gets can never succeed, so the journal is damaged or hand-edited; re-record the session\n", + .{ report.events_replayed, effect.key, @tagName(effect.credential_op), @tagName(effect.credential_outcome), effect.payload.len }, + ); + return error.ReplayDamagedRecord; + } // Provenance consistency, gated BEFORE the regeneration // skip below: a `.data` or `.closed` channel record // stamped with `.rejected` provenance would be skipped @@ -545,6 +556,24 @@ fn channelRecordDamaged(record: journal.EffectResultRecord) bool { return record.channel_kind != .data and record.payload.len > 0; } +fn credentialRecordDamaged(record: journal.EffectResultRecord) bool { + if (record.payload.len != 0) return true; + return switch (record.credential_op) { + .set => switch (record.credential_outcome) { + .ok, .failed, .cancelled => false, + else => true, + }, + .get => switch (record.credential_outcome) { + .recording_unsupported, .cancelled => false, + else => true, + }, + .delete => switch (record.credential_outcome) { + .ok, .not_found, .failed, .cancelled => false, + else => true, + }, + }; +} + /// Whether a channel record's provenance stamp contradicts its event /// kind — RECORDER TRUTH: channel records journal from exactly two /// sites. The live drain (staged posts and the close marker) journals @@ -663,6 +692,9 @@ fn effectRegeneratesUnderReplay(record: journal.EffectResultRecord) bool { .response => record.fetch_outcome == .rejected, .file => record.file_outcome == .rejected, .clipboard => record.clipboard_outcome == .rejected, + // Admission rejections never journal. Every credential record + // that passes the shape gate above is executor truth and feeds. + .credential => false, // Audio rejections are loop-side validation (path bounds) that // refuses again; everything else — loaded acknowledgments, // position ticks, completions, platform failures — is an @@ -801,6 +833,27 @@ test "audio records outside the exact-integer scalar window are damaged" { try std.testing.expect(audioScalarsDamaged(record)); } +test "credential replay accepts only metadata shapes the recorder can produce" { + var record: journal.EffectResultRecord = .{ + .kind = .credential, + .key = 1, + .credential_op = .get, + .credential_outcome = .recording_unsupported, + }; + try std.testing.expect(!credentialRecordDamaged(record)); + record.payload = "secret"; + try std.testing.expect(credentialRecordDamaged(record)); + record.payload = ""; + record.credential_outcome = .ok; + try std.testing.expect(credentialRecordDamaged(record)); + + record.credential_op = .set; + record.credential_outcome = .ok; + try std.testing.expect(!credentialRecordDamaged(record)); + record.credential_outcome = .not_found; + try std.testing.expect(credentialRecordDamaged(record)); +} + /// Debug aid: `NATIVE_SDK_SESSION_REPLAY_DUMP=` writes each /// replay-rendered screenshot PNG so a mismatching pixel mark can be /// diffed against the recording's artifact. diff --git a/src/runtime/tests.zig b/src/runtime/tests.zig index f1826d63b..5621e587d 100644 --- a/src/runtime/tests.zig +++ b/src/runtime/tests.zig @@ -20,6 +20,7 @@ test { _ = @import("effects_fetch_tests.zig"); _ = @import("effects_file_tests.zig"); _ = @import("effects_clipboard_tests.zig"); + _ = @import("effects_credential_tests.zig"); _ = @import("effects_audio_tests.zig"); _ = @import("effects_video_tests.zig"); _ = @import("effects_image_tests.zig"); diff --git a/src/runtime/ui_app.zig b/src/runtime/ui_app.zig index 3ecf3b93d..4f463c6b4 100644 --- a/src/runtime/ui_app.zig +++ b/src/runtime/ui_app.zig @@ -1415,6 +1415,7 @@ pub fn UiAppWithFeatures(comptime ModelT: type, comptime MsgT: type, comptime fe .response => try self.effects.feedResponseOutcome(record.key, record.fetch_outcome, record.status, record.payload), .file => try self.effects.feedFileResult(record.key, record.file_outcome, record.payload), .clipboard => try self.effects.feedClipboardResult(record.key, record.clipboard_outcome, record.payload), + .credential => try self.effects.feedCredentialResult(record.key, record.credential_outcome, ""), // `.host` records ride the route in `code` (0 ok / 1 // err); rejections never reach here — they carry // `.rejected` and regenerate from the same From 74689c04781a3f47f9ef456c87c8bf88210ecd03 Mon Sep 17 00:00:00 2001 From: Marcus Schiesser Date: Wed, 5 Aug 2026 09:25:32 +0200 Subject: [PATCH 2/2] Fix duplicate validation import after upstream merge --- src/runtime/effects.zig | 1 - 1 file changed, 1 deletion(-) diff --git a/src/runtime/effects.zig b/src/runtime/effects.zig index 7809e921a..1783d7a55 100644 --- a/src/runtime/effects.zig +++ b/src/runtime/effects.zig @@ -75,7 +75,6 @@ const platform = @import("../platform/root.zig"); const validation = @import("validation.zig"); const runtime_clock = @import("clock.zig"); const pty_transport = @import("pty.zig"); -const validation = @import("validation.zig"); /// Maximum in-flight effects (spawn slots / worker threads). pub const max_effects: usize = 16;