feat: persist index credentials in the OS keyring (sysand auth login/logout/status/whoami) - #453
feat: persist index credentials in the OS keyring (sysand auth login/logout/status/whoami)#453consideRatio wants to merge 84 commits into
Conversation
|
Doing some UX testing, dumping notes here. (Transcripts below refreshed to match the gutter-aligned output that landed after the original capture.)
Testing with staging.sysand.com$ sysand auth login https://staging.sysand.com
Logging in to index `https://staging.sysand.com/`
Enter token for `https://staging.sysand.com/`:
error: credential for `https://staging.sysand.com/` was rejected by the index API (`v1/whoami`) and accepted by no surface; nothing was stored
note: pass `-v`/`--verbose` to output additional logs
$ sysand auth login https://staging.sysand.com
Logging in to index `https://staging.sysand.com/`
Enter token for `https://staging.sysand.com/`:
Stored credential for `https://staging.sysand.com/` (validated (api))
Covers https://staging.sysand.com/**
$ sysand auth login https://staging.sysand.com
Logging in to index `https://staging.sysand.com/`
Enter token for `https://staging.sysand.com/`:
Replacing existing credential for `https://staging.sysand.com/`
Stored credential for `https://staging.sysand.com/` (validated (api))
Covers https://staging.sysand.com/**
$ sysand auth status
Stored https://staging.sysand.com/ validated (api)
patterns: https://staging.sysand.com/**
subject: user admin
token prefix: sysand_u_53a8fea5
expires: 2026-10-17 11:27:09 UTC (expires in 89 days)
$ sysand build
Building kpar `/home/erik/dev/sensmetry/sysand-test-repo/output/proj0-4.0.21.kpar`
updating file metadata (1/1)
Including readme from `/home/erik/dev/sensmetry/sysand-test-repo/README.md`
Including license from `/home/erik/dev/sensmetry/sysand-test-repo/LICENSES/MIT.txt`
$ sysand publish --index https://staging.sysand.com
warning: KPAR does not contain a changelog file CHANGELOG.md;
it is recommended to provide it to inform users of
the changes between versions
Publishing admin/proj0 v4.0.21 to https://staging.sysand.com/
Published new release successfully
$ sysand auth logout https://staging.sysand.com
Logging out from index `https://staging.sysand.com/`
Removed stored credential for `https://staging.sysand.com/`
$ sysand auth status
No credentials configured (no stored logins, no `SYSAND_CRED_*` variables).Testing with private index on GitHub$ sysand auth login "https://raw.githubusercontent.com/consideratio/priv-index-example-github/refs/heads/index/"
Logging in to index `https://raw.githubusercontent.com/consideratio/priv-index-example-github/refs/heads/index/`
Enter token for `https://raw.githubusercontent.com/consideratio/priv-index-example-github/refs/heads/index/`:
Stored credential for `https://raw.githubusercontent.com/consideratio/priv-index-example-github/refs/heads/index/` (validated (read))
Covers https://raw.githubusercontent.com/consideratio/priv-index-example-github/refs/heads/index/**
$ sysand info --iri "pkg:sysand/example-publisher-1/example-project-1" --index "https://raw.githubusercontent.com/consideratio/priv-index-example-github/refs/heads/index/"
warning: Direct or transitive usages of SysML v2/KerML standard library packages are
ignored by default. If you want to process them, pass `--include-std` flag
Name: example-project-1
Publisher: Example Publisher 1
Version: 0.0.1
License: MIT
No usages.Testing with private index on GitLab$ sysand auth login "https://gitlab.com/api/v4/projects/84113019/repository/files/{path}/raw?ref=index"
Logging in to index `https://gitlab.com/api/v4/projects/84113019/repository/files/{path}/raw?ref=index`
Enter token for `https://gitlab.com/api/v4/projects/84113019/repository/files/{path}/raw?ref=index`:
Stored credential for `https://gitlab.com/api/v4/projects/84113019/repository/files/{path}/raw?ref=index` (validated (read))
Covers https://gitlab.com/api/v4/projects/84113019/repository/files/**
$ sysand auth status
Stored https://gitlab.com/api/v4/projects/84113019/repository/files/{path}/raw?ref=index validated (read)
patterns: https://gitlab.com/api/v4/projects/84113019/repository/files/**
$ sysand info --iri "pkg:sysand/example-publisher-1/example-project-1" --index "https://gitlab.com/api/v4/projects/84113019/repository/files/{path}/raw?ref=index"
warning: Direct or transitive usages of SysML v2/KerML standard library packages are
ignored by default. If you want to process them, pass `--include-std` flag
Name: example-project-1
Publisher: Example Publisher 1
Version: 0.0.1
License: MIT
No usages. |
b7b0928 to
b3a984c
Compare
a94ecaf to
99a0bd1
Compare
99a0bd1 to
bb9a413
Compare
18c55d5 to
3dcef4b
Compare
3f4805e to
745df25
Compare
993098f to
6bd7be9
Compare
|
Ai generated below, will review the response myself now: EDIT (AI-generated): Followed up on the "non-http(s) should not be accepted anywhere" point. Confirmed a sysand index cannot be reached via Thanks for the review. I rebased the branch onto keyring → 4.1.5 · done, but the feature set had to change shapeBumped to keyring = { version = "4.1.5", optional = true, default-features = false, features = ["v1"] }The single One deliberate deviation from "Secret Service with vendored libdbus": 4.x's compat layer hardcodes the pure-Rust openssl out of the dependency tree · confirmed absent
fd-lock: is it necessary? · no, dropped it for stdGood call. MSRV is Why require
|
b495659 to
e143e5d
Compare
|
A five-lens review of this branch (architecture, storage correctness/security, command integration, tests/docs, CLI UX) found no high-severity correctness or security bugs, but produced a set of fixes and simplifications that just landed as 11 new commits. Each is summarized below with an honest assessment, so it is easy to judge per commit whether it earns its place; they are independent enough to drop individually if one looks wrong. Overall verdict from the review: the scary-looking parts (keyring blob, locking, probe/refusal validation, lazy auth layer) earn their complexity; the removable complexity was in duplicated selection logic, a doubled storage abstraction, and a few message/robustness gaps, which is what these commits address. f541bf9 test: default the CLI harness to the absent credential store seamWhat: Why: Assessment: clearly good, 6 lines, pure test hygiene closing a real isolation hole. No downside identified. 98c7e39 fix(auth): carve 429 out of forced discovery, humanize refusal wordingWhat: (1) a 429 discovery baseline no longer triggers the forced credentialed retry (treated like unreachable, matching the probes' existing 429 carve-out); (2) a read-probe 404 refusal hedges with "or no index exists at this URL"; (3) the single-surface refusal message reads "the index rejected the token ( Why: the 429 case contradicted the code's own stated rationale (do not spend rate budget or transmit the secret to a throttling host); the 404 case blamed the credential when the URL may simply have no index; the wording was design-doc vocabulary leaking to users. Assessment: good. (1) closes a genuine design-internal inconsistency that transmitted the secret when policy said not to. (2) and (3) are wording; low risk, +144 test lines pin them. 485e2d8 fix(publish): collapse identical-token bearers, polish credential hintsWhat: Why: two stored logins whose globs both match the upload URL with the same token worked for reads and whoami but spuriously failed publish; every sibling flow already collapsed identical tokens. Assessment: good. Fixes a user-visible asymmetry with a concrete failure scenario; hint and timestamp changes are copy polish aligned with stated CLI conventions. 5faeba9 fix(auth): harden credential store locking, redaction, and sync cacheWhat: lock file moves off session-scoped Why: the lock path was env-dependent, so a cron/systemd process and an interactive shell could lock different files while read-modify-writing the same keyring entry, silently losing a record (the branch's most significant correctness finding). The userinfo echo printed passwords into stderr/CI logs in the one place the code knows it handles a credential. Assessment: clearly good; this is the commit with the highest defect-fixing value on the branch. The lock-path change deviates from the design doc's original wording, which c5d5dd0 updates. c5d5dd0 docs(design): sync credential-storage.md with the review fixesWhat: design doc updated for the 429 carve-out, refusal wording, lock path and shared read lock, Assessment: good and necessary; the doc and code cross-reference each other, and the review found the doc accurate everywhere else, so keeping it that way is the point. Docs-only, zero runtime risk. 02d8f92 fix(auth): polish CLI UX for login, logout, whoami, and statusWhat: help-text fixes (double negative removed, dangling cross-reference now points at Assessment: good, though the largest and most mixed commit (+315/-85 over 5 files). The whoami single-read change is a real behavior fix (two unlock prompts on a locked Linux keyring); the rest is copy and signature polish. The dcc7d54 fix(auth): idempotent logout, quiet re-login notice, one env classifierWhat: logout of a non-existent entry warns and exits 0 (gh/cargo convention) while store failures stay hard errors; the "Replacing existing credential" notice respects Assessment: good. The exit-code change is the only observable-contract change on the branch (scripts relying on logout-of-nothing failing would break; none plausibly exist). The classifier unification removes a three-way drift hazard for +122 lines of one well-documented module. ab99440 refactor(auth): share one credential-selection helper across read, whoami, and publishWhat: one Assessment: good but honestly not a size win: net -3 lines excluding its new tests (the review's 100-200 line estimate was high). The value is that collapse/precedence/ambiguity semantics are written once; the previous triplication had already produced one real inconsistency (the publish asymmetry fixed in 485e2d8). One knowing delta: whoami now logs the same invalid-glob warning the runtime path always had (previously silent skip). b1dea59 refactor(auth): direct store read for publish, 429 carve-out, louder degradeWhat: the racy sync cache accessor Assessment: mostly good: removes an accessor whose safety rested on a comment about runtime flavor, and the 429 carve-out matches the design's "429 is never a verdict". Two knowing costs: a rare run can now read the store twice (discovery escalation plus publish's direct read; sanctioned by the doc update), and the blanket warn-on-degrade was a UX mistake, corrected in 24f0470. e51abe4 refactor(auth): collapse the CredentialStore trait into LockedBlobStoreWhat: the Assessment: good with a caveat: net only -32 lines (short of the estimated 80-100; cfg reflow and test scaffolding ate the difference), so judge it on shape, not size: one less public abstraction, five signatures lose a generic parameter, and the 24f0470 fix(auth): keep the absent-keyring read fallback quietWhat: partial correction of b1dea59: Why: on keyring-less hosts (CI, musl, headless) any routine 4xx would print a keyring warning to users who never stored a credential, once per process, with nothing actionable. The accepted blind spot (a broken Secret Service surfaces as "absent" and degrades quietly on the request path) stays diagnosable via Assessment: good; the warn-everything version would have been CI log noise. Worth knowing the blind spot exists. Verification: after each integration and at the end: Review items deliberately not acted on (considered and declined): dropping template-URL login targets, the pre-upload expiry fail-fast, and the forward-compat extra-field round-trip; all stay as shipped. 🤖 Generated with Claude Code |
|
Follow-up: three commits reducing PR size, since the diff was large. Method was top-down: write the inventory of behaviors and prose that SHOULD exist, then cut what falls outside it. Key semantics and secret-safety stay; formatting and restated rationale go. The PR shrinks from 11,639 to 10,373 insertions (roughly 700 of the remainder is Cargo.lock from the keyring dependency tree). 8a7b84c test(auth): keep CLI contracts, drop output-formatting pins (-475 lines, 44 to 28 tests)What: Assessment: this is where the fat was. Two judgment calls a reviewer may want back (each one revert away): the template-URL CLI round trip and the read-404-hedge flow test were dropped as variants; both behaviors remain pinned at the core layer. e2158d8 test(auth): cut formatting pins and merge duplicates, keep the behavior matrix (-442 lines, -10.6%)What: core test files: 8 tests dropped, 16 merged into 7 table-driven ones, exact-prose assertions loosened to substrings, small mock helpers extracted. Every expect(0)/expect(N) request-count proof and the full validation/locking/selection matrix kept. Assessment: honest shortfall against a 30-45% target: the core suite turned out to be almost entirely semantic pins, so only 10.6% was genuinely cuttable without deleting behavior coverage. Cutting more would remove matrix cells, which defeats the purpose. 2fd5774 docs(auth): trim rationale comments and the design doc (-375 lines net)What: narrative 10-25-line doc comments reduced to the load-bearing constraint plus a design-doc section pointer; design/credential-storage.md from 785 to 628 lines (build-phases plan reduced to a landed note, docs checklist to a pointer, duplicated explanations deduplicated); its status banner updated from "plan / draft" to shipped. Deliberately untouched: the lock-path rationale, the why-not-SequenceAuthentication note (defends against a future regression), the fail-quiet/fail-loud store-error taxonomy, and the test-seam safety docs. Assessment: pure prose, no code semantics changed (checked by build + full suite). The remaining 628 doc lines are normative behavior spec. Verification after integration: full 🤖 Generated with Claude Code |
…biguous env bearers Review batch from PR sensmetry#453: - Convert every long credential/auth user-facing message that was broken with string-continuation spaces to break with real newlines instead, keeping the existing break points (core/src/auth.rs, core/src/commands/auth.rs, sysand/src/lib.rs, sysand/src/commands/auth.rs, sysand/src/commands/publish.rs, sysand/src/credential_store.rs). - Replace PublishBearerSource with PublishBearerConflict, carrying the conflicting candidates per source: Env names every matching SYSAND_CRED_<LABEL> variable, Stored names every matching login key. BearerSelection::Ambiguous now carries the full pre-collapse match list; the identical-token collapse semantics are unchanged. Co-authored-by: Andrius Pukšta <andrius.puksta@sensmetry.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PczC2mqtqFveSLWt9aqof3 Signed-off-by: Erik Sundell <erik.sundell+2025@sensmetry.com>
The index.json probes only need the status, so HEAD avoids fetching index bodies during login validation; whoami and the forced discovery retry keep GET since their bodies are parsed. Co-authored-by: Andrius Pukšta <andrius.puksta@sensmetry.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PczC2mqtqFveSLWt9aqof3 Signed-off-by: Erik Sundell <erik.sundell+2025@sensmetry.com>
…ove inline tests out `auth status` used to list `SYSAND_CRED_*` pattern variables leniently while every credential-using command applied strict validation, so status could paint a healthy picture of a configuration other commands reject. The strict validation (label-less names, secrets without a URL pattern, patterns with no complete scheme, half a basic pair) now lives in one shared place, cred_env::validated_env_groups, and both the eager policy build in run_cli and `auth status` go through it with the exact messages the eager path already used. Stems are checked in sorted order so the reported error is deterministic. `auth whoami` keeps its lenient per-group policy on purpose: it must stay usable against a private index even when an unrelated group is malformed, and its skip-bad-groups behavior (including invalid glob patterns) is materially different from the strict validation, so unifying it would change behavior. Also move the inline tests module in sysand/src/commands/auth.rs to a sibling auth_tests.rs wired with the #[path] include idiom, matching every other file in this branch. Test-support items (core's credential_store test_support module, the cfg(test) accessor on StoredBearerAuth) stay in place: test files import them. Co-authored-by: Andrius Pukšta <andrius.puksta@sensmetry.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PczC2mqtqFveSLWt9aqof3 Signed-off-by: Erik Sundell <erik.sundell+2025@sensmetry.com>
…rror Like the publish conflict change: the error lists every matching SYSAND_CRED_<LABEL> variable or stored key (first-seen order, deduped) instead of a bare count, so the user knows exactly what to reconcile. Co-authored-by: Andrius Pukšta <andrius.puksta@sensmetry.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PczC2mqtqFveSLWt9aqof3 Signed-off-by: Erik Sundell <erik.sundell+2025@sensmetry.com>
The API may answer any error code, so the rejection carries the status per surface instead of implying a hardcoded 401: ProbeOutcome.rejected and ValidationRejected.rejected become Vec<(ProbeSurface, u16)>, the separate read_status field folds away (the read pair's code drives the 404 hedge), and the stored-anyway SurfaceRejected notice carries its surface's actual status too. Both the single-surface and enumerated refusal messages now name each surface's own answer. Co-authored-by: Andrius Pukšta <andrius.puksta@sensmetry.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PczC2mqtqFveSLWt9aqof3 Signed-off-by: Erik Sundell <erik.sundell+2025@sensmetry.com>
…ones The read-surface probe previously detected only a Basic challenge as a boolean. Now it collects every offered challenge scheme token verbatim (first-seen order, deduplicated case-insensitively, quote-aware top-level comma split, auth-param continuations filtered) and carries the list through ProbeOutcome, ValidationRejected, and the SurfaceRejected notice. Classification happens at render time via the shared schemes_include_basic / unsupported_schemes_followup helpers: Basic keeps the existing SYSAND_CRED_* routing hint byte-identical, Bearer is the expected scheme, and any other scheme is named verbatim in a new follow-up line on both the refusal and stored-anyway paths. Co-authored-by: Andrius Pukšta <andrius.puksta@sensmetry.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PczC2mqtqFveSLWt9aqof3 Signed-off-by: Erik Sundell <erik.sundell+2025@sensmetry.com>
…r key comment The unanchorable-template case is rejected on the raw input before the key is built, so the residual arm is a bug state; and the normalized-template-key comment now says what differs from Display (the URL-normalized anchor) instead of gesturing at idempotence. Co-authored-by: Andrius Pukšta <andrius.puksta@sensmetry.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PczC2mqtqFveSLWt9aqof3 Signed-off-by: Erik Sundell <erik.sundell+2025@sensmetry.com>
There was a problem hiding this comment.
To avoid having to do shenanigans like e.g. normalized_template_key, use part of the already-parsed expanded to replace prefix, so that parsing here normalizes the URL, and the Display implementation can then be used as the credential store key without changes.
There was a problem hiding this comment.
Done in 1faf45c: IndexUrlTemplate::parse now normalizes the prefix (it parses prefix + a re-encoded probe and strips the probe with strip_suffix, which is collision-proof where searching the full expansion is not), so Display is the canonical text and serves directly as the store key; normalized_template_key and its reassembly are deleted. Suffix stays verbatim (literal post-placeholder text). Pins cover host case, default port, the query-only corner gaining its root /, IDN, probe-aliasing literals, and spelled-vs-canonical expansion equivalence. (Claude, on watch duty for Erik.)
| let Some((cut, anchor)) = template_anchor_root(prefix) else { | ||
| return Err(AuthCommandError::TemplateWithoutAnchor { | ||
| url: index_url.to_string(), | ||
| }); | ||
| }; |
There was a problem hiding this comment.
This should be unreachable, since IndexUrlTemplate::parse tests for exactly this (though check this; if it turns out the check there is ineffective, correct it).
There was a problem hiding this comment.
Verified and done in 1faf45c: with parse-time normalization every template prefix carries an explicit path /, so the anchor is infallible for parsed templates; template_anchor_root now takes &IndexUrlTemplate and returns Url (no Option), and the dead TemplateWithoutAnchor variant and TemplateIndexRootSkipped notice are removed. The https://example.com?f={path} corner (which previously anchored nowhere) now logs in keyed as https://example.com/?f={path} with a host-root glob, tested. (Claude, on watch duty for Erik.)
| expires_at: body.token.expires_at, | ||
| }), | ||
| Err(err) => { | ||
| log::debug!("whoami body was not read: {err}"); |
There was a problem hiding this comment.
Separate "failed to read" from "failed to parse" and in the latter case include the message that failed to parse (it does not contain secrets).
There was a problem hiding this comment.
Done in bdb12b2: the whoami body handling now distinguishes "was not read" (reqwest error with source chain) from "was not parsed" (serde error plus the offending body text, which is index metadata about the token, never a secret). (Claude, on watch duty for Erik.)
| ) -> Option<WhoamiIdentity> { | ||
| let parsed = runtime | ||
| .block_on(response.bytes()) | ||
| .map_err(|err| err.to_string()) |
There was a problem hiding this comment.
This silently discards error source(), which reqwest sometimes populates, and which includes the actual details of the error.
There was a problem hiding this comment.
Done in bdb12b2: probe and discovery errors now flow through format_err, so the reqwest source() chain (where the real detail lives) is preserved; the same fix applied to the other error-to-string sites in the auth commands. (Claude, on watch duty for Erik.)
reqwest errors flow through format_err (the source chain carries the actual failure detail), and the whoami body debug log now distinguishes an unreadable body from an unparsable one, including the offending body text (index metadata, never a secret). Co-authored-by: Andrius Pukšta <andrius.puksta@sensmetry.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PczC2mqtqFveSLWt9aqof3 Signed-off-by: Erik Sundell <erik.sundell+2025@sensmetry.com>
…e key IndexUrlTemplate::parse now rewrites the literal prefix into the text url::Url serializes it as (lowercase scheme and host, punycode, default port dropped, explicit root /), by parsing the prefix with a probe segment appended and cutting the probe back off. The probe goes on the prefix alone so suffix text can never move the cut, and the suffix stays as written. With Display canonical, the auth credential key for a template is just IndexLocation Display text: normalized_template_key and its prefix reassembly are gone. The normalized prefix always carries an explicit path /, so template_anchor_root is infallible for parsed templates (every IndexUrlTemplate is built by parse; discovery-advertised roots included) and now takes the template and returns the anchor directly. The dead TemplateWithoutAnchor error and TemplateIndexRootSkipped notice are removed: a placeholder directly after the authority, like https://example.com?f={path}, now anchors at the host root instead of being rejected or skipped. Co-authored-by: Andrius Pukšta <andrius.puksta@sensmetry.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PczC2mqtqFveSLWt9aqof3 Signed-off-by: Erik Sundell <erik.sundell+2025@sensmetry.com>
Implements credential storage and the
sysand authcommand family, v1 of the design settled in #437 (the reviewed plan was settled in this comment, now a historical snapshot; the maintained version isdesign/credential-storage.mdin this branch, updated as review findings landed).The
v1/whoamiendpoint is already deployed on sysand.com (a validating login against it probesv1/whoami).What this delivers
Store an index credential once with
sysand auth login, andsysandreuses it across runs on Windows, macOS, and Linux, with the secret held by the OS keyring, never a plaintext file. Bearer-only v1;SYSAND_CRED_*env vars keep working unchanged and take precedence (CI overrides an interactive login).Command surface: auth login / logout / status
sysand auth login [INDEX_URL] [--token-stdin]SYSAND_DEFAULT_INDEX, configdefault = true, elsehttps://sysand.com); more than one configured default errors and asks for an explicit URL. The resolved index is always echoed before any secret entry. There is deliberately no per-subcommand--default-indexflag: it would only duplicate the positional argument.Enter token for ...:, rpassword) or--token-stdin(trims exactly one trailing newline); never an inline argument. No TTY and no--token-stdinfails fast instead of hanging. Empty token errors.SYSAND_CRED_*lines with the secret as a literal<token>placeholder (never echoed; asserted by test).sysand auth logout [INDEX_URL]: removes a stored login (same default-index resolution and echo; logging out of an index with no stored login warns and exits 0, so cleanup is idempotent).sysand auth status: one unified view of everything sysand will authenticate with. Stored entries (key in the exactauth logout <key>form, covered globs, subject and token prefix when a validating login ran,expires in N days/ expired,SYSAND_CRED_*shadowing) and env entries, tagged by source; each stored entry shows its persisted validation claim (validated (read/api)or a warn-stylednot validated); absence negatives print only when nothing is configured at all; entries covering the resolved default index carry a dim(default index)marker. Never shows secrets. Output is styled with the CLI's existing anstyle tokens and aligned to the same 12-column gutter as the rest of the CLI (plain when piped or underNO_COLOR).sysand auth whoami [INDEX_URL]: query-only live identity check againstv1/whoami, using the same credential selection as the runtime (env over stored, and the output names which source was used). Exit 0 only on an accepted credential; rejected/unreachable/rate-limited get distinct nonzero messages. Never writes the store. Errors clearly when the index advertises no API.https://gitlab.com/api/v4/projects/<id>/repository/files/{path}/raw?ref=index) are accepted as login/logout/whoami targets: the credential is scoped to the template's literal prefix, and template keys are normalized so login/logout/status round-trip.Storage: single keyring blob, fail-closed, locked
sysand, accountcredentials) holding a versioned JSON blob of records{key, globs, scheme, secret, expires_at?, subject?, token_name?, token_prefix?}.serde(flatten)), a parse failure surfaces "credential store unreadable" instead of silently clobbering stored credentials. A pre-existing older blob is proven to still parse.std::fs::Filelocking API) at a per-user path, bounded wait; never an existence-based lock file.SYSAND_CRED_*fallback named).LockedBlobStoretype (generic over aBlobBackendseam) are unconditional core; the OS-keyring backend sits behind a new non-defaultkeyringfeature (enabled by the CLI; the js/wasm binding stays green, verified by wasmcargo check). The Linux Secret Service backend is keyring 4.x's pure-Rust zbus, so clippy--all-featuresworks on runners without system libdbus.Consumption: lazy, no extra requests, env precedence
CredentialStoreAuthenticationcombinator wraps the existing env policy: runs it first, returns non-4xx untouched, and only on an auth-relevant 4xx reads the credential blob (once per process, cached viaOnceCell;spawn_blockingfor the sync keyring call) and issues a forced bearer retry when a stored glob matches.SYSAND_CRED_*> stored login > unauthenticated, end to end.expires_atis past, a hint suggests re-runningsysand auth login(any 4xx, since some hosts answer 404 on bad auth); at most once per record per process.Login validation (always on)
sysand-index-config.jsononce, both for glob scoping and to learnapi_root. The API surface is probed only when discovery advertisedapi_root(now required by the protocol, soapi_rootis set exactly when an API exists), so static indexes are never phantom-probed.v1/whoamiis forced-only. Probes never follow redirects (a redirected probe counts as not tested, naming the target). A 429 is never a verdict (rate-limited probes count as not tested; this was a spec bug found during implementation and fixed in both code and plan).validated (read),validated (api),validated (read, api), orstored, not validated.WWW-Authenticate: Basicsays the index uses username/password auth and routes toSYSAND_CRED_<X>_BASIC_USER/_BASIC_PASS.subject, token name/prefix, andexpires_atinto the record forauth status.SYSAND_CRED_*remains the escape hatch for pathological middleboxes.api_rootlearned, disjoint roots covered, and can validate asvalidated (read, api); a forced 404 is treated as an authoritative "no document".Glob scoping
globset::escape(<normalized root>)+**), so URLs containing glob metacharacters, including IPv6 literals likehttps://[::1]:8000/, cannot silently fail to match themselves (tested).index_root/api_rootonly when not already prefix-covered; disjoint hosts get their own escaped glob. Tests assert the discovery document URL,index.jsonURL, and upload URL each match the derived set.index_roots and typed login targets, are anchored at their literal prefix (clamped to at leastscheme://authority/; a template with no safe anchor is rejected with a pointer toSYSAND_CRED_*).Publish integration
SYSAND_CRED_<LABEL>" vs "from your stored login for<key>") with matching remediation; a 403 additionally points atsysand auth status(catches a project token scoped to a different project).expires_at(one-hour skew margin) stops publish before the archive upload; the server's 401 remains the authority.Protocol spec and server counterpart
design/index-api-protocol.mdgains a Token Identity section specifyingGET v1/whoamiunder the resolvedapi_root: bearer auth, 200 body (subject.typeuser/project/oidc with per-typesubject.namesemantics,token.namenull for exchanged OIDC tokens, non-secrettoken.prefix,expires_atwith Z suffix), 401 with unspecified body, MUST NOT consume single-use credentials, MAY rate limit.api/v1/whoami, reusing the existing bearer resolution across all three token types). It is already deployed on sysand.com, satisfying the ordering requirement that whoami be live before this client (a validating login against an official index without it would refuse valid tokens on a 404).Testing and verification
keyringfeature enabled) and 191 sysand CLI tests, all green on the final branch;cargo check -p sysand-core -F networking(keyring off) and wasmcargo checkgreen;prek run -aclean; every commit DCO-signed.SYSAND_TEST_CREDENTIAL_STOREseam selects a file-backed or absent backend (hard-errors in release builds), enabling end-to-end login/status/logout tests including a PTY-driven hidden-prompt test and a no-secret-leak assertion on the no-keyring path.Deferred and follow-ups
Deferred by design (plan section 10):
auth set/auth unset, basic auth via--username,--pattern, and longest-prefix glob tie-breaking (P2, requiring an explicitapi_root, already landed inmainvia #478). Follow-ups noted during implementation: exposingdo_auth_*through the py/java bindings, templated login targets beyond the literal-prefix scoping that shipped, and cargo-deny verification of the new deps (keyring, believed MIT/Apache-compatible).docs.sysand.com documentation. The user-facing docs for this feature are written and reviewed (a multi-lens review pass, fixes applied) on the
docs/sysand-authbranch in the sysand-index repo, and are live on the staging docs preview. That branch is 16 pages: the client authentication explanation / how-to / reference, theauth login/logout/status/whoamicommand pages, publish and configuration cross-links, and the index-side API-token pages. It is deliberately gated on this PR shipping: docs.sysand.com deploys frommain, and the docs policy forbids documenting functionality that has not been released, so the branch merges to main (and goes live) only once the release carrying this change is out.🤖 Generated with Claude Code
https://claude.ai/code/session_01EfyrkJkeo3mRngVoppJjUN