@skrr-ai/cli 0.1.123 → 0.1.124
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/commands/agents/delete.d.ts +8 -0
- package/dist/commands/agents/delete.js +16 -0
- package/dist/commands/context.js +7 -0
- package/dist/commands/search/workspace.js +1 -1
- package/dist/commands/store/gallery-admissions/approve.js +1 -1
- package/dist/commands/store/gallery-admissions/preview.js +1 -1
- package/dist/commands/store/gallery-admissions/refuse.js +1 -1
- package/dist/commands/store/gallery-admissions/show.js +1 -1
- package/dist/lib/auth-core-init.d.ts +15 -2
- package/dist/lib/auth-core-init.js +47 -6
- package/dist/lib/command-hygiene.js +30 -1
- package/dist/lib/daemonBrokerRefusal.js +5 -1
- package/dist/lib/keychain.js +41 -4
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/cliHandoffWire.d.ts +5 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/cliHandoffWire.js +1 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialEnvelopeBridge.js +13 -28
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceIdentityBridge.d.ts +133 -4
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceIdentityBridge.js +387 -90
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKey.d.ts +106 -7
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKey.js +77 -19
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKeyHandle.d.ts +33 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKeyHandle.js +77 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKeyHardware.d.ts +68 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKeyHardware.js +264 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKeyServerSupport.d.ts +31 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKeyServerSupport.js +90 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/dpop.js +2 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/independentMachineReservation.js +2 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.d.ts +7 -3
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.js +102 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/index.d.ts +1 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/index.js +6 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/linux.d.ts +18 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/linux.js +8 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/macos.d.ts +22 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/macos.js +11 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/masterKekService.d.ts +43 -3
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/masterKekService.js +33 -3
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/liveState.d.ts +154 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/liveState.js +281 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/machineId.js +6 -3
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/machineUuid.js +8 -5
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/profileStateDir.js +7 -27
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/refresh.d.ts +1 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/refresh.js +12 -10
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/stateFiles.d.ts +31 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/stateFiles.js +89 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/cliHandoffWire.d.ts +5 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/cliHandoffWire.js +4 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialEnvelopeBridge.js +15 -37
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceIdentityBridge.d.ts +133 -4
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceIdentityBridge.js +536 -101
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKey.d.ts +106 -7
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKey.js +131 -27
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKeyHandle.d.ts +33 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKeyHandle.js +81 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKeyHardware.d.ts +68 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKeyHardware.js +317 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKeyServerSupport.d.ts +31 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKeyServerSupport.js +86 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/dpop.js +4 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/independentMachineReservation.js +4 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.d.ts +7 -3
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.js +13 -3
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/index.d.ts +1 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/index.js +1 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/linux.d.ts +18 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/linux.js +15 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/macos.d.ts +22 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/macos.js +20 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/masterKekService.d.ts +43 -3
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/masterKekService.js +104 -8
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/liveState.d.ts +154 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/liveState.js +386 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/machineId.js +6 -3
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/machineUuid.js +11 -5
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/profileStateDir.js +6 -35
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/refresh.d.ts +1 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/refresh.js +14 -10
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/stateFiles.d.ts +31 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/stateFiles.js +73 -0
- package/dist/node_modules/@skrr-ai/auth-core/package.json +11 -1
- package/dist/node_modules/@skrr-ai/data-provider/index.js +25 -7
- package/oclif.manifest.json +1 -1
- package/package.json +4 -1
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* liveState.ts — a test process does not mutate the state of the product installed on the
|
|
3
|
+
* machine running it.
|
|
4
|
+
*
|
|
5
|
+
* The party that owns the credential files is this package, so the refusal lives at ITS write
|
|
6
|
+
* boundary. A runner-level setup (a jest global setup, a node:test `--import` loader) is
|
|
7
|
+
* necessary and insufficient: it cannot cover a child process spawned without `env`, a read of
|
|
8
|
+
* `os.userInfo().homedir`, a runner nobody wired, or a test file run directly with `node`. This
|
|
9
|
+
* module is the one place that answers the two questions every one of those cases comes down to,
|
|
10
|
+
* and `stateFiles.ts` asks them before every mutation.
|
|
11
|
+
*
|
|
12
|
+
* It exists because the same accident happened under three different runners. On 2026-10-29 a
|
|
13
|
+
* desktop jest spec assigned `process.env.HOME`, which moves nothing under jest (a worker gets a
|
|
14
|
+
* COPY of the environment, and `os.homedir()` reads the real one), ran the real credential
|
|
15
|
+
* envelope against the real `~/.skrr`, and replaced the `default` profile's wrapped DEK. The
|
|
16
|
+
* Keychain was faked, so the real KEK could not open it. The guard had been rebuilt per runner
|
|
17
|
+
* (daemon, cli, desktop) and each rebuild left the next runner uncovered.
|
|
18
|
+
*
|
|
19
|
+
* 1. "Is this a test process?" `testProcessSignal` / `isTestProcess`.
|
|
20
|
+
* 2. "What is the real home?" `accountHomeDir` — the account's home from the OS user
|
|
21
|
+
* database, NOT `os.homedir()` or `$HOME`, which a test can move. Node's
|
|
22
|
+
* `os.userInfo().homedir` reads the user database; Bun's returns `$HOME`, so there it is
|
|
23
|
+
* read from the database directly.
|
|
24
|
+
*
|
|
25
|
+
* Then `assertMayMutateLiveState(target, what)` for a path and `assertMayMutateLiveKeychain`
|
|
26
|
+
* for a Keychain service. Both are no-ops outside a test process: they read a handful of
|
|
27
|
+
* environment variables and return, so production behaviour is unchanged.
|
|
28
|
+
*
|
|
29
|
+
* Fails CLOSED. When the real home cannot be determined, a test process may mutate only what
|
|
30
|
+
* lies under the sandbox home a hermetic runner created (`SKRR_TEST_HERMETIC_HOME`); everything
|
|
31
|
+
* else is refused, because "unknown" is not "safe".
|
|
32
|
+
*
|
|
33
|
+
* Deliberately dependency-free (node builtins and two leaf constants): the daemon imports this
|
|
34
|
+
* at module load, before anything else in it exists.
|
|
35
|
+
*/
|
|
36
|
+
import { execFileSync } from 'node:child_process';
|
|
37
|
+
import { createHash } from 'node:crypto';
|
|
38
|
+
import fs from 'node:fs';
|
|
39
|
+
import os from 'node:os';
|
|
40
|
+
import path from 'node:path';
|
|
41
|
+
import { MASTER_KEK_KEYCHAIN_SERVICE } from './kek/masterKekService.js';
|
|
42
|
+
import { CONFIG_DIR_NAME, LEGACY_NATIVE_ID_PREFIXES, NATIVE_ID_PREFIX } from './localIdentity.js';
|
|
43
|
+
/** Directory name of the state root inside a home directory. */
|
|
44
|
+
export { CONFIG_DIR_NAME };
|
|
45
|
+
/** Opt-in for a deliberate check against the machine's real state. */
|
|
46
|
+
export const ALLOW_LIVE_STATE_ENV = 'SKRR_TEST_ALLOW_LIVE_STATE';
|
|
47
|
+
/** Set by every hermetic runner to the throwaway home it gave the process. */
|
|
48
|
+
export const HERMETIC_HOME_ENV = 'SKRR_TEST_HERMETIC_HOME';
|
|
49
|
+
/** `LiveStateRefusedError.code`. Kept at its pre-consolidation value: scripts match on it. */
|
|
50
|
+
export const LIVE_STATE_REFUSED_CODE = 'SKRR_TEST_LIVE_STATE';
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// 1. Is this a test process?
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
/** A test file's name, as an entry script: `node --import tsx src/foo.test.ts`. */
|
|
55
|
+
const TEST_FILE_RE = /\.(?:test|spec)\.[cm]?[jt]sx?$/;
|
|
56
|
+
/**
|
|
57
|
+
* Which signal says this is a test process, or `null`. The text names the signal so a false
|
|
58
|
+
* positive (a shell that exports `NODE_ENV=test`) diagnoses itself.
|
|
59
|
+
*
|
|
60
|
+
* `NODE_TEST_CONTEXT` is set by `node --test` in every file's child process and inherited by
|
|
61
|
+
* anything a test spawns. `--test` in `execArgv` covers `--test-isolation=none`, where tests run
|
|
62
|
+
* in the runner itself. A test file run DIRECTLY (`node file.test.ts`) has neither: node:test
|
|
63
|
+
* sets no marker for it, so the entry script's name is the only evidence. `JEST_WORKER_ID` is
|
|
64
|
+
* set in the real environment of every jest worker (and by `--runInBand`), so a child a spec
|
|
65
|
+
* spawns without `env` still carries it. `NODE_ENV=test` is what jest, vitest and `bun test`
|
|
66
|
+
* default to, and what custom scripts set by hand.
|
|
67
|
+
*
|
|
68
|
+
* `argv` is the process's own only when `env` is: a caller describing a hypothetical process
|
|
69
|
+
* (`{}`) describes all of it.
|
|
70
|
+
*/
|
|
71
|
+
export function testProcessSignal(env = process.env, execArgv = process.execArgv, argv = env === process.env ? process.argv : []) {
|
|
72
|
+
if (env.NODE_TEST_CONTEXT)
|
|
73
|
+
return 'NODE_TEST_CONTEXT is set (node --test)';
|
|
74
|
+
if (env.JEST_WORKER_ID)
|
|
75
|
+
return 'JEST_WORKER_ID is set (jest)';
|
|
76
|
+
if (env.VITEST || env.VITEST_WORKER_ID)
|
|
77
|
+
return 'VITEST is set (vitest)';
|
|
78
|
+
if (env[HERMETIC_HOME_ENV])
|
|
79
|
+
return `${HERMETIC_HOME_ENV} is set (a hermetic test runner)`;
|
|
80
|
+
if (env.NODE_ENV === 'test')
|
|
81
|
+
return 'NODE_ENV=test';
|
|
82
|
+
if (execArgv.includes('--test'))
|
|
83
|
+
return 'node was started with --test';
|
|
84
|
+
const entry = argv[1];
|
|
85
|
+
if (typeof entry === 'string' && TEST_FILE_RE.test(entry)) {
|
|
86
|
+
return `the entry script ${path.basename(entry)} is a test file`;
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
/** Is this a test process? See `testProcessSignal` for what counts. */
|
|
91
|
+
export function isTestProcess(env = process.env, execArgv = process.execArgv, argv) {
|
|
92
|
+
return testProcessSignal(env, execArgv, argv) !== null;
|
|
93
|
+
}
|
|
94
|
+
/** Should a test process be refused access to the machine's real state? */
|
|
95
|
+
export function refusesLiveStateInTest(env = process.env, execArgv = process.execArgv, argv) {
|
|
96
|
+
return isTestProcess(env, execArgv, argv) && env[ALLOW_LIVE_STATE_ENV] !== '1';
|
|
97
|
+
}
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// 2. What is the real home?
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
let cachedAccountHome;
|
|
102
|
+
function userDatabaseHome() {
|
|
103
|
+
const probe = (command, args) => execFileSync(command, args, {
|
|
104
|
+
encoding: 'utf-8',
|
|
105
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
106
|
+
timeout: 5_000,
|
|
107
|
+
});
|
|
108
|
+
try {
|
|
109
|
+
if (process.platform === 'darwin') {
|
|
110
|
+
const out = probe('dscl', [
|
|
111
|
+
'.',
|
|
112
|
+
'-read',
|
|
113
|
+
`/Users/${os.userInfo().username}`,
|
|
114
|
+
'NFSHomeDirectory',
|
|
115
|
+
]);
|
|
116
|
+
const home = out.replace(/^NFSHomeDirectory:\s*/, '').trim();
|
|
117
|
+
return home || null;
|
|
118
|
+
}
|
|
119
|
+
if (process.platform === 'linux' && typeof process.getuid === 'function') {
|
|
120
|
+
const home = probe('getent', ['passwd', String(process.getuid())])
|
|
121
|
+
.trim()
|
|
122
|
+
.split(':')[5];
|
|
123
|
+
return home || null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* The account's home directory from the user database. A test that points `HOME` somewhere else
|
|
133
|
+
* cannot move it, which is the point: it names where the INSTALLED product keeps its state,
|
|
134
|
+
* whatever this process believes.
|
|
135
|
+
*
|
|
136
|
+
* Node's `os.userInfo().homedir` reads the user database. Bun's returns `$HOME`, which is
|
|
137
|
+
* exactly what a test moves, so the compiled daemon asks the database itself — without that, a
|
|
138
|
+
* compiled binary spawned by a test with its own `HOME` saw that home as the live one and
|
|
139
|
+
* refused to start. `null` when it cannot be determined.
|
|
140
|
+
*/
|
|
141
|
+
export function accountHomeDir() {
|
|
142
|
+
if (cachedAccountHome !== undefined)
|
|
143
|
+
return cachedAccountHome;
|
|
144
|
+
if (typeof process.versions.bun === 'string') {
|
|
145
|
+
cachedAccountHome = userDatabaseHome();
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
try {
|
|
149
|
+
cachedAccountHome = os.userInfo().homedir || null;
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
cachedAccountHome = null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return cachedAccountHome;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* @internal Test seam — make `accountHomeDir()` answer `home` (`null`: "cannot be determined"),
|
|
159
|
+
* or restore the user-database lookup with `undefined`. It lets a spec stand a THROWAWAY
|
|
160
|
+
* directory in for the account's real home, so the guard can be exercised end to end — and its
|
|
161
|
+
* refusal removed, to prove the spec notices — without any path ever touching the real one.
|
|
162
|
+
* Never called from production code; `stateFileBoundary.spec.ts` fails if it is.
|
|
163
|
+
*/
|
|
164
|
+
export function __setAccountHomeForTest(home) {
|
|
165
|
+
cachedAccountHome = home;
|
|
166
|
+
}
|
|
167
|
+
/** The state root of the product installed for this account, when it can be known. */
|
|
168
|
+
export function liveStateRoot(home = accountHomeDir()) {
|
|
169
|
+
return home ? path.resolve(home, CONFIG_DIR_NAME) : null;
|
|
170
|
+
}
|
|
171
|
+
function probeHome(probe) {
|
|
172
|
+
const home = probe.realHome === undefined ? accountHomeDir() : probe.realHome;
|
|
173
|
+
// A filesystem root as "the home" (a service account with HOME=/) would make every path live
|
|
174
|
+
// and the guard useless; it cannot tell the product's state from anything else.
|
|
175
|
+
if (!home || path.parse(path.resolve(home)).root === path.resolve(home))
|
|
176
|
+
return null;
|
|
177
|
+
return home;
|
|
178
|
+
}
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// Path containment, robust to symlinks and case-insensitive volumes.
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
/**
|
|
183
|
+
* `target` with every symlink resolved on its longest EXISTING prefix, the rest appended. A
|
|
184
|
+
* state directory that does not exist yet still lands where it will be created, and a symlink
|
|
185
|
+
* inside a sandbox that points back at the real home does not hide it.
|
|
186
|
+
*/
|
|
187
|
+
function canonicalise(target) {
|
|
188
|
+
const absolute = path.resolve(target);
|
|
189
|
+
let head = absolute;
|
|
190
|
+
const tail = [];
|
|
191
|
+
for (;;) {
|
|
192
|
+
try {
|
|
193
|
+
const real = fs.realpathSync.native(head);
|
|
194
|
+
return tail.length === 0 ? real : path.join(real, ...tail.reverse());
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
const parent = path.dirname(head);
|
|
198
|
+
if (parent === head)
|
|
199
|
+
return absolute;
|
|
200
|
+
tail.push(path.basename(head));
|
|
201
|
+
head = parent;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
function isWithin(child, parent) {
|
|
206
|
+
const rel = path.relative(parent, child);
|
|
207
|
+
return rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel);
|
|
208
|
+
}
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
// The refusal.
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
export class LiveStateRefusedError extends Error {
|
|
213
|
+
code = LIVE_STATE_REFUSED_CODE;
|
|
214
|
+
/** What the mutation was aimed at: a path, or a Keychain service. */
|
|
215
|
+
target;
|
|
216
|
+
/** Why this process was taken for a test. */
|
|
217
|
+
signal;
|
|
218
|
+
constructor(what, target, signal, why, hint) {
|
|
219
|
+
super(`Refusing to ${what}: ${why}. This process looks like a test (${signal}), and the target ` +
|
|
220
|
+
`belongs to the Skrr daemon, CLI and desktop app installed on this machine. ` +
|
|
221
|
+
(hint ??
|
|
222
|
+
'Run the suite through its own test script (`npm test`, or `npm run test:file -- ' +
|
|
223
|
+
'<path>` in daemon/), which gives every test process a home of its own, or point ' +
|
|
224
|
+
'HOME at a throwaway directory for the run (HOME=$(mktemp -d) …). ') +
|
|
225
|
+
`Set ${ALLOW_LIVE_STATE_ENV}=1 only for a deliberate check against the real state.`);
|
|
226
|
+
this.name = 'LiveStateRefusedError';
|
|
227
|
+
this.target = target;
|
|
228
|
+
this.signal = signal;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Is `err` a live-state refusal? By name and code as well as by class: auth-core ships as both
|
|
233
|
+
* ESM and CJS, and two copies of it in one process (a farmed worktree) have two classes.
|
|
234
|
+
*/
|
|
235
|
+
export function isLiveStateRefusal(err) {
|
|
236
|
+
return (err instanceof LiveStateRefusedError ||
|
|
237
|
+
(err?.name === 'LiveStateRefusedError' &&
|
|
238
|
+
err.code === LIVE_STATE_REFUSED_CODE));
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* First line of any `catch` that wraps a guarded mutation. A refusal swallowed into a telemetry
|
|
242
|
+
* event is a write that silently did not happen under a test that reports green, which is the
|
|
243
|
+
* "everything reads healthy" shape this repo has paid for before. A no-op outside a test
|
|
244
|
+
* process, where no refusal is ever constructed.
|
|
245
|
+
*/
|
|
246
|
+
export function rethrowIfLiveStateRefusal(err) {
|
|
247
|
+
if (isLiveStateRefusal(err))
|
|
248
|
+
throw err;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Why `target` may not be mutated by a test process, or `null` when it may. The caller has
|
|
252
|
+
* already established that this is a test process and that the opt-in is not set.
|
|
253
|
+
*/
|
|
254
|
+
function pathRefusal(target, probe, env) {
|
|
255
|
+
const resolved = canonicalise(target);
|
|
256
|
+
const home = probeHome(probe);
|
|
257
|
+
if (home === null) {
|
|
258
|
+
// Fail closed: the only place a test may write when the real home is unknown is the sandbox
|
|
259
|
+
// a hermetic runner created.
|
|
260
|
+
const sandbox = env[HERMETIC_HOME_ENV];
|
|
261
|
+
if (sandbox && isWithin(resolved, canonicalise(sandbox)))
|
|
262
|
+
return null;
|
|
263
|
+
return (`${target} cannot be shown to be outside the real home, because the account's home ` +
|
|
264
|
+
`directory could not be determined`);
|
|
265
|
+
}
|
|
266
|
+
return isWithin(resolved, canonicalise(home))
|
|
267
|
+
? `${target} is inside the real home ${home}`
|
|
268
|
+
: null;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Throw `LiveStateRefusedError` when a test process is about to mutate `target`, a path under
|
|
272
|
+
* the account's real home. Returns without reading anything else outside a test process.
|
|
273
|
+
*
|
|
274
|
+
* `what` completes "Refusing to …" (`replace the wrapped DEK`). `hint` replaces the default
|
|
275
|
+
* advice about how to run the test safely.
|
|
276
|
+
*/
|
|
277
|
+
export function assertMayMutateLiveState(target, what, probe = {}, hint) {
|
|
278
|
+
const env = probe.env ?? process.env;
|
|
279
|
+
const signal = testProcessSignal(env, probe.execArgv ?? process.execArgv, probe.argv);
|
|
280
|
+
if (signal === null || env[ALLOW_LIVE_STATE_ENV] === '1')
|
|
281
|
+
return;
|
|
282
|
+
const why = pathRefusal(target, probe, env);
|
|
283
|
+
if (why !== null)
|
|
284
|
+
throw new LiveStateRefusedError(what, target, signal, why, hint);
|
|
285
|
+
}
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
// The Keychain's live service set.
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
/** Service name prefix of the daemon's credential slots; always followed by `.<profile>`. */
|
|
290
|
+
export const DAEMON_KEYCHAIN_SERVICE_BASE = `${NATIVE_ID_PREFIX}.daemon`;
|
|
291
|
+
/**
|
|
292
|
+
* Separate Keychain service for local BYOK secrets.
|
|
293
|
+
*
|
|
294
|
+
* Keeping provider keys out of the daemon-auth service prevents a broad "export daemon
|
|
295
|
+
* credentials" operation from accidentally including them and lets the user remove a provider
|
|
296
|
+
* key without disturbing their login.
|
|
297
|
+
*/
|
|
298
|
+
export const DAEMON_BYOK_KEYCHAIN_SERVICE_BASE = `${NATIVE_ID_PREFIX}.daemon.byok`;
|
|
299
|
+
/**
|
|
300
|
+
* The PRE-RENAME, pre-Phase-K service name. Read ONLY by the daemon's migration helper.
|
|
301
|
+
*
|
|
302
|
+
* It must NOT equal `DAEMON_KEYCHAIN_SERVICE_BASE`, and it held that value until a fix: the
|
|
303
|
+
* skrr rename swept the string like any other, and the two became identical. Nothing failed,
|
|
304
|
+
* because the failure is an absence — `migrateLegacyServiceToProfile` read a service nothing had
|
|
305
|
+
* ever written, found no slots, and reported success. Pre-rename credentials stayed where they
|
|
306
|
+
* were and the user silently re-authenticated. Written as a literal on purpose.
|
|
307
|
+
*/
|
|
308
|
+
export const LEGACY_DAEMON_KEYCHAIN_SERVICE = 'ai.oversky.daemon';
|
|
309
|
+
/** The profile the installed daemon runs when nothing selects another. */
|
|
310
|
+
export const LIVE_DEFAULT_PROFILE = 'default';
|
|
311
|
+
/**
|
|
312
|
+
* `-<sha256(dir)[0:8]>`, the suffix of every per-profile Keychain service name. The input is the
|
|
313
|
+
* absolute, trailing-slash-stripped config directory, so two OS users running the daemon under
|
|
314
|
+
* their own `~/.skrr/` get distinct names and cannot read each other's tokens.
|
|
315
|
+
*/
|
|
316
|
+
export function keychainSuffixForDir(dir) {
|
|
317
|
+
const resolved = path.resolve(dir.replace(/\/+$/, ''));
|
|
318
|
+
return `-${createHash('sha256').update(resolved).digest('hex').toLowerCase().slice(0, 8)}`;
|
|
319
|
+
}
|
|
320
|
+
/** A per-profile default-profile service whose hash we cannot verify without the real home. */
|
|
321
|
+
const UNVERIFIABLE_DEFAULT_SLOT_RE = new RegExp(`^(?:${DAEMON_KEYCHAIN_SERVICE_BASE.replace(/\./g, '\\.')}|` +
|
|
322
|
+
`${DAEMON_BYOK_KEYCHAIN_SERVICE_BASE.replace(/\./g, '\\.')})\\.` +
|
|
323
|
+
`${LIVE_DEFAULT_PROFILE}-[0-9a-f]{8}$`);
|
|
324
|
+
/**
|
|
325
|
+
* Keychain services that belong to the product installed on this machine: the master KEK
|
|
326
|
+
* (current brand and the ones before it), the default profile's credential and BYOK slots under
|
|
327
|
+
* the account's real home, and the legacy names the daemon migrates from on start — a write to
|
|
328
|
+
* one of those is adopted by the next restart instead of the next read.
|
|
329
|
+
*
|
|
330
|
+
* The per-profile names are derived from the ACCOUNT's home, which no `HOME` override can reach.
|
|
331
|
+
* When the home is unknown only the names that need no home are returned; see
|
|
332
|
+
* `isLiveKeychainService` for what is done about the rest.
|
|
333
|
+
*/
|
|
334
|
+
export function liveKeychainServices(probe = {}) {
|
|
335
|
+
const services = new Set([
|
|
336
|
+
MASTER_KEK_KEYCHAIN_SERVICE,
|
|
337
|
+
...LEGACY_NATIVE_ID_PREFIXES.map((prefix) => `${prefix}.daemon.kek`),
|
|
338
|
+
`${DAEMON_KEYCHAIN_SERVICE_BASE}.${LIVE_DEFAULT_PROFILE}`,
|
|
339
|
+
DAEMON_KEYCHAIN_SERVICE_BASE,
|
|
340
|
+
LEGACY_DAEMON_KEYCHAIN_SERVICE,
|
|
341
|
+
]);
|
|
342
|
+
const root = liveStateRoot(probeHome(probe));
|
|
343
|
+
if (root) {
|
|
344
|
+
const suffix = keychainSuffixForDir(path.resolve(root, 'profiles', LIVE_DEFAULT_PROFILE));
|
|
345
|
+
services.add(`${DAEMON_KEYCHAIN_SERVICE_BASE}.${LIVE_DEFAULT_PROFILE}${suffix}`);
|
|
346
|
+
services.add(`${DAEMON_BYOK_KEYCHAIN_SERVICE_BASE}.${LIVE_DEFAULT_PROFILE}${suffix}`);
|
|
347
|
+
}
|
|
348
|
+
return services;
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Does `service` name an item of the installed product? When the real home is unknown, a
|
|
352
|
+
* default-profile slot with a hash suffix is assumed live: its hash cannot be checked, and the
|
|
353
|
+
* guard fails closed.
|
|
354
|
+
*/
|
|
355
|
+
export function isLiveKeychainService(service, probe = {}) {
|
|
356
|
+
if (liveKeychainServices(probe).has(service))
|
|
357
|
+
return true;
|
|
358
|
+
return probeHome(probe) === null && UNVERIFIABLE_DEFAULT_SLOT_RE.test(service);
|
|
359
|
+
}
|
|
360
|
+
/** Would a mutation of Keychain `service` be refused in a process with this shape? */
|
|
361
|
+
export function refusesLiveKeychainMutation(service, probe = {}) {
|
|
362
|
+
const env = probe.env ?? process.env;
|
|
363
|
+
return (refusesLiveStateInTest(env, probe.execArgv ?? process.execArgv, probe.argv) &&
|
|
364
|
+
isLiveKeychainService(service, probe));
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Throw `LiveStateRefusedError` when a test process is about to write or delete an item in a
|
|
368
|
+
* Keychain service the installed product reads. Callers that can be handed a fake `security`
|
|
369
|
+
* binary call this only when the real one would run: a mocked write touches nothing.
|
|
370
|
+
*/
|
|
371
|
+
export function assertMayMutateLiveKeychain(service, what, probe = {}, hint) {
|
|
372
|
+
const env = probe.env ?? process.env;
|
|
373
|
+
const signal = testProcessSignal(env, probe.execArgv ?? process.execArgv, probe.argv);
|
|
374
|
+
if (signal === null || env[ALLOW_LIVE_STATE_ENV] === '1')
|
|
375
|
+
return;
|
|
376
|
+
if (!isLiveKeychainService(service, probe))
|
|
377
|
+
return;
|
|
378
|
+
let where = '';
|
|
379
|
+
if (probe.keychainFile !== undefined) {
|
|
380
|
+
const why = pathRefusal(probe.keychainFile, probe, env);
|
|
381
|
+
if (why === null)
|
|
382
|
+
return;
|
|
383
|
+
where = `, in a keychain that is not shown to be anyone else's (${why})`;
|
|
384
|
+
}
|
|
385
|
+
throw new LiveStateRefusedError(what, service, signal, `the Keychain service ${service} is one the installed product reads${where}`, hint);
|
|
386
|
+
}
|
|
@@ -27,7 +27,9 @@ import crypto from 'node:crypto';
|
|
|
27
27
|
import fs from 'node:fs';
|
|
28
28
|
import os from 'node:os';
|
|
29
29
|
import path from 'node:path';
|
|
30
|
+
import { rethrowIfLiveStateRefusal } from './liveState.js';
|
|
30
31
|
import { getAuthMachineConfigDir, getAuthLogger } from './runtime.js';
|
|
32
|
+
import { ensureStateDir, renameStateFile, writeStateFile } from './stateFiles.js';
|
|
31
33
|
/** File name for the stable machine daemon id. Never changes. */
|
|
32
34
|
const DAEMON_ID_FILENAME = 'daemon.id';
|
|
33
35
|
/** Profile subdirectory — scanned for legacy per-profile identity files. */
|
|
@@ -86,16 +88,16 @@ function isUuidLike(raw) {
|
|
|
86
88
|
*/
|
|
87
89
|
function writeDaemonIdFile(target, id) {
|
|
88
90
|
const dir = path.dirname(target);
|
|
89
|
-
|
|
91
|
+
ensureStateDir(dir, 'create the machine id directory');
|
|
90
92
|
const tmp = `${target}.tmp.${process.pid}.${Math.random().toString(36).slice(2, 10)}`;
|
|
91
|
-
|
|
93
|
+
writeStateFile(tmp, id + '\n', { encoding: 'utf-8', mode: 0o600 }, 'write the machine id');
|
|
92
94
|
try {
|
|
93
95
|
fs.chmodSync(tmp, 0o600);
|
|
94
96
|
}
|
|
95
97
|
catch {
|
|
96
98
|
/* best-effort — writeFileSync already set mode */
|
|
97
99
|
}
|
|
98
|
-
|
|
100
|
+
renameStateFile(tmp, target, 'write the machine id');
|
|
99
101
|
}
|
|
100
102
|
/**
|
|
101
103
|
* Default fallback when a consumer doesn't supply one. Synthesizes a
|
|
@@ -179,6 +181,7 @@ export function ensureMachineDaemonId(machineUuidFallback) {
|
|
|
179
181
|
return fresh;
|
|
180
182
|
}
|
|
181
183
|
catch (err) {
|
|
184
|
+
rethrowIfLiveStateRefusal(err);
|
|
182
185
|
log.warn(`[machineIdentity] persist ${target} failed: ${err.message} — using fallback`);
|
|
183
186
|
const fallback = `fallback-${fallbackFn()}`;
|
|
184
187
|
cachedStableDaemonId = fallback;
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
* identity can only agree by being edited together; this file is the one
|
|
13
13
|
* implementation, and `daemon/src/registry-lite.ts` re-exports it.
|
|
14
14
|
*
|
|
15
|
-
* Deliberately dependency-free (node builtins
|
|
15
|
+
* Deliberately dependency-free (node builtins, plus the leaf `liveState` / `stateFiles`
|
|
16
|
+
* modules that guard its one write, which import nothing else) and exposed as the
|
|
16
17
|
* `@skrr-ai/auth-core/machine-uuid` subpath: the packaged desktop imports the
|
|
17
18
|
* daemon's `registry-lite.js` directly, and loading the full auth-core index
|
|
18
19
|
* there would drag in the credential stack for a one-function need.
|
|
@@ -28,6 +29,8 @@ import os from 'node:os';
|
|
|
28
29
|
import path from 'node:path';
|
|
29
30
|
import { execFileSync } from 'node:child_process';
|
|
30
31
|
import { resolveConfigRoot } from './configRoot.js';
|
|
32
|
+
import { rethrowIfLiveStateRefusal } from './liveState.js';
|
|
33
|
+
import { ensureStateDir, renameStateFile, writeStateFile } from './stateFiles.js';
|
|
31
34
|
/**
|
|
32
35
|
* Daemon sources whose identity is ISSUED by the control plane rather than
|
|
33
36
|
* derived from the host: the provisioner mints the daemon id and machine uuid
|
|
@@ -220,7 +223,7 @@ function readPersistedChoice(file, hardware, profile, requireProfile = false) {
|
|
|
220
223
|
}
|
|
221
224
|
function persistChoice(dir, file, hardware, machineUuid, reason, profile, evidence) {
|
|
222
225
|
try {
|
|
223
|
-
|
|
226
|
+
ensureStateDir(dir, 'create the machine identity directory');
|
|
224
227
|
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
225
228
|
const body = {
|
|
226
229
|
version: 1,
|
|
@@ -236,10 +239,13 @@ function persistChoice(dir, file, hardware, machineUuid, reason, profile, eviden
|
|
|
236
239
|
...(evidence && evidence.length > 0 ? { evidence: [...evidence] } : {}),
|
|
237
240
|
decidedAt: new Date().toISOString(),
|
|
238
241
|
};
|
|
239
|
-
|
|
240
|
-
|
|
242
|
+
writeStateFile(tmp, `${JSON.stringify(body)}\n`, { mode: 0o600 }, 'write the machine identity');
|
|
243
|
+
renameStateFile(tmp, file, 'write the machine identity');
|
|
241
244
|
}
|
|
242
|
-
catch {
|
|
245
|
+
catch (err) {
|
|
246
|
+
// A refusal is the one failure that is not "not persisted": a test aimed at the real home
|
|
247
|
+
// must hear about it.
|
|
248
|
+
rethrowIfLiveStateRefusal(err);
|
|
243
249
|
// Not persisted: the in-process cache keeps this process consistent, and the
|
|
244
250
|
// next process decides again from the same evidence.
|
|
245
251
|
}
|
|
@@ -73,6 +73,7 @@ import { createHash } from 'node:crypto';
|
|
|
73
73
|
import fs from 'node:fs';
|
|
74
74
|
import path from 'node:path';
|
|
75
75
|
import { getAuthConfigDir, getAuthMachineConfigDir } from './runtime.js';
|
|
76
|
+
import { atomicWrite0600, renameStateFile } from './stateFiles.js';
|
|
76
77
|
/** Directory segment that separates per-profile state from machine-scoped state. */
|
|
77
78
|
export const PROFILES_SEGMENT = 'profiles';
|
|
78
79
|
/**
|
|
@@ -286,37 +287,6 @@ function sha256OfFile(target) {
|
|
|
286
287
|
return null;
|
|
287
288
|
}
|
|
288
289
|
}
|
|
289
|
-
/**
|
|
290
|
-
* tmp + fsync + rename + dir-fsync, 0600. The same shape the two bridges
|
|
291
|
-
* already use, so a crash mid-migration leaves either the old file or the new
|
|
292
|
-
* one and never a half-written key.
|
|
293
|
-
*/
|
|
294
|
-
function atomicWrite0600(target, value) {
|
|
295
|
-
const dir = path.dirname(target);
|
|
296
|
-
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
297
|
-
const tmp = `${target}.tmp.${process.pid}.${Date.now()}`;
|
|
298
|
-
const fd = fs.openSync(tmp, 'w', 0o600);
|
|
299
|
-
try {
|
|
300
|
-
fs.writeSync(fd, value);
|
|
301
|
-
fs.fsyncSync(fd);
|
|
302
|
-
}
|
|
303
|
-
finally {
|
|
304
|
-
fs.closeSync(fd);
|
|
305
|
-
}
|
|
306
|
-
fs.renameSync(tmp, target);
|
|
307
|
-
try {
|
|
308
|
-
const dirFd = fs.openSync(dir, 'r');
|
|
309
|
-
try {
|
|
310
|
-
fs.fsyncSync(dirFd);
|
|
311
|
-
}
|
|
312
|
-
finally {
|
|
313
|
-
fs.closeSync(dirFd);
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
catch {
|
|
317
|
-
/* best-effort — Windows / some BSDs reject dir fsync */
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
290
|
function adopt(fromDir, toDir, files, dryRun) {
|
|
321
291
|
const written = [];
|
|
322
292
|
for (const name of files) {
|
|
@@ -324,8 +294,9 @@ function adopt(fromDir, toDir, files, dryRun) {
|
|
|
324
294
|
if (!existsFile(source))
|
|
325
295
|
continue;
|
|
326
296
|
const target = path.join(toDir, name);
|
|
327
|
-
if (!dryRun)
|
|
328
|
-
atomicWrite0600(target, fs.readFileSync(source));
|
|
297
|
+
if (!dryRun) {
|
|
298
|
+
atomicWrite0600(target, fs.readFileSync(source), `adopt ${name} into ${toDir}`);
|
|
299
|
+
}
|
|
329
300
|
written.push(target);
|
|
330
301
|
}
|
|
331
302
|
return { written, supersededAside: [] };
|
|
@@ -337,7 +308,7 @@ function moveAside(dir, files, suffix = SUPERSEDED_COPY_SUFFIX) {
|
|
|
337
308
|
const target = path.join(dir, name);
|
|
338
309
|
if (!existsFile(target))
|
|
339
310
|
continue;
|
|
340
|
-
|
|
311
|
+
renameStateFile(target, `${target}${suffix}.${stamp}`, `set aside ${target}`);
|
|
341
312
|
aside.push(target);
|
|
342
313
|
}
|
|
343
314
|
return aside;
|
|
@@ -355,7 +326,7 @@ function writeMarker(markerPath, supersededBy, reason) {
|
|
|
355
326
|
'older binaries read it and it is the only rollback that needs no operator step. ' +
|
|
356
327
|
'Remove it only through logout.',
|
|
357
328
|
};
|
|
358
|
-
atomicWrite0600(markerPath, Buffer.from(`${JSON.stringify(payload, null, 2)}\n`, 'utf-8'));
|
|
329
|
+
atomicWrite0600(markerPath, Buffer.from(`${JSON.stringify(payload, null, 2)}\n`, 'utf-8'), `write the consolidation marker ${markerPath}`);
|
|
359
330
|
}
|
|
360
331
|
function emitOutcome(spec, status, meta) {
|
|
361
332
|
spec.onEvent?.(`path_consolidation.${status}`, status !== 'conflict', {
|
|
@@ -231,7 +231,7 @@ export type DeviceProofSigner = (params: {
|
|
|
231
231
|
method: string;
|
|
232
232
|
url: string;
|
|
233
233
|
refreshToken: string;
|
|
234
|
-
}) => string | null | undefined
|
|
234
|
+
}) => string | null | undefined | Promise<string | null | undefined>;
|
|
235
235
|
export declare function refreshDaemonToken(serverUrl: string, currentRefreshToken: string, daemonId: string, options?: {
|
|
236
236
|
machineUuid?: string;
|
|
237
237
|
origin?: string;
|
|
@@ -25,7 +25,9 @@ import fs from 'node:fs';
|
|
|
25
25
|
import path from 'node:path';
|
|
26
26
|
import lockfile from 'proper-lockfile';
|
|
27
27
|
import { getAuthLogger, getAuthConfigDir, emitAuthTelemetry, notifyResponseObserver, } from './runtime.js';
|
|
28
|
+
import { rethrowIfLiveStateRefusal } from './liveState.js';
|
|
28
29
|
import { permanentReasonForRefreshCode } from './refreshClassification.js';
|
|
30
|
+
import { ensureStateDir, removeStateFile, writeStateFile } from './stateFiles.js';
|
|
29
31
|
import { PermanentAuthFailure, TransientAuthFailure, } from './types.js';
|
|
30
32
|
/**
|
|
31
33
|
* P1-6: Reduced from 30 s to 5 s.
|
|
@@ -254,9 +256,9 @@ export async function withAuthLock(body, daemonId) {
|
|
|
254
256
|
const lockPath = authLockPath();
|
|
255
257
|
const dir = path.dirname(lockPath);
|
|
256
258
|
if (!fs.existsSync(dir))
|
|
257
|
-
|
|
259
|
+
ensureStateDir(dir, 'create the auth lock directory', null);
|
|
258
260
|
if (!fs.existsSync(lockPath)) {
|
|
259
|
-
|
|
261
|
+
writeStateFile(lockPath, '', { mode: 0o600 }, 'create the auth lock');
|
|
260
262
|
}
|
|
261
263
|
const acquireStart = Date.now();
|
|
262
264
|
emitAuthTelemetry({
|
|
@@ -615,7 +617,7 @@ async function doRefresh(serverUrl, currentRefreshToken, daemonId, options = {})
|
|
|
615
617
|
// `DEVICE_PROOF_MISSING` is only a LOCAL fact (no key to sign with) when we
|
|
616
618
|
// know none went out; if one did, something between us stripped it.
|
|
617
619
|
let proofAttached = false;
|
|
618
|
-
function buildRequestInit() {
|
|
620
|
+
async function buildRequestInit() {
|
|
619
621
|
proofAttached = false;
|
|
620
622
|
// Phase 3-R5: the stable `X-Oversky-Origin` header is the server's
|
|
621
623
|
// preferred identity signal on the benign-grace rotation match. Newer
|
|
@@ -638,7 +640,9 @@ async function doRefresh(serverUrl, currentRefreshToken, daemonId, options = {})
|
|
|
638
640
|
__refreshDiagnostics.requestAttemptCount += 1;
|
|
639
641
|
if (options.signRequestProof) {
|
|
640
642
|
try {
|
|
641
|
-
|
|
643
|
+
// Awaited: a hardware key signs in a helper process. A synchronous
|
|
644
|
+
// signer (a test double, an older caller) still works unchanged.
|
|
645
|
+
const proof = await options.signRequestProof({
|
|
642
646
|
method: 'POST',
|
|
643
647
|
url,
|
|
644
648
|
refreshToken: currentRefreshToken,
|
|
@@ -675,7 +679,7 @@ async function doRefresh(serverUrl, currentRefreshToken, daemonId, options = {})
|
|
|
675
679
|
// Single attempt today. The builder structure is the load-bearing
|
|
676
680
|
// bit: any future retry must call `buildRequestInit()` again so
|
|
677
681
|
// the per-attempt invariant — fresh jti per request — holds.
|
|
678
|
-
response = await fetch(url, buildRequestInit());
|
|
682
|
+
response = await fetch(url, await buildRequestInit());
|
|
679
683
|
notifyResponseObserver(response);
|
|
680
684
|
}
|
|
681
685
|
catch (err) {
|
|
@@ -972,12 +976,11 @@ export function persistReauthState(state) {
|
|
|
972
976
|
try {
|
|
973
977
|
const dir = path.dirname(flagPath);
|
|
974
978
|
if (!fs.existsSync(dir))
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
mode: 0o600,
|
|
978
|
-
});
|
|
979
|
+
ensureStateDir(dir, 'create the re-auth latch directory');
|
|
980
|
+
writeStateFile(flagPath, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 }, 'write the re-auth latch');
|
|
979
981
|
}
|
|
980
982
|
catch (err) {
|
|
983
|
+
rethrowIfLiveStateRefusal(err);
|
|
981
984
|
log.debug(`[auth/refresh] failed to persist reauth flag: ${err?.message}`);
|
|
982
985
|
}
|
|
983
986
|
}
|
|
@@ -1009,9 +1012,10 @@ export function clearReauthState() {
|
|
|
1009
1012
|
try {
|
|
1010
1013
|
const flagPath = reauthFlagPath();
|
|
1011
1014
|
if (fs.existsSync(flagPath))
|
|
1012
|
-
|
|
1015
|
+
removeStateFile(flagPath, 'clear the re-auth latch');
|
|
1013
1016
|
}
|
|
1014
1017
|
catch (err) {
|
|
1018
|
+
rethrowIfLiveStateRefusal(err);
|
|
1015
1019
|
log.debug(`[auth/refresh] clearReauthState: ${err?.message}`);
|
|
1016
1020
|
}
|
|
1017
1021
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stateFiles.ts — the ONE place auth-core mutates credential state on disk.
|
|
3
|
+
*
|
|
4
|
+
* Every write, delete, rename and mkdir of a wrapped DEK, a device key, an enrollment marker, a
|
|
5
|
+
* lock sentinel, a re-auth latch or a machine identity goes through a function here, and each
|
|
6
|
+
* asks `assertMayMutateLiveState` first: a test process aimed at the account's real home is
|
|
7
|
+
* refused with a named error before a byte changes (`liveState.ts` says why that cannot be left
|
|
8
|
+
* to each runner).
|
|
9
|
+
*
|
|
10
|
+
* This is the "own a control surface, or do not touch it" rule applied to the real home. The
|
|
11
|
+
* atomic writer used to exist three times (device identity, credential envelope, profile
|
|
12
|
+
* consolidation), so a guard added to one left two doors open. `stateFileBoundary.spec.ts`
|
|
13
|
+
* fails when a file other than this one starts calling a raw `fs` mutator, so a fourth copy
|
|
14
|
+
* cannot appear unguarded.
|
|
15
|
+
*
|
|
16
|
+
* Outside a test process every function here is exactly the `fs` call it wraps.
|
|
17
|
+
*/
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
/** `mkdir -p` for a state directory, 0700 unless `mode` says otherwise. */
|
|
20
|
+
export declare function ensureStateDir(dir: string, what: string, mode?: number | null): void;
|
|
21
|
+
/**
|
|
22
|
+
* Atomic 0600 write — tmp+fsync+rename+dir-fsync. The same shape the daemon and CLI always used,
|
|
23
|
+
* so a partial-write race between `skrr` and `skrrd` produces the same on-disk transitions.
|
|
24
|
+
*/
|
|
25
|
+
export declare function atomicWrite0600(target: string, value: Buffer | string, what: string): void;
|
|
26
|
+
/** `fs.writeFileSync` for a state file: lock sentinels, a re-auth latch, a machine identity. */
|
|
27
|
+
export declare function writeStateFile(target: string, data: string | NodeJS.ArrayBufferView, options: fs.WriteFileOptions, what: string): void;
|
|
28
|
+
/** `fs.unlinkSync` for a state file. Throws what `unlinkSync` throws, ENOENT included. */
|
|
29
|
+
export declare function removeStateFile(target: string, what: string): void;
|
|
30
|
+
/** `fs.renameSync` between state paths; both ends are checked. */
|
|
31
|
+
export declare function renameStateFile(from: string, to: string, what: string): void;
|