instar 1.3.534 → 1.3.536
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/CredentialSwapExecutor.d.ts +234 -0
- package/dist/core/CredentialSwapExecutor.d.ts.map +1 -0
- package/dist/core/CredentialSwapExecutor.js +596 -0
- package/dist/core/CredentialSwapExecutor.js.map +1 -0
- package/dist/core/CredentialWriteFunnel.d.ts +16 -0
- package/dist/core/CredentialWriteFunnel.d.ts.map +1 -1
- package/dist/core/CredentialWriteFunnel.js +19 -0
- package/dist/core/CredentialWriteFunnel.js.map +1 -1
- package/dist/core/OAuthRefresher.d.ts +18 -1
- package/dist/core/OAuthRefresher.d.ts.map +1 -1
- package/dist/core/OAuthRefresher.js +23 -1
- package/dist/core/OAuthRefresher.js.map +1 -1
- package/dist/core/QuotaPoller.d.ts.map +1 -1
- package/dist/core/QuotaPoller.js +7 -0
- package/dist/core/QuotaPoller.js.map +1 -1
- package/dist/monitoring/AccountSwitcher.d.ts.map +1 -1
- package/dist/monitoring/AccountSwitcher.js +13 -2
- package/dist/monitoring/AccountSwitcher.js.map +1 -1
- package/dist/monitoring/CredentialProvider.d.ts +20 -0
- package/dist/monitoring/CredentialProvider.d.ts.map +1 -1
- package/dist/monitoring/CredentialProvider.js +26 -0
- package/dist/monitoring/CredentialProvider.js.map +1 -1
- package/package.json +4 -2
- package/scripts/lint-no-direct-destructive.js +4 -0
- package/scripts/lint-no-unfunneled-credential-write.js +162 -0
- package/src/data/builtin-manifest.json +2 -2
- package/upgrades/{1.3.534.md → 1.3.535.md} +22 -0
- package/upgrades/1.3.536.md +34 -0
- package/upgrades/side-effects/live-credential-repointing-increment-a-funnel-routing.md +86 -0
- package/upgrades/side-effects/ws52-step5-swap-executor.md +64 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* lint-no-unfunneled-credential-write.js — refuses a raw write to the Claude Code
|
|
4
|
+
* credential store outside the serialization funnel (Step 4b of live credential
|
|
5
|
+
* re-pointing, spec §2.2).
|
|
6
|
+
*
|
|
7
|
+
* Why this exists: every in-process write to a config home's `Claude Code-credentials`
|
|
8
|
+
* keychain entry MUST go through `CredentialWriteFunnel.withSlotLock(slot, …)` (via
|
|
9
|
+
* `refreshClaudeToken`, `writeCredentialsSerialized`, or the Step-5 swap executor) so a
|
|
10
|
+
* refresh write can never interleave with a swap on the SAME slot and strand a rotated
|
|
11
|
+
* token. A callsite that writes the credential store directly — `defaultCredentialStore.write`,
|
|
12
|
+
* `provider.writeCredentials(...)`, or a hand-rolled `security add-generic-password` to the
|
|
13
|
+
* `Claude Code-credentials` service — bypasses that lock and re-opens the clobber race.
|
|
14
|
+
* This mirrors the SafeGitExecutor / SafeFsExecutor single-funnel precedent.
|
|
15
|
+
*
|
|
16
|
+
* Rule: outside the closed allowlist below, no source file may contain:
|
|
17
|
+
* - a `defaultCredentialStore.write(` call,
|
|
18
|
+
* - a qualified `.writeCredentials(` call (i.e. `someProvider.writeCredentials(...)` —
|
|
19
|
+
* the bare method DEFINITION `writeCredentials(creds…)` has no leading dot and is fine),
|
|
20
|
+
* - a raw `add-generic-password` invocation in a file that targets the
|
|
21
|
+
* `Claude Code-credentials` service (scoped by the literal service string so the OTHER
|
|
22
|
+
* keychain vaults — WorktreeKeyVault / SecretStore / GlobalSecretStore / RemediationKeyVault,
|
|
23
|
+
* each a DISTINCT service — never false-positive).
|
|
24
|
+
*
|
|
25
|
+
* Comment-only mentions are NOT a bypass and are ignored.
|
|
26
|
+
*
|
|
27
|
+
* Exit codes: 0 — clean; 1 — at least one violation.
|
|
28
|
+
*
|
|
29
|
+
* Usage:
|
|
30
|
+
* node scripts/lint-no-unfunneled-credential-write.js # full repo
|
|
31
|
+
* node scripts/lint-no-unfunneled-credential-write.js --staged # staged files
|
|
32
|
+
* node scripts/lint-no-unfunneled-credential-write.js <file…> # explicit files (tests)
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import fs from 'node:fs';
|
|
36
|
+
import path from 'node:path';
|
|
37
|
+
import { execSync } from 'node:child_process';
|
|
38
|
+
import { fileURLToPath } from 'node:url';
|
|
39
|
+
|
|
40
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
41
|
+
const ROOT = path.resolve(path.dirname(__filename), '..');
|
|
42
|
+
|
|
43
|
+
// ── Allowlist (closed). Each entry is a file that OWNS a credential-write primitive
|
|
44
|
+
// and routes it through the funnel internally (or the lint itself). Adding an entry
|
|
45
|
+
// requires review of WHY the callsite cannot route through `withSlotLock`. ──────
|
|
46
|
+
const ALLOWLIST = new Set([
|
|
47
|
+
// THE funnel — the per-slot lock lives here.
|
|
48
|
+
'src/core/CredentialWriteFunnel.ts',
|
|
49
|
+
// Owns `defaultCredentialStore.write` + the raw keychain write; `refreshClaudeToken`
|
|
50
|
+
// wraps it in `funnel.withSlotLock(configHome, …)`.
|
|
51
|
+
'src/core/OAuthRefresher.ts',
|
|
52
|
+
// Owns `KeychainCredentialProvider.writeCredentials` (the raw `security -i` write) +
|
|
53
|
+
// the sanctioned `writeCredentialsSerialized` funnel chokepoint that wraps it.
|
|
54
|
+
'src/monitoring/CredentialProvider.ts',
|
|
55
|
+
// Step 5 (spec section 2.3). Owns the async execFile add-generic-password keychain write
|
|
56
|
+
// primitive (defaultKeychainExec) used for slot + staging writes. Every credential write the
|
|
57
|
+
// executor performs runs INSIDE funnel.withSingleMover then funnel.withSlotLocks([A,B], ...) —
|
|
58
|
+
// the staged exchange takes the single-mover mutex AND both slot locks before any write, so a
|
|
59
|
+
// swap write can never interleave with a refresh/switch on the same slot. Funnel-routing is at
|
|
60
|
+
// the call layer (the primitive must NOT self-lock under the slot locks). Sanctioned route.
|
|
61
|
+
'src/core/CredentialSwapExecutor.ts',
|
|
62
|
+
// This lint file names the patterns it greps for.
|
|
63
|
+
'scripts/lint-no-unfunneled-credential-write.js',
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
const SCAN_DIRS = ['src', 'scripts', 'templates'];
|
|
67
|
+
const EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.mjs', '.cjs', '.sh']);
|
|
68
|
+
|
|
69
|
+
// The keychain service the funnel guards. Scopes the raw `add-generic-password` rule so the
|
|
70
|
+
// other (distinct-service) keychain vaults never trip it.
|
|
71
|
+
const GUARDED_SERVICE = 'Claude Code-credentials';
|
|
72
|
+
|
|
73
|
+
const PATTERNS = [
|
|
74
|
+
{
|
|
75
|
+
re: /defaultCredentialStore\.write\s*\(/,
|
|
76
|
+
msg: 'direct defaultCredentialStore.write — route through CredentialWriteFunnel.withSlotLock (see refreshClaudeToken)',
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
re: /\.writeCredentials\s*\(/,
|
|
80
|
+
msg: 'direct provider.writeCredentials(...) — route through writeCredentialsSerialized (CredentialProvider.ts)',
|
|
81
|
+
},
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
// A raw keychain write is flagged ONLY in a file that also references the guarded service —
|
|
85
|
+
// so WorktreeKeyVault / SecretStore / GlobalSecretStore / RemediationKeyVault (distinct
|
|
86
|
+
// services) are never false-positived.
|
|
87
|
+
const RAW_KEYCHAIN_WRITE = /add-generic-password/;
|
|
88
|
+
|
|
89
|
+
function isCommentLine(line) {
|
|
90
|
+
const t = line.trim();
|
|
91
|
+
return t.startsWith('//') || t.startsWith('*') || t.startsWith('/*') || t.startsWith('#');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function listFiles() {
|
|
95
|
+
if (process.argv.includes('--staged')) {
|
|
96
|
+
const out = execSync('git diff --cached --name-only', { cwd: ROOT, encoding: 'utf-8' });
|
|
97
|
+
return out.split('\n').filter(Boolean);
|
|
98
|
+
}
|
|
99
|
+
const explicit = process.argv.slice(2).filter((a) => !a.startsWith('--'));
|
|
100
|
+
if (explicit.length) return explicit;
|
|
101
|
+
|
|
102
|
+
const files = [];
|
|
103
|
+
const walk = (dir) => {
|
|
104
|
+
let entries;
|
|
105
|
+
try {
|
|
106
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
107
|
+
} catch {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
for (const e of entries) {
|
|
111
|
+
if (e.name === 'node_modules' || e.name === '.git' || e.name === 'dist') continue;
|
|
112
|
+
const full = path.join(dir, e.name);
|
|
113
|
+
if (e.isDirectory()) walk(full);
|
|
114
|
+
else if (EXTENSIONS.has(path.extname(e.name))) files.push(path.relative(ROOT, full));
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
for (const d of SCAN_DIRS) walk(path.join(ROOT, d));
|
|
118
|
+
return files;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let violations = 0;
|
|
122
|
+
for (const rel of listFiles()) {
|
|
123
|
+
const normalized = rel.split(path.sep).join('/');
|
|
124
|
+
if (ALLOWLIST.has(normalized)) continue;
|
|
125
|
+
if (!EXTENSIONS.has(path.extname(normalized))) continue;
|
|
126
|
+
const full = path.isAbsolute(rel) ? rel : path.join(ROOT, normalized);
|
|
127
|
+
let content;
|
|
128
|
+
try {
|
|
129
|
+
content = fs.readFileSync(full, 'utf-8');
|
|
130
|
+
} catch {
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const fileTargetsGuardedService = content.includes(GUARDED_SERVICE);
|
|
134
|
+
const lines = content.split('\n');
|
|
135
|
+
for (let i = 0; i < lines.length; i++) {
|
|
136
|
+
const line = lines[i];
|
|
137
|
+
if (isCommentLine(line)) continue;
|
|
138
|
+
for (const { re, msg } of PATTERNS) {
|
|
139
|
+
if (re.test(line)) {
|
|
140
|
+
console.error(`${normalized}:${i + 1} — ${msg}`);
|
|
141
|
+
violations++;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (fileTargetsGuardedService && RAW_KEYCHAIN_WRITE.test(line)) {
|
|
145
|
+
console.error(
|
|
146
|
+
`${normalized}:${i + 1} — raw 'add-generic-password' to the ${GUARDED_SERVICE} service outside ` +
|
|
147
|
+
`the funnel. Route the write through CredentialWriteFunnel.withSlotLock, or add an allowlist entry ` +
|
|
148
|
+
`here with a justification.`,
|
|
149
|
+
);
|
|
150
|
+
violations++;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (violations > 0) {
|
|
156
|
+
console.error(
|
|
157
|
+
`\nlint-no-unfunneled-credential-write: ${violations} violation(s). Every Claude credential write ` +
|
|
158
|
+
`must go through CredentialWriteFunnel.withSlotLock (spec §2.2).`,
|
|
159
|
+
);
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
console.log('lint-no-unfunneled-credential-write: clean');
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "./builtin-manifest.schema.json",
|
|
3
3
|
"schemaVersion": 1,
|
|
4
|
-
"generatedAt": "2026-06-
|
|
5
|
-
"instarVersion": "1.3.
|
|
4
|
+
"generatedAt": "2026-06-13T19:24:03.890Z",
|
|
5
|
+
"instarVersion": "1.3.536",
|
|
6
6
|
"entryCount": 201,
|
|
7
7
|
"entries": {
|
|
8
8
|
"hook:session-start": {
|
|
@@ -5,6 +5,16 @@
|
|
|
5
5
|
|
|
6
6
|
## What Changed
|
|
7
7
|
|
|
8
|
+
Step 4b of the live-credential-repointing build (Subscription & Auth Standard), shipping **dark**. Every in-process write to the `Claude Code-credentials` keychain store now routes through the Step-4a `CredentialWriteFunnel`'s per-slot lock, so two writers to the same config home serialize instead of interleaving:
|
|
9
|
+
|
|
10
|
+
- `OAuthRefresher.refreshClaudeToken` wraps its token write in `withSlotLock(credentialSlotKey(configHome), …)`. A lock-acquire timeout returns a new typed reason `write-skipped` — the OAuth exchange already succeeded and the existing valid credential is left untouched.
|
|
11
|
+
- `QuotaPoller.pollAccount` maps `write-skipped` to "no snapshot this cycle, retry next tick" — explicitly NOT `needs-reauth`. A momentarily-busy lock can no longer cry-wolf a healthy login into a re-auth prompt.
|
|
12
|
+
- `CredentialProvider` adds `writeCredentialsSerialized`, the one sanctioned chokepoint that serializes a provider write; `AccountSwitcher` now uses it (a busy lock returns a non-destructive "store busy, try again").
|
|
13
|
+
- A new lint, `lint-no-unfunneled-credential-write.js` (wired into `npm run lint`), forbids any future unfunneled write to the Claude credential store — the structural guarantee that no new code path can bypass the lock. It is file-scoped to the guarded service, so the other keychain vaults are never false-flagged.
|
|
14
|
+
- `credentialSlotKey` canonicalizes the lock key so a refresh and a switch on the same home always share one lock regardless of path spelling.
|
|
15
|
+
|
|
16
|
+
The credential-repointing feature gate is untouched and remains off + dry-run. With no swap running (always, while dark), the funnel adds only per-slot ordering and a bounded skip-retry under contention — behaviorally identical to before.
|
|
17
|
+
|
|
8
18
|
The session reaper's shared guard (`ReapGuard`) had two halves that disagreed about a *stale* open commitment. The KILL decision (`evaluate()`) already treats a commitment as abandoned after 8h of topic silence and lets the idle session be reaped. But the RESUME-eligibility decision (`workEvidence()`) counted **any** open commitment as proof of interrupted work — with no staleness gate. So an idle session was killed (commitment stale ⇒ reap) and immediately revived (commitment exists ⇒ resume-eligible), in an endless loop.
|
|
9
19
|
|
|
10
20
|
This patch applies the **same 8h staleness gate** to the resume-eligibility probe that the kill decision already uses, so the two halves agree: a stale commitment neither keeps a session alive nor revives it, and a fresh one keeps the session alive so it never needs reviving. Strictly safer — it can only ever revive *less*, never more. Genuine interrupted work (a live build, an active sub-agent, a pending injection, a running process) is untouched.
|
|
@@ -22,18 +32,30 @@ A precise per-trigger hint label (carrying the actual originating trigger rather
|
|
|
22
32
|
|
|
23
33
|
## What to Tell Your User
|
|
24
34
|
|
|
35
|
+
Nothing changes for you right now — this is internal plumbing for the upcoming restartless subscription rebalancing, and it is shipping switched off. When that feature is eventually turned on, it will be able to move an account's login between config slots without ever interrupting a session; this step lays the safety groundwork so two background credential operations can never step on each other and accidentally knock a healthy login into a "please log in again" state. You don't need to do anything, and you won't see any difference until the feature is explicitly enabled.
|
|
36
|
+
|
|
25
37
|
Sessions that finished their work but still had an old, untouched promise on the books were being killed and revived over and over. That loop is fixed. You'll see fewer "🪦 your session was shut down — a restart is queued" notices on topics where nothing was actually unfinished. Promises still get followed up on by the commitment system — that part is unchanged.
|
|
26
38
|
|
|
27
39
|
When you run me on more than one machine and move a heavy-work conversation from one to another, the bigger model it was using no longer silently disappears the moment the conversation resumes on the new machine. The new machine asks itself whether that conversation should be on the bigger model and re-runs its own cost checks first — quota, budget, anti-flapping, how many big-model sessions it is already running. Only if all of those pass does it put the resumed conversation back on the bigger model. If they do not, or if you pinned that topic to never escalate, it simply runs on the normal model. So the bigger-model state follows your moved conversation, but it is always re-priced against the machine it lands on, never smuggled across to dodge a budget. This ships off by default and does nothing at all on a single machine.
|
|
28
40
|
|
|
29
41
|
## Summary of New Capabilities
|
|
30
42
|
|
|
43
|
+
| Capability | How to Use |
|
|
44
|
+
|-----------|-----------|
|
|
45
|
+
| Serialized credential writes (per-slot lock) | Automatic (internal) — every Claude credential write is now ordered through one funnel |
|
|
46
|
+
| Busy-lock safety on token refresh | Automatic — a contended refresh retries next cycle instead of triggering needs-reauth |
|
|
47
|
+
| Unfunneled-write lint guard | Automatic at build time (`npm run lint`) |
|
|
48
|
+
|
|
31
49
|
No new capability — this is a bugfix. It removes a spurious session kill→revive loop driven by stale (long-untouched) open commitments by aligning the reaper guard's resume-eligibility decision with its existing kill-decision staleness rule.
|
|
32
50
|
|
|
33
51
|
`models.tierEscalation.ridesTopic` (default false) — when on (under `tierEscalation.enabled`), a topic moved via `POST /pool/transfer` while escalated carries its escalation trigger as an ephemeral hint, and the destination re-admits the resumed session through its own EscalationGovernor cost guards. New exported `EscalationHintStore` (the ephemeral per-topic hint carrier). No new HTTP route; rides the existing `/pool/transfer` + `topic-profile-pull` surfaces.
|
|
34
52
|
|
|
35
53
|
## Evidence
|
|
36
54
|
|
|
55
|
+
- 15 new unit tests (`credential-write-routing.test.ts`, `lint-no-unfunneled-credential-write.test.ts`) plus all 193 pre-existing credential/oauth/quota/account-switcher tests green (208 total). The routing tests prove end-to-end that a busy lock yields `write-skipped` → no-snapshot and does NOT mark `needs-reauth`, with a CONTRAST test proving a genuine `exchange-failed` still does.
|
|
56
|
+
- `npx tsc --noEmit` clean; full `npm run lint` chain clean (including the new lint and the destructive-tool lint with the new script allowlisted).
|
|
57
|
+
- Independent second-pass reviewer subagent: **CONCUR** — traced the token-refresh hot path, confirmed no regression, no lock-wedge, no double-lock, and a sound lint on both axes; the one non-blocking concern (lock-key string fragility) was fixed inline via `credentialSlotKey`.
|
|
58
|
+
|
|
37
59
|
- Live `logs/reap-log.jsonl` (2026-06-13): **13** age-limit reaps with `midWork=true`, every one carrying solely `workEvidence=[open-commitment]`, across 6 topics — and corresponding repeated `reason=age-limit` respawn entries in the resume queue (several doubled per topic). The loop, captured.
|
|
38
60
|
- Root cause confirmed in source: `ReapGuard.evaluate()` gates the open-commitment KEEP on `recentUserMessage(topicId, staleCommitmentWindowMs)`; `ReapGuard.workEvidence()` did not.
|
|
39
61
|
- New regression tests in `tests/unit/work-evidence.test.ts`: a stale commitment emits no `open-commitment` evidence (and `isMidWork`→false); a fresh one still does; an explicit `evaluate()`/`workEvidence()` consistency assertion; and the `protectOpenCommitments:false` boundary. All 22 work-evidence + 19 reap-guard unit tests green; 164 related reap/resume tests green; `tsc --noEmit` clean.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Upgrade Guide — vNEXT
|
|
2
|
+
|
|
3
|
+
<!-- assembled-by: assemble-next-md -->
|
|
4
|
+
<!-- bump: patch -->
|
|
5
|
+
|
|
6
|
+
## What Changed
|
|
7
|
+
|
|
8
|
+
The staged-exchange primitive (spec §2.3) that MOVES an account's OAuth credential between two config-home "slots" without restarting the sessions reading them. The sessions switch token sources on their next API call (the Claude client re-reads its credential store), so no restart, no aborted turns.
|
|
9
|
+
|
|
10
|
+
- **Exchange, never copy** — `swap(slotA, slotB)` exchanges the two slots' credentials so exactly one account-lineage lives in each readable config home (the one-home-per-credential invariant holds by construction).
|
|
11
|
+
- **Preconditions before any path expansion** — both slots must be EXACT members of the location ledger's enumerated slot set before any home-expansion/keychain call, so a `../`/`~`/absolute traversal value can never reach a keychain service (validated against the ledger, not the filesystem); a quarantined or no-refresh-token slot is refused.
|
|
12
|
+
- **Source-slot CAS (§2.3.1a)** — immediately before each destructive write the slot is re-read; a same-tenant newer blob (the Claude client's own rotated copy) is ADOPTED, a different-tenant blob is a clobber-race that ABORTS the swap, and a changed blob the oracle cannot confirm ABORTS — never a silent overwrite.
|
|
13
|
+
- **Crash-proof staging** — blob A is COPIED into a disjoint `instar-credential-swap-staging-*` keychain namespace and `begin` is journaled BEFORE the first destructive write, so a crash before the exchange unwinds to a true no-op and a crash mid-exchange recovers from the retained escrow.
|
|
14
|
+
- **Identity-verified, repair-safe** — after the exchange each slot is verified on its ACCOUNT IDENTITY via the profile-endpoint oracle. A confirmed match commits; a mismatch with a reachable oracle gets ONE repair-from-staging then re-verify; an UNAVAILABLE oracle is quarantine-never-repair (an outage never triggers a destructive repair). The executor never commits a slot it cannot identity-confirm.
|
|
15
|
+
- **Commit, then delayed re-verify** — the ledger is updated with staging RETAINED, then ~90s later both slots are re-verified before staging is deleted and the journal closed (the heal source for a client write-back that lands in the in-flight window).
|
|
16
|
+
- **Bounded everywhere** — all `security` keychain calls are async with a 10s timeout (never sync, which could wedge the event loop); every swap runs under the single-mover mutex + both slot locks (the Step-4b funnel); boot recovery acquires the single-mover mutex and the balancer's first pass is gated on a recovery barrier WITH a hang-timeout (a wedged recovery can't freeze the system).
|
|
17
|
+
- **Dark** — gated by the existing `subscriptionPool.credentialRepointing` flag (`enabled:false` + `dryRun:true`). With the feature off (the fleet + dev default) the executor is a strict no-op; with dry-run on it runs the full decision loop and audits the would-swap with zero writes. Going live needs a deliberate two-flag flip.
|
|
18
|
+
|
|
19
|
+
The HTTP route + census re-routing that wire this into the running system are Steps 6 and 7 (a later increment); this ships the swap primitive plus its three test tiers.
|
|
20
|
+
|
|
21
|
+
## What to Tell Your User
|
|
22
|
+
|
|
23
|
+
This is internal plumbing that ships turned off, so day to day nothing changes for you. What it builds toward: when I hold more than one of your subscription accounts, I can move which account a running session is using WITHOUT restarting that session — today moving an account means killing and respawning the session, which loses in-flight work and re-pays the warm-up cost. The new mechanism swaps the credential under the hood and the session just picks up the new account on its next request. It is built to be safe under failure: it never leaves a session pointed at an account it could not positively confirm, it survives a crash mid-swap without losing a login, and it can never run two swaps at once on the same machine. It is off by default and does nothing until it is deliberately turned on after a review window.
|
|
24
|
+
|
|
25
|
+
## Summary of New Capabilities
|
|
26
|
+
|
|
27
|
+
New exported `CredentialSwapExecutor` (src/core/CredentialSwapExecutor.ts) — the spec §2.3 staged-exchange primitive of live credential re-pointing. Gated by the existing `subscriptionPool.credentialRepointing` flag; no new config flag, no new HTTP route (routes are a later step). Consumes the merged Step 2/3/4 primitives (location ledger, identity oracle, write funnel).
|
|
28
|
+
|
|
29
|
+
## Evidence
|
|
30
|
+
|
|
31
|
+
- `tests/unit/credential-swap-executor.test.ts` (20) — dark/dryRun inertness; preconditions (non-member/quarantined/no-refresh-token rejection, zero writes); the happy exchange (keychain-first, identity-verified, one-lineage-per-home, staging retained then deleted at step 6); clobber-race (different-tenant abort, same-tenant-newer adopt); identity verify/repair/quarantine (mismatch repair-then-commit; oracle-unavailable quarantine-never-repair); THE blocker lens (no committed-unverified write); permutation-property (2 and 5 concurrent swaps serialize, exactly one exchange); crash-at-every-boundary (budgets 0/1/2 + hang-timeout barrier quarantining before lift); orphan-staging sweep predicate; token-material scrub.
|
|
32
|
+
- `tests/integration/credential-swap-executor-host.test.ts` (2) — the executor composed as its host will (REAL CredentialIdentityOracle profile-endpoint + REAL pool-mapping + REAL ledger + REAL funnel): oracle ALLOW yields commit; oracle UNAVAILABLE (5xx) at verify yields quarantine + attention.
|
|
33
|
+
- `tests/e2e/credential-swap-executor-dark-ship-lifecycle.test.ts` (3) — Phase-1 dark-ship inertness on the production config path: a DEV and a fleet agent's real ConfigDefaults gate the executor to a strict no-op; recovery on the dark config is still crash-safe.
|
|
34
|
+
- tsc clean; full `npm run lint` clean; dark-gate unchanged; docs-coverage green (CredentialSwapExecutor documented in architecture/under-the-hood.md).
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Side-Effects Review — Live credential re-pointing (Increment A, Step 4b: route writers through the funnel + forbidding lint)
|
|
2
|
+
|
|
3
|
+
**Version / slug:** `live-credential-repointing-increment-a-funnel-routing`
|
|
4
|
+
**Date:** `2026-06-13`
|
|
5
|
+
**Author:** `echo`
|
|
6
|
+
**Second-pass reviewer:** `required` (touches the live token-refresh hot path — see §5/§7; result appended below)
|
|
7
|
+
|
|
8
|
+
## Summary of the change
|
|
9
|
+
|
|
10
|
+
Routes every in-process write to the `Claude Code-credentials` keychain store through the Step-4a `CredentialWriteFunnel` (spec §2.2), and adds the forbidding lint that makes the routing structural:
|
|
11
|
+
|
|
12
|
+
- **`OAuthRefresher.refreshClaudeToken`** — its single `store.write` is now wrapped in `funnel.withSlotLock(configHome, …)`. A lock-acquire timeout returns the NEW typed reason `write-skipped` (the exchange already succeeded; the existing, still-valid credential is untouched). `RefreshDeps.funnel` is injectable, defaulting to the process-wide `credentialWriteFunnel` singleton.
|
|
13
|
+
- **`QuotaPoller.pollAccount`** — maps `write-skipped` to "no snapshot this cycle, retry next tick", explicitly NOT `markNeedsReauth`. A busy lock can never cry-wolf a healthy login into needs-reauth.
|
|
14
|
+
- **`CredentialProvider`** — adds `writeCredentialsSerialized(provider, slot, creds, funnel?)`, the sanctioned chokepoint that wraps `provider.writeCredentials` in `withSlotLock`. `KeychainCredentialProvider.writeCredentials` (the raw `security -i` write) is unchanged — it is a primitive; callers serialize.
|
|
15
|
+
- **`AccountSwitcher.switchAccount`** — now writes via `writeCredentialsSerialized(...)`; a busy lock returns a non-destructive "store busy, try again", never a corrupting half-write.
|
|
16
|
+
- **`scripts/lint-no-unfunneled-credential-write.js`** (+ wired into `npm run lint`, allowlisted in `lint-no-direct-destructive.js`) — forbids, outside the closed allowlist (the funnel + the two primitive owners + the lint itself): `defaultCredentialStore.write(`, a qualified `.writeCredentials(`, and a raw `add-generic-password` in a file that targets the `Claude Code-credentials` service (file-scoped so the OTHER vaults — WorktreeKeyVault / SecretStore / GlobalSecretStore / RemediationKeyVault, each a distinct service — never false-positive).
|
|
17
|
+
- Tests: `credential-write-routing.test.ts` (7) + `lint-no-unfunneled-credential-write.test.ts` (8).
|
|
18
|
+
|
|
19
|
+
Ships DARK: the credential-repointing feature gate is untouched and stays off+dry-run. The funnel is pure in-process serialization with NO behavioral change when no swap is running (which is always, while dark) — it only adds per-slot ordering and a bounded skip-and-retry under contention.
|
|
20
|
+
|
|
21
|
+
## Decision-point inventory
|
|
22
|
+
|
|
23
|
+
- `refreshClaudeToken` write → `withSlotLock` — **modify** — serialize the refresh write; contention → `write-skipped` (retry), never corruption.
|
|
24
|
+
- `QuotaPoller` `write-skipped` mapping — **add** — a busy lock is NOT a dead login; no `needs-reauth`.
|
|
25
|
+
- `writeCredentialsSerialized` — **add** — the one sanctioned provider-write chokepoint.
|
|
26
|
+
- `lint-no-unfunneled-credential-write` — **add** — structural enforcement that no future writer bypasses the funnel. Signal-only (a build-time lint), no runtime authority.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## 1. Over-block
|
|
31
|
+
|
|
32
|
+
The only new "refusal" is a try-lock-timeout SKIP, which is the §2.2 bounded-wait contract, not a content decision. A legitimate refresh write that loses the 15s race for its slot is reported `write-skipped` and retried next poll cycle — it is never dropped or failed. A legitimate account-switch write that loses the race gets a "store busy, try again" and the operator retries. Neither rejects a valid input; both defer it by one cycle. Because the feature ships dark (no swaps run), the only lock holders are these writes themselves (sub-millisecond `security` calls), so contention — and therefore any skip — is effectively unreachable until Step 5 introduces a longer-held swap lock.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## 2. Under-block
|
|
37
|
+
|
|
38
|
+
The funnel can only serialize a writer that routes through it. That is exactly what the new lint enforces: the real tree is now lint-clean, and any future callsite that hand-rolls a `Claude Code-credentials` write (raw `security`, `defaultCredentialStore.write`, or `provider.writeCredentials`) outside the allowlist fails the build. The lint is line-scoped and skips comments (a documentation mention is not a bypass) and file-scoped to the guarded service (the four sibling keychain vaults are out of scope by construction, verified: none contains the `Claude Code-credentials` literal). Residual not covered: a brand-new keychain service string for Claude credentials would not be caught by the service-literal scope — acceptable, because the service name is a fixed Anthropic-client constant (`claudeCredentialService`), not something a new writer invents.
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## 3. Level-of-abstraction fit
|
|
43
|
+
|
|
44
|
+
Correct layer. The wrapping lives in the two files that OWN the write primitives (`OAuthRefresher`, `CredentialProvider`) — the SafeGit/SafeFs precedent of "the primitive's owner is the funnel-internal site, everything else routes through a named method." The QuotaPoller maps the new reason at the exact point it already classifies refresh outcomes. The lint mirrors `lint-no-unfunneled-topic-creation` / `-headless-launch` precisely (closed allowlist, comment-skipping, `--staged` bootstrap). No higher layer should own this — credential writes are a `src/core`/`src/monitoring` concern.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## 4. Signal vs authority compliance
|
|
49
|
+
|
|
50
|
+
Compliant. The funnel is mechanism (a per-slot mutex with a bounded outcome), not authority over agent behavior — it makes no allow/deny decision about content, only about lock availability, deterministically. The QuotaPoller mapping REMOVES a false-authority failure mode (a transient lock contention was never a real `needs-reauth`, and now can't masquerade as one). The lint is a build-time signal with zero runtime authority. No brittle check gains blocking authority over messaging, dispatch, or sessions.
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## 5. Interactions
|
|
55
|
+
|
|
56
|
+
- **Hot path — token refresh:** the load-bearing interaction. `refreshClaudeToken` keeps every session's access token fresh; the only change to it is that its write is serialized. The existing 17 oauth-refresher + quota-poller + credential tests (193 → all green) prove the default-singleton path is behaviorally identical to today (a free lock ⇒ `ran:true` ⇒ same write, same result). The single NEW path is `ran:false` ⇒ `write-skipped` ⇒ QuotaPoller returns no-snapshot — covered by a dedicated test plus the CONTRAST test proving `exchange-failed` still marks `needs-reauth`.
|
|
57
|
+
- **No double-lock / deadlock:** the primitives (`store.write`, `provider.writeCredentials`) do NOT self-lock; callers lock once. `writeCredentialsSerialized` is the only locker for the provider path, so AccountSwitcher → serialized → provider.write is a single lock acquisition. Verified by the per-slot-isolation test (a busy slot does not block a different slot).
|
|
58
|
+
- **Shared singleton:** refresh, switch, and (Step 5) swap all use `credentialWriteFunnel`, so a refresh and a switch on the SAME slot genuinely serialize. Slot keys are the expanded config home; the default slot is `expandHome('~/.claude')` on both paths so they share one lock.
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## 6. External surfaces
|
|
63
|
+
|
|
64
|
+
No new routes, no network, no notices. The only externally observable change is benign: an account-switch that hits a busy lock returns a clearer "store busy, try again" message instead of proceeding, and a refresh that hits a busy lock logs one warn line and skips a single poll cycle. Token values are never logged (the skip reason and warn line are credential-free). Nothing changes for other agents/users/machines.
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## 7. Multi-machine posture (Cross-Machine Coherence)
|
|
69
|
+
|
|
70
|
+
**Machine-local BY DESIGN.** Credential writes target THIS machine's keychain, so the serialization that protects them is inherently per-process/per-machine — there is no shared state to replicate. The funnel singleton serializes writers WITHIN one process; cross-machine coordination of a credential move (a topic transfer mid-swap) is the existing handoff guard's responsibility, composed-with in Step 5, not this lock's. A second machine refreshing its OWN keychain copy of the same account is independent by construction (each machine has its own login lineage) — the spec's one-home-per-credential invariant is about config homes on a single machine, enforced by the ledger (Step 2), not by this lock.
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## 8. Rollback cost
|
|
75
|
+
|
|
76
|
+
Low. Plain `git revert` of this commit restores the direct writes; no migration, no persisted state, no schema. The funnel singleton and primitive remain (shipped in Step 4a) but go unused. Because the feature is dark and the change is behaviorally identical under no-contention, a revert is invisible to production. The lint removal is a one-line edit to `npm run lint` if ever needed.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## Second-pass review
|
|
81
|
+
|
|
82
|
+
_Appended by the dedicated independent reviewer subagent (Phase 5), 2026-06-13._
|
|
83
|
+
|
|
84
|
+
**VERDICT: CONCUR.** The reviewer independently traced every hot-path concern and confirmed: tests 15/15 green; no hot-path regression (the `write-skipped` path returns no-snapshot and never `markNeedsReauth` — verified end-to-end through `QuotaPoller.pollAccount`); no lock-wedge (acquisition is bounded; `mine` releases only after the real prior holder settles; every `fn` here is a synchronous `security`/fs call); no double-lock/deadlock (neither primitive self-locks; `writeCredentialsSerialized` is the sole locker on the provider path); the lint is sound on both axes (regex matches qualified calls but not the method definition / interface / `writeCredentialsSerialized`; the raw-keychain rule is file-scoped to the guarded service so the four sibling vaults never false-positive); behaviorally inert when dark.
|
|
85
|
+
|
|
86
|
+
**One non-blocking concern raised → FIXED in this commit (not deferred).** The reviewer noted the per-slot lock key was string-identity-fragile: a default account enrolled non-canonically (`~/.claude/` with a trailing slash, or a differently-spelled path) could key the refresh write and the switch write to *different* locks for the *same* keychain entry, letting them race. Resolution: added `credentialSlotKey(configHome) = path.resolve(expandHome(configHome))` in `OAuthRefresher.ts` and routed all three lock keys through it (`refreshClaudeToken`, `DEFAULT_CREDENTIAL_SLOT`, `writeCredentialsSerialized`), so the shared-resource→lock-key mapping is canonical rather than dependent on operator spelling. This strictly improves on today's behavior (which has no funnel at all) and removes the latent footgun before Step 5's longer-held swap lock can expose it.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Side-Effects Review — WS5.2 Step 5: CredentialSwapExecutor (staged-exchange swap primitive)
|
|
2
|
+
|
|
3
|
+
**Spec:** docs/specs/live-credential-repointing-rebalancer.md §2.3 (converged 5 rounds 50→0, `approved:true` — operator pre-approval, Justin topic 13481). **Parent:** Cross-Machine Coherence — One Agent, Robust Under Degraded Conditions.
|
|
4
|
+
**Ships DARK** behind the EXISTING `subscriptionPool.credentialRepointing` flag (`enabled:false` + `dryRun:true`, a `DARK_GATE_EXCLUSIONS` `destructive` entry shipped by Step 4b). Going live needs a deliberate two-flag flip. No new config flag, no new dark-gate line.
|
|
5
|
+
**Files:** src/core/CredentialSwapExecutor.ts (new), scripts/lint-no-unfunneled-credential-write.js (allowlist + header), tests/unit/lint-no-unfunneled-credential-write.test.ts (allowlist ratchet), tests/unit/credential-swap-executor.test.ts (new), tests/integration/credential-swap-executor-host.test.ts (new), tests/e2e/credential-swap-executor-dark-ship-lifecycle.test.ts (new), site/src/content/docs/architecture/under-the-hood.md (awareness).
|
|
6
|
+
|
|
7
|
+
## What changed
|
|
8
|
+
|
|
9
|
+
1. **CredentialSwapExecutor.ts (new):** the §2.3 staged-exchange primitive. `swap(slotA, slotB)` EXCHANGES (never copies) the two slots' OAuth credentials so the sessions reading them switch token sources on their next API call (E3) with zero restart. The full §2.3 sequence: preconditions (EXACT ledger membership BEFORE any path expansion → traversal can't reach a keychain service) → §2.3.1a source-slot CAS re-read (adopt a same-tenant newer blob, ABORT a different-tenant clobber-race) → staging escrow COPY into a disjoint `instar-credential-swap-staging-*` namespace + journal `begin` → exchange (keychain-first, config-second) → verify on ACCOUNT IDENTITY via the injected oracle resolver (match→commit, mismatch→one repair-from-staging→re-verify, **unavailable→QUARANTINE-NEVER-REPAIR**) → commit with staging RETAINED → delayed ~90s re-verify → delete staging + journal `done`. Consumes the merged primitives: `CredentialWriteFunnel.withSingleMover`+`withSlotLocks`, `CredentialLocationLedger` (membership/journal/quarantine/recordAssignment), and an injected `ResolveSlotIdentity` (composed by the host from `CredentialIdentityOracle`+pool).
|
|
10
|
+
2. **Async keychain exec surface (`KeychainCredentialExec` + `defaultKeychainExec`):** ALL `security` calls go through async `execFile` + a 10s timeout — NEVER `execFileSync` (a locked-keychain ACL prompt would wedge the event loop). Injectable so tests run zero-keychain. The default impl derives the slot service via the imported `claudeCredentialService(slot)`; the literal `'Claude Code-credentials'` never appears in the executor source.
|
|
11
|
+
3. **Boot recovery (`recover()`):** acquires the single-mover mutex (a recovery WRITE must not race a boot balancer pass on the ledger), resolves every in-flight journal row by re-reading live identity (adopt-on-newer, never a blind staging overwrite), and lifts a **recovery-complete barrier** so the balancer's first pass can run. The barrier carries a bounded HANG-TIMEOUT: a wedged recovery write quarantines its slots BEFORE the lift, so the post-lift balancer structurally cannot select a wedged slot. Recovery completion is INDEPENDENT of dryRun (an already-begun exchange is finished for crash-safety even on the dark config).
|
|
12
|
+
4. **`sweepOrphanStaging()`:** the §2.3 step-2 orphan predicate — a staging entry is protected by ANY non-`done` swap journal row (begin/exchanged/verified all keep their staging as the step-6 heal source); only a `done` row (or no row) makes staging an orphan. Avoids both the lost-source bug (deleting a committed row's heal source) and the orphan-leak.
|
|
13
|
+
5. **Single audit + scrub chokepoint:** every audited step routes through one `audit()` funnel that scrubs token material (`sk-ant-…` runs → `redactToken`) before it reaches any persisted/served/notified surface (§2.9). Quarantines raise ONE HIGH attention item (the blast-radius surface).
|
|
14
|
+
6. **lint allowlist (Step 4b ratchet):** `src/core/CredentialSwapExecutor.ts` added to the `lint-no-unfunneled-credential-write.js` closed allowlist (named in the header as the sanctioned Step-5 funnel route — its raw `add-generic-password` keychain write runs INSIDE `funnel.withSingleMover → withSlotLocks`, so the funnel-routing is at the call layer; the primitive must not self-lock under the slot locks). The self-test that asserts the closed allowlist set was updated to include the 5th entry.
|
|
15
|
+
7. **Awareness:** a `CredentialSwapExecutor` subsection in `architecture/under-the-hood.md` (Core Infrastructure) — 2 mentions, satisfies docs-coverage for the new exported class.
|
|
16
|
+
|
|
17
|
+
## Blast radius
|
|
18
|
+
|
|
19
|
+
- **The path is config-gated to a strict no-op while dark.** `swap()` returns `disabled` (zero keychain writes, zero ledger mutation) the instant `enabled` is false — the fleet + dev default. `dryRun:true` runs the full decision loop (preconditions + CAS) and audits the WOULD-swap but performs ZERO writes (returns `dry-run` before the staging step). Proven by the E2E dark-ship test (DEV and fleet config) + the unit dark/dryRun tests + the dark-gate test (16/16, unchanged).
|
|
20
|
+
- **No new flag, no new dark-gate line.** The executor reuses the Step-4b `subscriptionPool.credentialRepointing` flag already in `DARK_GATE_EXCLUSIONS`/`ConfigDefaults`, so the cartographer dark-gate line-map did not shift (no recompute needed; `lint-dev-agent-dark-gate.js` clean, the dark-gate test green as-is).
|
|
21
|
+
- **No unverified live commit.** A slot the oracle cannot identity-confirm (mismatch-unrepairable OR oracle-unavailable) is quarantined + surfaced; `swap()` returns `quarantined` BEFORE `recordAssignment` — the ledger never records a clean assignment for an unverified slot. Proven by THE blocker lens test.
|
|
22
|
+
- **No unfunneled credential write.** Every keychain credential write is inside `withSingleMover → withSlotLocks` (the Step-4b funnel guards a swap against a concurrent refresh/switch on the same slot). `lint-no-unfunneled-credential-write` clean.
|
|
23
|
+
- **Per-machine local, no cross-machine egress (Phase C).** The swap moves a credential between config slots on ONE machine; it carries no LAN/broadcast assumption and adds no cross-machine traffic. An N-machine pool runs one single-mover mutex + recovery barrier PER machine; each machine's executor is independent and bounded — the fleet never has two movers on the same home.
|
|
24
|
+
|
|
25
|
+
## Risk + mitigation
|
|
26
|
+
|
|
27
|
+
- **Risk:** a concurrent Claude-client keychain write on the source slot strands a rotated lineage (the §0.c residual instar cannot lock). **Mitigation:** §2.3.1a source-slot CAS re-reads each slot immediately before the destructive write; a same-tenant newer blob is ADOPTED (carry the client's rotated copy), a different-tenant blob ABORTS the swap (clobber-race), an unconfirmable changed blob ABORTS (never overwrite what we can't identity-verify). The window is narrowed (final-re-read→write), not closed against an external writer — the §2.3.6 delayed re-verify + the §2.4 audit catch the honest residual. Proven by the clobber-race adopt/abort tests.
|
|
28
|
+
- **Risk:** a crash mid-exchange destroys a blob or strands staging. **Mitigation:** staging is a COPY (slot A untouched until step 3 → crash-before-step-3 unwinds to a no-op); `begin` is journaled before the first destructive write; staging is RETAINED through step 6 (the heal source); recovery resolves every in-flight row by live identity (adopt-on-newer, never blind). Proven by the crash-at-every-boundary tests (budgets 0/1/2) + the orphan-sweep predicate test.
|
|
29
|
+
- **Risk:** an oracle outage during verify triggers a destructive repair-storm against healthy blobs. **Mitigation:** oracle-UNAVAILABLE is read as "unverified", NEVER as "mismatch" — it quarantines (never repairs); repair is reserved for a CONFIRMED mismatch with a reachable oracle. Proven by the quarantine-never-repair test + the host-wired REAL-oracle 5xx test.
|
|
30
|
+
- **Risk:** a wedged recovery write (keychain ACL prompt) freezes the balancer forever. **Mitigation:** the recovery barrier carries a bounded hang-timeout that quarantines the unresolved slots THEN lifts (the per-write 10s execFile timeout is what releases the held lock). Proven by the hang-timeout barrier test.
|
|
31
|
+
- **Risk:** a token byte leaks into a log/attention/reason string. **Mitigation:** single `audit()`/scrub chokepoint redacts every `sk-ant-…` run before any surface; quarantine reasons + oracle error strings pass through `scrub()`. Proven by the token-scrub test (an adversarial token-bearing oracle reason is redacted).
|
|
32
|
+
- **Risk:** two swaps interleave and corrupt a slot. **Mitigation:** the single-mover mutex admits exactly one swap; the loser is a transient `skipped` (retry), never a partial write. Proven by the permutation-property tests (2 and 5 concurrent swaps → exactly one exchange).
|
|
33
|
+
|
|
34
|
+
## Migration parity
|
|
35
|
+
|
|
36
|
+
- **No migration needed.** No new config flag (reuses the Step-4b `credentialRepointing` block already in `ConfigDefaults` + `DARK_GATE_EXCLUSIONS`), no new hook, no CLAUDE.md template section (this ships dark + internal; the awareness note lives in the docs site, not the agent template — so no `templates.ts`/`migrateClaudeMd` touch, no `featureSections`/shadow-marker entry). The executor is a new module consumed by the Step-6/7 host wiring (a later increment); nothing on the production path constructs it live yet.
|
|
37
|
+
|
|
38
|
+
## Dark-gate line-map
|
|
39
|
+
|
|
40
|
+
- UNCHANGED. No new `enabled:` literal was added to `ConfigDefaults.ts` — the executor is gated by the existing `subscriptionPool.credentialRepointing.enabled` line (shipped by Step 4b). The dark-gate attributor reads `ConfigDefaults.ts` `enabled:` lines only; none shifted. Verified: `node scripts/lint-dev-agent-dark-gate.js` → clean; `tests/unit/credential-repointing-dark-gate.test.ts` → green; the dark-gate ground-truth test → unchanged.
|
|
41
|
+
|
|
42
|
+
## Rollback
|
|
43
|
+
|
|
44
|
+
- The feature is already dark (`enabled:false` + `dryRun:true`) — the executor is inert. To fully revert Step 5: remove `src/core/CredentialSwapExecutor.ts` + its three test files, revert the lint allowlist entry + its self-test edit, and drop the `under-the-hood.md` subsection. Dark + additive throughout; no consumer on the production path depends on it yet.
|
|
45
|
+
|
|
46
|
+
## Tests
|
|
47
|
+
|
|
48
|
+
- `tests/unit/credential-swap-executor.test.ts` (20) — dark/dryRun inertness; preconditions (non-member/quarantined/no-refresh-token rejection, zero writes); the happy exchange (keychain-first, identity-verified, §0.d one-lineage-per-home, staging retained→step-6 delete); **clobber-race** (different-tenant→abort, same-tenant-newer→adopt); **identity verify/repair/quarantine** (mismatch→one-repair→commit; oracle-unavailable→quarantine-never-repair); **THE blocker lens** (no committed-unverified write); **permutation-property** (2 + 5 concurrent swaps serialize, exactly one exchange); **crash-at-every-boundary** (budgets 0/1/2 + hang-timeout barrier with pre-lift quarantine); orphan-staging sweep predicate; token-scrub.
|
|
49
|
+
- `tests/integration/credential-swap-executor-host.test.ts` (2) — the executor composed exactly as the Step-7 host will (REAL `CredentialIdentityOracle` profile-endpoint + REAL pool-mapping + REAL ledger + REAL funnel): oracle ALLOW → commit; oracle UNAVAILABLE (5xx) at verify → QUARANTINE + attention.
|
|
50
|
+
- `tests/e2e/credential-swap-executor-dark-ship-lifecycle.test.ts` (3) — Phase-1 dark-ship inertness on the PRODUCTION config path: a DEV and a fleet agent's real ConfigDefaults gate the executor to a strict no-op (zero writes, ledger untouched, byte-identical blobs); recovery on the dark config is still crash-safe (an already-begun exchange finishes regardless of dryRun).
|
|
51
|
+
- Full unit suite green (zero regressions); `npm run lint` clean (tsc + all structural lints).
|
|
52
|
+
|
|
53
|
+
## Phase C answer (operator clarity)
|
|
54
|
+
|
|
55
|
+
The swap is **per-machine local** — it moves a credential between config slots on ONE machine and adds no cross-machine egress / no LAN/broadcast assumption. An N-machine pool runs its single-mover mutex + recovery barrier independently per machine, so the fleet never has two movers on the same home; each machine's executor is bounded by its own mutex.
|
|
56
|
+
|
|
57
|
+
## 4-lens adversarial verdict
|
|
58
|
+
|
|
59
|
+
- **Unverified-write bypass (THE blocker):** PASS — no path commits a slot the oracle cannot identity-confirm; an unconfirmable slot is quarantined and `swap()` returns before `recordAssignment`. Test: "no committed-unverified write".
|
|
60
|
+
- **Clobber-race / source-slot CAS:** PASS — a concurrent client write is adopted (same tenant) or aborts (different tenant / unconfirmable), never silently overwritten. Tests: clobber-race adopt/abort.
|
|
61
|
+
- **Crash-boundary coherence:** PASS — journal begin/done + retained staging + adopt-on-newer recovery leave no lost-lineage state. Tests: crash budgets 0/1/2 + hang-timeout barrier.
|
|
62
|
+
- **Dark-ship inertness:** PASS — flag off → strict no-op; reuses the existing dark-gate exclusion (no line shift). Tests: E2E dark-ship (dev+fleet) + dark-gate.
|
|
63
|
+
|
|
64
|
+
<!-- tracked: 20905 -->
|