Skip to content

fix(credentials): store the sign-in token through TinySecurity's keyring, roll back failed sign-ins, and guard artifact reads - #8536

Merged
M3gA-Mind merged 15 commits into
tinyhumansai:mainfrom
M3gA-Mind:chore/followup-repin-1103
Nov 5, 2026
Merged

M3gA-Mind merged 15 commits into
tinyhumansai:mainfrom
M3gA-Mind:chore/followup-repin-1103

Conversation

@M3gA-Mind

@M3gA-Mind M3gA-Mind commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Follow-up to #8530, rebased onto main after #8534, #8535, #8540 and #8542, which already landed the module re-pins, the retained-binding reuse for TinySecurity and the callback-bound modules, and the scope takeover for embedded runtimes. What remains here:

  1. Sign-in stores its token. Writing the session token goes through TinySecurity's keyring, which asks the user to approve the exact operation. The approval prompt only reached the app over Socket.IO, which connects after sign-in, so a first sign-in waited 120 s and failed. The app now polls pending keyring approvals while it has no socket.
  2. A failed sign-in is rolled back instead of leaving a half-created profile and a purged pre-login conversation store.
  3. A host that activates per-user profiles (CLI, TUI, headless core) reports restartRequired for a sign-in into a profile its admitted TinySecurity cannot serve, instead of failing the credential write.
  4. Artifact reads go through the protected-path guard (vendor/tiny-artifacts), plus the last gitlink left on a formerly fork-only merge (vendor/tinystoragedrivers).
  5. Native fixtures from a pinned release: TinySecurity and the four callback-bound modules load from a verified release with no local-file source, as the shipped app does, and keep serving.
  6. TinySecurity v0.19.0 and contract 2.0. The re-pin brings the module-side keyring consent fix (tinysecurity#51) and contract 2.0 (tinysecurity#43), which adds an abandoned execution outcome.

What was broken (on main)

  • First sign-in could not store its token. TinySecurity's keyring write asks the host for consent per operation. The module made a plain call to the host's confidential consent endpoint, which tinybus refuses, so every keyring write fell back to the encrypted file (fixed in tinysecurity v0.19.0, re-pinned here). With that fixed, the consent prompt still never reached the login screen: the renderer receives it only over Socket.IO, and the socket connects once a session exists. And the host gave up on the keyring call after tinybus' default 30 s while the prompt stays open for 120 s, so a user who took longer than 30 s to approve still failed sign-in.
  • A failed sign-in was not rolled back. It left users/<id>/ created and the config moved, and it had already deleted the pre-login conversations.
  • On per-user-profile hosts, signing into another profile failed closed. TinySecurity is admitted once per process for one profile's secret key, so the credential write was refused ("security secret scope changed after module admission").
  • Artifact file reads skipped the protected-path floor when reading a file artifact's bytes.

What changes

  • Keyring approvals before the socket connects. The host keeps each pending keyring prompt's display-safe metadata (never a secret) and serves it as openhuman.keyring_consent_list_pending_operations. While no socket is connected, the approval overlay polls it once a second, de-duplicated by request id against socket deliveries. The core also serves this list and openhuman.keyring_consent_respond_operation during its startup secret preflight, so a restarted desktop that reads a keyring-held token at startup can ask the user. They are the same class as approval_list_pending and approval_decide, already served there: local operator, the per-launch bearer, and they only resolve a request that is already pending, by its id; they create no grant.
  • Keyring deadlines in order. The host's keyring call now waits 140 s, TinySecurity's consent deadline is 125 s, and the host's own prompt wait stays 120 s. An unanswered prompt ends the same way every time: pending at the host first, then the module, then the call. All three are host settings: TinySecurity takes its consent deadline from the host's module config (KeyringConfig2.consent_timeout_ms, which it accepts up to 300 s), so no TinySecurity change is needed. A decided outcome nobody takes now expires after 30 s, and a waiter that goes away releases its slot, so abandoned prompts can no longer fill the 64-slot registry until restart.
  • Sign-in is atomic. Activation records what it changed. Any failure before the credential is stored restores the previous active user (or none) and removes a profile directory the sign-in created. The pre-login conversation purge runs only after the sign-in has committed.
  • Profile switch on per-user-profile hosts. A sign-in into a profile this process's TinySecurity cannot serve activates the profile, stores nothing under the old one and answers restartRequired: true. The auth state reports it whenever the active profile is not the one TinySecurity serves (its live scope owner, or once released, the admitting scope; Let a later embedded runtime take over TinySecurity's secret scope once the first is released #8540). The CLI exits 75 with "restart required to finish switching profiles"; a server or TUI keeps refusing the other scope until restarted.
  • Contract 2.0. The host now requires TinySecurity contract 2.0. A tool execution that ends without reporting a result (its audit guard is dropped) is recorded as abandoned, since its effects are unknown, instead of failed; the host's aborted outcome maps to the module's abandoned.
  • Artifact reads. artifacts::tiny_store::guarded_roots installs TinySecurity's protected-path check as tiny-artifacts' async guard, so a protected file is refused before its bytes are read.

What users see

  • Desktop: signing in shows the keyring approval on the login screen; approving it stores the token in the OS keychain and signs in, with no relaunch. On the next launch the app asks once more, to read the stored token, and signs in when approved. How often the user is asked follows TinySecurity's per-operation consent: one prompt per keyring operation, and a config-backed credential save writes every stored profile, so it asks once per profile. Whether to group these into a per-session grant is an open product decision, not part of this PR. Denying it fails the sign-in cleanly and leaves the device as it was. The desktop keeps its profile in place (see Desktop sign-in never activates the per-user profile (all accounts share users/local) since 2026-10-27 #8543), so it never needs a restart to sign in.
  • CLI: an auth command that moves to another profile prints "restart required to finish switching profiles" and exits 75; signing in again after the restart completes it.
  • Long-running servers and the TUI report restartRequired and keep refusing the other profile's security calls until restarted.

Moved gitlinks

Gitlink Move Notes
tinystoragedrivers 2d23a6a → 5e8c837 (#91 merge) library; no record
tiny-artifacts b8920f7 → 6ee41ad (#15 merge) library; no record
tinysecurity b53eceb (v0.18.4) → 17c1284 (v0.19.0) record and all 11 digests from checksum.toml, each checked against the downloaded archive
  • Each is a descendant of its previous pin (git merge-base --is-ancestor old new) and is on the canonical repository's main.
  • Lockfiles: minimal hunks (cap-fs-ext for tiny-artifacts, libc for tinystoragedrivers-fence, the tinysecurity-bus version line) in both Cargo.lock and crates/openhuman-app/Cargo.lock, checked with --locked. No cargo update.

Rollback

Contract 2.0 can write abandoned into approval and audit rows. A host or module on contract 1.x cannot decode those rows, so rolling back past this PR on a device that has recorded one needs those rows migrated or cleared first.

Validation (cuttlefish)

The full gate set runs once, as this PR's final CI, after the tinysecurity v0.19.0 re-pin. Results so far on the rebased branch:

Check Result
fmt, rust:layout, process-statics, module-boundaries, feature-forwarding pass
check-module-pins (14 records), check-submodule-monotonic vs main (2 forward) pass
cargo check --locked: core, CLI, app, openhuman-modules-linked --all-targets --features all pass
clippy (product) 1 finding, type_complexity at observability_reporting.rs:535, untouched here and red on main
End-to-end sign-in through the desktop boot path pending (v0.19.0)

Known red, pre-existing on main (not changed here)

From runs against 9d3a7ae; the final CI run re-checks each against 806fc50.

  • Two registry count tests: contract_tests::a_product_feature_build_carries_all_thirteen_records and contract_tests::every_record_matches_its_contract_constants (the security-module build carries 14 records).
  • Three host tests that need staged module assets: documents::tests::held_outputs_are_released_after_success_read_failure_and_cancellation, hosting::test::providers_and_launch_use_the_declared_bus_members_and_confidential_execute, hosting::test::workspace_resolution_refuses_absolute_traversal_and_symlink_escape.
  • tests/security_native_fixture.rs has an unused import (openhuman_core::storage::DocumentStore), so clippy on that test target fails with -D warnings. This PR doesn't touch the file.
  • agent::harness::tool_result_artifacts::tests::a_wrapped_read_is_pointed_at_the_materialised_file depends on test order: the shared lib test process admits TinySecurity under the first test's secret scope.
  • Four artifacts::-area tests fail even one-per-process (nextest), here and on main:
    • artifacts::files::tests::the_escape_guard_keeps_the_credential_store_floor, …::the_escape_guard_rejects_a_file_outside_its_folder and …::a_legacy_path_with_parent_components_is_rejected. All three hit "file is in a protected location" before the check they assert.
    • agent::subagent_host::ops::runner_artifacts::tests::byte_policy_projects_a_parent_readable_artifact_before_the_result_cap. It gets [REDACTED] instead of the full result.
    • One-per-process, this PR fails 4 tests in this area and main fails 35.
  • GitHub Lanes (rust-lint). On main, run the same way, these three steps fail identically:
    • clippy-product: type_complexity at observability_reporting.rs:535.
    • tinyhumans-clippy: unused pub use backend_error_code::HOSTED_VOICE_UNAVAILABLE_USER_MESSAGE at openhuman-tinyhumans/src/inference/mod.rs:37.
    • embed-offline-examples: the linux_fleet example builds without security-module, so its prompt is held ("Prompt flagged for security review"). Behind it, the profiles example also fails, with "TinySecurity secret scope is unavailable".
  • GitHub Lanes (checks), frontend:scripts-self-tests:
    • Failure: two stage-modules.test.mjs Windows VC-runtime cases fail with spawnSync unzip ENOENT.
    • Cause: stage-modules.mjs extracts zips with unzip, and the CI image (.github/Dockerfile) doesn't install it.
    • Fix (outside this PR): add unzip to that image.

Follow-ups

  • Desktop per-user isolation (Related: Desktop sign-in never activates the per-user profile (all accounts share users/local) since 2026-10-27 #8543). Since 2026-10-27 the desktop never activates users/<id>, so every desktop account shares users/local. Restoring it brings back TinySecurity's one-identity-per-process limit on the desktop, which needs deferred admission with a settlement rebind (config snapshot, approval gate, live policy, text seams, MCP jail).
  • Production audit for failed, cancelled and abandoned tool calls. ApprovalSecurityMiddleware has no record_terminal override in production, so the audit rows of failed, cancelled and abandoned tool calls stay Reserved. A production override was added in 2f60fd8 and removed in 26d12f6 (2026-10-25); restoring it is a behaviour change outside this PR.
  • search::providers::tests::role_keys_match_the_contract_serde_names fails on main since chore(modules): reconcile six module pins with their releases #8534: tinysearch's contract has four roles (Role::Images, tinysearch 6ec9048) and the host's SEARCH_ROLES lists three. Not changed here.
  • Remove the unregistered duplicate security/credentials/ops/credential.rs and its sibling modules. core/all.rs registers credentials::, not this copy.
  • Wire the native approval engine tests back in. security/approval/native/engine_tests.rs (and engine_tool_trust_tests.rs, engine_origin_tests.rs) is declared by no mod and gated on an undeclared feature, so it never compiles; nothing checks the outcome the module stores. This PR's engine_outcome_tests covers the host's outcome mapping only.
  • A hung keyring store after a timely approval. TinySecurity's keyring store has no deadline of its own; if the Secret Service hangs past the host's 140 s call deadline, the module may still write after the host has given up.
  • Make the order-dependent artifacts test deterministic, or run it in its own process.

Submission Checklist

  • Tests added or updated (happy path + at least one failure / edge case):
    • native fixtures load TinySecurity and the four callback-bound modules from a pinned release with no local-file source, and check that a later load, the protected floor and redaction all work;
    • operation_approval_tests cover a pending prompt listed until decided, cancelled or expired; the controller contract tests cover the new RPC;
    • vitest covers the overlay polling with no socket (and de-duplication against a later socket delivery) and no polling once connected; keyringApi covers the new call;
    • user_scope_tests cover a rolled-back first sign-in (pre-login threads kept, no profile left) and a rolled-back account switch (previous user restored);
    • session_state_tests and cli_tests cover the restart status;
    • compile-time asserts pin the keyring deadline order; policy_tests checks the contract 2.0 version; engine_outcome_tests the abandoned outcome;
    • tests/desktop_sign_in_fixture.rs signs in on the desktop core (secret preflight, no socket, TinySecurity from its release, a throwaway Secret Service): approved stores and reads back the token with no restart; denied stays signed out.
  • Diff coverage ≥ 80%: the changed Rust lines are covered by the unit tests above and the native fixtures; the frontend change by the vitest cases. CI's diff-cover gate is authoritative.
  • N/A: no coverage-matrix feature row is added, removed or renamed; this fixes existing security, keyring and sign-in behaviour.
  • N/A: no matrix feature IDs are affected.
  • N/A: no new external network dependencies; the fixtures download the pinned release assets from the same GitHub release hosts the existing fixture uses.
  • Release-cut surface: desktop sign-in now shows a keyring approval on the login screen; smoke-check approve and deny on macOS, Windows and Linux.
  • N/A: there is no linked issue; this is the follow-up listed in chore(modules): re-pin the 2026-11-03 vendor releases #8530.

Related

Open at merge time

  • Four credential tests fail when each runs in its own process (nextest), and pass when they run after another credential test in the same process. They cover session sign-out with a preserved API key and a second user's sign-in. main passes all four. Each raises one keyring consent; answering it removes the wait but not the failure. It's being diagnosed as a first-credential-flow-per-process effect introduced with v0.19.0; the fix, if one is needed, follows in a separate PR.
  • The final full self-hosted CI run was not repeated on this exact head after the rebase onto 9341250. The narrow runs on the pre-rebase heads (range-diff identical apart from dropping the relock commit, now in fix(runtime): don't relock the config snapshot while replacing it #8546) are summarised above.

@coderabbitai

coderabbitai Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Explain Change →

Caution

Review failed

The pull request is closed.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

@M3gA-Mind
M3gA-Mind force-pushed the chore/followup-repin-1103 branch 2 times, most recently from 4c806ce to 84de00d Compare November 4, 2026 01:57
@M3gA-Mind
M3gA-Mind force-pushed the chore/followup-repin-1103 branch from 84de00d to f1b528c Compare November 4, 2026 13:39
@M3gA-Mind M3gA-Mind changed the title fix: re-pin the remaining vendor modules and make TinySecurity work in shipped builds and across sign-in fix(credentials): store the sign-in token through TinySecurity's keyring, roll back failed sign-ins, and guard artifact reads Nov 4, 2026
tinystoragedrivers 2d23a6a -> 5e8c837 (tinyhumansai#91). The new pin descends from the
old one and is on tinystoragedrivers main's first-parent history.
… tiny-artifacts

Move vendor/tiny-artifacts b8920f7 -> 6ee41ad (tinyhumansai#15), which adds
FileRoots::with_async_guard and FileRoots::read. guarded_roots now runs
TinySecurity's protected-path check as that guard, so a protected file is
refused before its bytes are read. The legacy import and the visible-file
copy read through FileRoots::read, which keeps the authorized file open,
instead of resolving a path and opening it again.
Root and desktop lockfiles follow the new gitlinks: tinyconnectors 0.22.1,
tinydocs 0.2.7 (ppt-rs without its PDF stack, calamine 0.38), tinyhosts
0.4.5, tinymcp 0.20.5, tinystoragedrivers-fence's libc dependency and
tiny-artifacts' cap-fs-ext. No cargo update.
…hrough the guard

These tests exercise artifact storage, not TinySecurity. Through the
guarded roots every read now asks TinySecurity's protected floor. In the
shared lib test process the module is admitted once with the first test's
secret scope, so the query fails and the guard refuses every path.
Scripting an empty floor keeps them on what they test; the new guard
test scripts the one protected path it asserts.
…admission

The shipped app loads TinySecurity as a pinned release, never from a local
file. The native fixture now stages the verified archive as a bundled module
with no local-file source and checks that later loads keep serving the
admitted callback binding: a second client, the protected-path floor and
redaction.
…l is stored

A sign-in activated the new user's directory, purged the pre-login
conversations and only then stored the credential. When the store failed,
as every sign-in after a signed-out launch did, the purge had already run
and users/<id> was left half-created with the profile switched.

Activation now records what it changed. The pre-login purge waits until the
credential is stored and the process has rebound, and any earlier failure
restores the previous active user (or none) and removes a user directory
the sign-in created.
…the native fixture

TinyMCP, TinyEventQueues, TinyChannels and TinyComputer take host callbacks
and ship as pinned releases. The native fixture now stages each verified
archive as a bundled module with no local-file source, boots a desktop core
so the storage host and secret store exist, and checks that each module
admits and reloads. On Linux a module whose system libraries the runner
lacks is skipped with a log line, and the rest are still checked.
TinySecurity is admitted once per process for one profile's secret key, and
its secret, approval and audit identity cannot change after that. On a host
that activates per-user profiles (the CLI, a TUI or a headless core), a
sign-in into another profile moved the process to a profile its
TinySecurity could not serve, and the credential write failed closed.

Such a sign-in now activates the profile, stores nothing under the old one
and answers restartRequired, so the user signs in again after a restart.
The auth state reports restartRequired whenever the active profile is not
the one TinySecurity serves (its live scope owner, or after a release the
admitting scope). The CLI exits 75 with "restart required to finish
switching profiles"; a server or TUI keeps refusing the other scope until
it is restarted. The desktop keeps its profile in place, so it signs in
without a restart.
native_fixture_serves_later_calls_after_a_release_admission
(tests/security_release_reuse_fixture.rs) and
native_fixture_loads_callback_modules_from_a_release_source
(tests/callback_module_release_fixture.rs) load pinned module releases, so
like the other native fixtures they are #[ignore] by default and run by
scripts/ci/security-native-fixture.sh with --ignored --exact.
…nects

A TinySecurity keyring operation waits for the user's approval, which the
renderer receives only as a Socket.IO event. The socket connects once a
session exists, so the keyring write that stores a first sign-in's token
was never shown: the host waited out its 120 s and the sign-in failed.

The host now keeps each pending prompt's display-safe metadata and serves
it as openhuman.keyring_consent_list_pending_operations. While no socket is
connected, the approval overlay polls it once a second, de-duplicated by
request id against socket deliveries; a connected socket stays the only
source.

The same holds while a restarted desktop core is still in its secret
preflight: reading a keyring-held session token at startup asks the user,
and no socket exists yet. The preflight now also serves the keyring prompt
list and its answer, the same class as approval_list_pending and
approval_decide: they resolve an already pending request by its id and
create no grant.
Re-pin vendor/tinysecurity b53eceb (v0.18.4) -> 17c1284 (v0.19.0), which
carries the module-side keyring consent fix (tinysecurity#51) and contract
2.0 (tinysecurity#43). The registry record and its 11 digests come from the
release's checksum.toml, each checked against the downloaded archive.

Contract 2.0 adds an `abandoned` execution outcome. Nothing the host calls
aborted proves the tool never ran, so `Aborted` now maps to the module's
`Abandoned` instead of `Cancelled`, and a tool execution whose audit guard is
dropped before it reports a result records `Aborted` instead of `Failure`.

Rollback: a host or module on contract 1.x cannot decode a row that records
`abandoned`.
An ignored fixture boots the desktop core the way the shell does
(desktop_builder: secret preflight, embedder host) with TinySecurity from its
bundled release and no Socket.IO, as on a first sign-in. It signs in with a
session credential and answers each keyring prompt it can only see through
keyring_consent_list_pending_operations:
- approved, the token is stored and read back in the same process and the
  device is signed in, with no restart;
- denied, sign-in fails and the device stays signed out with no token.

A third case restarts the desktop core over the approved sign-in's
workspace: reading the stored token during the secret preflight asks the
user, and once that prompt is approved the core comes up signed in.

The fixture script runs each case in its own process, on Linux under a
private D-Bus session with an unlocked throwaway Secret Service, and lists
the openhuman entries the keyring holds afterwards.
…t at 30 s

The host's KeyringAccess2 call used tinybus' default 30 s deadline while the
keyring prompt it waits on stays open for 120 s. A user who took longer than
30 s to approve still failed sign-in: the call timed out, the save fell back
to the encrypted document, whose forced approval is refused at sign-in.

Order the three deadlines so an unanswered prompt ends the same way every
time: the host's own wait (120 s) ends first and records the prompt as
pending, TinySecurity's consent deadline (125 s) after it, and the host's
KeyringAccess2 call deadline (140 s, room for the keyring store) last.
Compile-time asserts keep that order.

A decided outcome the caller never took stayed in the registry and counted
against its 64 slots until restart, as did a prompt whose waiter went away.
An outcome now expires 30 s after its decision, and a waiter that goes away
releases its reservation.
The desktop core serves only its approval methods until the secret
preflight finishes and its services start. Nothing logged the steps in
between, so a preflight that never finished looked like a silent gate.
Log each step at debug level, and the hand-off from the preflight to the
full router.
…builds

With TinySecurity v0.19.0 every keyring operation waits for the user to
approve it. Tests that save credentials have no user, so each save waited
out the 120 s consent window and then failed: 156 tests that pass on main
failed or timed out, and the measured suites took 25 times as long.

A responder answers the credential store's prompts through the ordinary
approval path. It is compiled only into unit-test builds, e2e-test-support
builds (the WDIO and Playwright apps) and test-keyring-consent builds (the
in-process integration tests, through the CLI's core dev-dependency), never
a product build. OPENHUMAN_TEST_KEYRING_CONSENT picks approve, deny or off;
unit tests approve by default, the other builds only when the lane scripts
set it. Prompts from other callers stay unanswered, so tests of unanswered
prompts keep working.

check-feature-forwarding now fails if a test-only gate is in the product
feature set or is reached from any crate's default features.
@M3gA-Mind
M3gA-Mind force-pushed the chore/followup-repin-1103 branch from f1b528c to 9c6645a Compare November 5, 2026 02:51
@M3gA-Mind
M3gA-Mind marked this pull request as ready for review November 5, 2026 02:51
@tinysweeper

tinysweeper Bot commented Nov 5, 2026 •

Copy link
Copy Markdown

Tiny Sweeper review

⚠️ Review of 9c6645af6b37 stopped because the pull request closed or changed.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 5, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ⚠️ Failed 2026-11-05T02:57:03.021943Z
We couldn’t complete this review. Comment @codex review to try again.
9c6645a Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@M3gA-Mind
M3gA-Mind merged commit 0f699bd into tinyhumansai:main Nov 5, 2026
22 of 33 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant