@skrr-ai/cli 0.1.55 → 0.1.57

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.
@@ -3,8 +3,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.CliCredentialInitializationError = void 0;
6
+ exports.CliCredentialInitializationError = exports.CREDENTIAL_KEY_CHANGED_NOTICE = void 0;
7
7
  exports.plainCredentialEnvelopeWarning = plainCredentialEnvelopeWarning;
8
+ exports.createCredentialEnvelopeWarningPresenter = createCredentialEnvelopeWarningPresenter;
8
9
  exports.prepareDedicatedRuntimeAuthGeneration = prepareDedicatedRuntimeAuthGeneration;
9
10
  exports.ensureAuthCoreConfigured = ensureAuthCoreConfigured;
10
11
  exports.prefetchCliAuth = prefetchCliAuth;
@@ -75,6 +76,73 @@ function plainCredentialEnvelopeWarning(args) {
75
76
  const remedy = hint === auth_core_1.DEFAULT_UNREADABLE_CREDENTIAL_HINT ? ' Run `skrr login`.' : '';
76
77
  return `warning: ${hint.trim()}.${remedy} (DEBUG=1 shows the underlying error.)`;
77
78
  }
79
+ /**
80
+ * The one line an EXPECTED credential-key change prints (OSK-12186).
81
+ *
82
+ * When the key that wraps this profile's credential key changes underneath it
83
+ * — a Dedicated guest moved to a new image gets a new `/etc/machine-id`, which
84
+ * the Linux key is derived from; a new Mac or a reset Keychain does the same —
85
+ * auth-core cannot unwrap the old key, enrolls a new one and carries on. That
86
+ * is recovery, not damage, but it reached the terminal as four warnings: the
87
+ * unwrap failure, the rotation notice, and one decrypt failure per credential
88
+ * still sealed under the old key, each ending in a raw crypto string. The
89
+ * second run was clean, which is exactly what corruption does not look like.
90
+ *
91
+ * It is distinct from a GENUINELY unreadable credential (OSK-12180): there the
92
+ * key unwraps and one stored value still cannot be opened, nothing will fix it
93
+ * by itself, and that line names `skrr login`. Here the old values are
94
+ * unreadable BECAUSE the key was replaced, and the next step happens on its
95
+ * own when a local daemon can hand a credential over.
96
+ */
97
+ exports.CREDENTIAL_KEY_CHANGED_NOTICE = "note: this machine's credential key changed (a new machine or image), so credentials " +
98
+ 'saved under the old key can no longer be read. skrr gets a new one from the local daemon when ' +
99
+ 'one is running; otherwise run `skrr login`. (DEBUG=1 shows the underlying error.)';
100
+ /**
101
+ * What the CLI prints for one auth-core warning: a replacement line, nothing
102
+ * (`''`), or null for "print it as auth-core wrote it".
103
+ *
104
+ * Stateful by design — the process that detected a key change has already
105
+ * told the person why every old credential is unreadable, so the per-value
106
+ * decrypt failures that follow add nothing. A fresh process starts clean, and
107
+ * a later genuinely unreadable credential still gets its own OSK-12180 line.
108
+ */
109
+ /** auth-core's device-identity warning for a private key it could not open. */
110
+ const DEVICE_KEY_DECRYPT_FAILED = '[deviceIdentity] private_key_decrypt_failed';
111
+ function createCredentialEnvelopeWarningPresenter() {
112
+ let keyChanged = false;
113
+ return (args) => {
114
+ const [message, metadata] = args;
115
+ const event = typeof message === 'string' && message.startsWith('[credEnvelope] ')
116
+ ? message.slice('[credEnvelope] '.length)
117
+ : null;
118
+ const meta = (metadata && typeof metadata === 'object' ? metadata : {});
119
+ // auth-core emits this and then, from the same branch, `kek_rotated`: the
120
+ // wrapped key was present and would not unwrap. The notice below is the
121
+ // one line for both.
122
+ if (event === 'cred_envelope.unwrap.failed' && meta.reason === 'wrapped_dek_unwrap_threw') {
123
+ return '';
124
+ }
125
+ if (event === 'cred_envelope.kek_rotated') {
126
+ if (keyChanged)
127
+ return '';
128
+ keyChanged = true;
129
+ return exports.CREDENTIAL_KEY_CHANGED_NOTICE;
130
+ }
131
+ // Every value sealed under the old key fails here now — the notice has
132
+ // already said why. A MALFORMED envelope is a different fact and still prints.
133
+ if (keyChanged && event === 'cred_envelope.read.decrypt_failed')
134
+ return '';
135
+ // The device private key is one of those values: auth-core's device
136
+ // identity loads it next, cannot open it, and mints a new keypair. It
137
+ // logs that through its own `[deviceIdentity]` prefix, which reached the
138
+ // terminal as a raw `[auth-core warn]` line right under the notice. The
139
+ // notice covers it; without a key change it is a real fault and prints.
140
+ if (keyChanged && message === DEVICE_KEY_DECRYPT_FAILED)
141
+ return '';
142
+ return plainCredentialEnvelopeWarning(args);
143
+ };
144
+ }
145
+ const presentCredentialEnvelopeWarning = createCredentialEnvelopeWarningPresenter();
78
146
  const cliLogger = {
79
147
  // The CLI is a short-lived foreground process. Trace/debug get dropped
80
148
  // unless DEBUG is set so we don't spam terminal output.
@@ -87,7 +155,9 @@ const cliLogger = {
87
155
  console.error('[auth-core info]', ...args);
88
156
  },
89
157
  warn: (...args) => {
90
- const plain = process.env.DEBUG ? null : plainCredentialEnvelopeWarning(args);
158
+ const plain = process.env.DEBUG ? null : presentCredentialEnvelopeWarning(args);
159
+ if (plain === '')
160
+ return;
91
161
  if (plain)
92
162
  console.error(plain);
93
163
  else
@@ -48,6 +48,21 @@ export interface WriteOptions {
48
48
  * retiring it. Everything else should keep using `readFromBackend()`.
49
49
  */
50
50
  export declare function readFromFile(): AuthBundle | null;
51
+ /**
52
+ * The refresh family a stored `cli-auth.json` carries, whether or not it also
53
+ * carries an access token — for the Dedicated guest's retirement of a
54
+ * pre-broker file only (OSK-12194).
55
+ *
56
+ * `readFromFile()` treats a file with an empty `token` as no credential, which
57
+ * is right for signing in and wrong here: the realistic pre-broker shape is
58
+ * `{"token":"", "refreshToken":…, "serverOrigin":…}` once its access token
59
+ * lapsed, and a reader that returns nothing for it retires nothing. Same
60
+ * ownership rules as `readFromFile()`; never rewrites the file.
61
+ */
62
+ export declare function readRefreshFamilyFromFile(): {
63
+ refreshToken: string;
64
+ serverOrigin?: string;
65
+ } | null;
51
66
  /**
52
67
  * Delete `~/.skrr/cli-auth.json` only (the keychain is untouched). Exported
53
68
  * for the brokered-hand-off migration cleanup; `skrr logout` continues to
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.backends = void 0;
37
37
  exports.readFromFile = readFromFile;
38
+ exports.readRefreshFamilyFromFile = readRefreshFamilyFromFile;
38
39
  exports.deleteFileBackend = deleteFileBackend;
39
40
  exports.readFromBackend = readFromBackend;
40
41
  exports.describeForeignCredential = describeForeignCredential;
@@ -234,13 +235,33 @@ function judgeOwnership(bundle, me, ownSlot) {
234
235
  function readFromFile() {
235
236
  return readFileAssessed().bundle;
236
237
  }
238
+ /**
239
+ * The refresh family a stored `cli-auth.json` carries, whether or not it also
240
+ * carries an access token — for the Dedicated guest's retirement of a
241
+ * pre-broker file only (OSK-12194).
242
+ *
243
+ * `readFromFile()` treats a file with an empty `token` as no credential, which
244
+ * is right for signing in and wrong here: the realistic pre-broker shape is
245
+ * `{"token":"", "refreshToken":…, "serverOrigin":…}` once its access token
246
+ * lapsed, and a reader that returns nothing for it retires nothing. Same
247
+ * ownership rules as `readFromFile()`; never rewrites the file.
248
+ */
249
+ function readRefreshFamilyFromFile() {
250
+ const bundle = readFileAssessed({ requireAccessToken: false }).bundle;
251
+ if (!bundle?.refreshToken)
252
+ return null;
253
+ return {
254
+ refreshToken: bundle.refreshToken,
255
+ ...(bundle.serverOrigin ? { serverOrigin: bundle.serverOrigin } : {}),
256
+ };
257
+ }
237
258
  /**
238
259
  * This root's file, refused when it belongs to another config; else, under a
239
260
  * root override, the pre-override `$HOME/.skrr` file when it is positively ours.
240
261
  */
241
- function readFileAssessed() {
262
+ function readFileAssessed(opts = {}) {
242
263
  const me = currentOwner();
243
- const own = readFileAt(authFilePath(), true);
264
+ const own = readFileAt(authFilePath(), true, opts);
244
265
  if (own) {
245
266
  const judged = judgeOwnership(own, me, true);
246
267
  return judged.ours ? { bundle: own } : { bundle: null, foreign: judged.stored };
@@ -248,7 +269,7 @@ function readFileAssessed() {
248
269
  const legacy = legacyAuthFilePath();
249
270
  if (path.resolve(legacy) === path.resolve(authFilePath()))
250
271
  return { bundle: null };
251
- const adopted = readFileAt(legacy, false);
272
+ const adopted = readFileAt(legacy, false, opts);
252
273
  if (adopted && judgeOwnership(adopted, me, false).ours)
253
274
  return { bundle: adopted };
254
275
  return { bundle: null };
@@ -265,7 +286,7 @@ function legacyFileIsOurs() {
265
286
  * Parse one credential file. `upgradeInPlace` lets the envelope migration
266
287
  * rewrite it — true only for this root's own file, never a migration source.
267
288
  */
268
- function readFileAt(p, upgradeInPlace) {
289
+ function readFileAt(p, upgradeInPlace, opts = {}) {
269
290
  if (!fs.existsSync(p))
270
291
  return null;
271
292
  let raw;
@@ -282,7 +303,9 @@ function readFileAt(p, upgradeInPlace) {
282
303
  catch {
283
304
  return null;
284
305
  }
285
- if (typeof parsed.token !== 'string' || parsed.token.length === 0) {
306
+ const requireAccessToken = opts.requireAccessToken !== false;
307
+ const hasAccessToken = typeof parsed.token === 'string' && parsed.token.length > 0;
308
+ if (!hasAccessToken && requireAccessToken) {
286
309
  return null;
287
310
  }
288
311
  // L12 — route the secret string fields through the envelope. Mixed
@@ -293,13 +316,16 @@ function readFileAt(p, upgradeInPlace) {
293
316
  const owner = parseOwner(parsed.owner);
294
317
  const ownerRoot = owner?.configRoot ? path.resolve(owner.configRoot) : undefined;
295
318
  const decryptOptions = ownerRoot && ownerRoot !== path.resolve((0, config_1.configRoot)()) ? { foreignOwnerRoot: ownerRoot } : {};
296
- const tokenResult = (0, cred_envelope_1.maybeDecryptOnRead)(parsed.token, decryptOptions);
319
+ const tokenResult = hasAccessToken
320
+ ? (0, cred_envelope_1.maybeDecryptOnRead)(parsed.token, decryptOptions)
321
+ : { plaintext: '', needsMigration: false };
297
322
  if (tokenResult.plaintext === null) {
298
323
  // Envelope on disk but no DEK to unwrap → treat as missing so the
299
324
  // caller falls through to a clean `skrr login`.
300
325
  return null;
301
326
  }
302
- let needsMigration = tokenResult.needsMigration;
327
+ // A token-less read is never written back: there is no access token to seal.
328
+ let needsMigration = hasAccessToken && tokenResult.needsMigration;
303
329
  const bundle = { token: tokenResult.plaintext };
304
330
  if (typeof parsed.expiresAt === 'number' && Number.isFinite(parsed.expiresAt)) {
305
331
  bundle.expiresAt = parsed.expiresAt;
@@ -1334,6 +1334,9 @@ function formatCommitmentExplanation(explanation) {
1334
1334
  : explanation.verdict === 'quiet'
1335
1335
  ? `Chose to stay quiet at: ${explanation.stoppedAt}`
1336
1336
  : `Stopped at: ${explanation.stoppedAt}`);
1337
+ // What the next Run may do without asking — an Ask-mode Agent can ask
1338
+ // nobody on an unattended Run (OSK-12201). Same lines as show/preflight.
1339
+ lines.push(...permissionLines(explanation.permissions));
1337
1340
  return lines;
1338
1341
  }
1339
1342
  /**
@@ -190,6 +190,10 @@ export declare function isEnvAuthOverride(): boolean;
190
190
  * resolver and every existing install read it.
191
191
  */
192
192
  export declare function configRoot(env?: NodeJS.ProcessEnv): string;
193
+ /** The root this machine uses when nothing relocates it (`~/.skrr`). */
194
+ export declare function defaultConfigRoot(): string;
195
+ /** Is this process on the primary root, however it was reached? */
196
+ export declare function isDefaultConfigRoot(env?: NodeJS.ProcessEnv): boolean;
193
197
  /**
194
198
  * The machine cliId THIS config root mints when it has none stored
195
199
  * (OSK-12179). The primary root keeps the historical (host, user) id; any other
@@ -39,6 +39,8 @@ exports.describeDeployment = describeDeployment;
39
39
  exports.canonicalizeKnownBaseURL = canonicalizeKnownBaseURL;
40
40
  exports.isEnvAuthOverride = isEnvAuthOverride;
41
41
  exports.configRoot = configRoot;
42
+ exports.defaultConfigRoot = defaultConfigRoot;
43
+ exports.isDefaultConfigRoot = isDefaultConfigRoot;
42
44
  exports.machineCliId = machineCliId;
43
45
  exports.priorConfigDir = priorConfigDir;
44
46
  exports.loadConfig = loadConfig;
@@ -276,8 +278,8 @@ function defaultConfigRoot() {
276
278
  return (0, auth_core_1.resolveConfigRoot)({});
277
279
  }
278
280
  /** Is this process on the primary root, however it was reached? */
279
- function isDefaultConfigRoot() {
280
- return path.resolve(configRoot()) === path.resolve(defaultConfigRoot());
281
+ function isDefaultConfigRoot(env = process.env) {
282
+ return path.resolve(configRoot(env)) === path.resolve(defaultConfigRoot());
281
283
  }
282
284
  /**
283
285
  * The machine cliId THIS config root mints when it has none stored
@@ -64,6 +64,33 @@ export declare function resolveCredential(opts?: ResolveOptions): Promise<Resolv
64
64
  * a token (e.g., just post-login) don't need to re-resolve.
65
65
  */
66
66
  export declare function kindOf(token: string | null | undefined): TokenKind;
67
+ /**
68
+ * Where an in-memory credential ACTUALLY came from, when the shared
69
+ * `CredentialSource` union has no slot for it.
70
+ *
71
+ * Two CLI paths hold a token that was never read from `OVERSKY_TOKEN` yet
72
+ * are recorded as `env-token` — the union's only "a token this process holds
73
+ * in memory" slot, and the one `ensureFreshCliCredential` correctly skips: a
74
+ * daemon hand-off redeem (renewed by re-redeeming its descriptor) and a
75
+ * daemon-launched Agent session (`OVERSKY_SESSION_TOKEN`). Labelling either
76
+ * by the slot alone printed `Token source: OVERSKY_TOKEN` for a variable
77
+ * that was not set, which sends a debugging user to the wrong place
78
+ * (OSK-12185). The provenance travels BESIDE the credential so the slot keeps
79
+ * its refresh semantics and the label tells the truth.
80
+ */
81
+ export type CredentialProvenance = {
82
+ kind: 'daemon-handoff';
83
+ descriptorPath?: string;
84
+ } | {
85
+ kind: 'daemon-delegated';
86
+ } | {
87
+ kind: 'agent-session';
88
+ };
89
+ /**
90
+ * The label `whoami`, `context` and `doctor` print — text and `--json` read
91
+ * the same value. A recorded provenance wins over the source slot.
92
+ */
93
+ export declare function describeCredentialSource(source: CredentialSource, provenance?: CredentialProvenance | null): string;
67
94
  /**
68
95
  * Human-readable label for a `CredentialSource`. Used by `skrr whoami`
69
96
  * and (Phase H) `skrr doctor`.
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.resolveTrustedAutonomousCredential = resolveTrustedAutonomousCredential;
4
4
  exports.resolveCredential = resolveCredential;
5
5
  exports.kindOf = kindOf;
6
+ exports.describeCredentialSource = describeCredentialSource;
6
7
  exports.formatCredentialSource = formatCredentialSource;
7
8
  /**
8
9
  * credential-resolver.ts — Phase H.1 precedence for the Sky CLI.
@@ -118,6 +119,24 @@ async function resolveCredential(opts = {}) {
118
119
  function kindOf(token) {
119
120
  return (0, auth_core_1.classifyTokenKind)(token);
120
121
  }
122
+ /**
123
+ * The label `whoami`, `context` and `doctor` print — text and `--json` read
124
+ * the same value. A recorded provenance wins over the source slot.
125
+ */
126
+ function describeCredentialSource(source, provenance) {
127
+ if (provenance?.kind === 'daemon-handoff') {
128
+ return provenance.descriptorPath
129
+ ? `daemon hand-off (${provenance.descriptorPath})`
130
+ : 'daemon hand-off';
131
+ }
132
+ if (provenance?.kind === 'daemon-delegated') {
133
+ return 'daemon delegated credential (OVERSKY_DELEGATED_CLI_BROKER_URL)';
134
+ }
135
+ if (provenance?.kind === 'agent-session') {
136
+ return 'agent session (OVERSKY_SESSION_TOKEN)';
137
+ }
138
+ return formatCredentialSource(source);
139
+ }
121
140
  /**
122
141
  * Human-readable label for a `CredentialSource`. Used by `skrr whoami`
123
142
  * and (Phase H) `skrr doctor`.
@@ -115,6 +115,13 @@ export interface DaemonBrokeredOutcome {
115
115
  /** Server the minted credential belongs to (daemon-reported, else the
116
116
  * descriptor's own `serverUrl`). */
117
117
  serverUrl?: string;
118
+ /**
119
+ * The descriptor file the token was redeemed from. The token lives only in
120
+ * this process's memory, so this path is the only honest answer to "where
121
+ * did this credential come from" — `skrr whoami` prints it as the token
122
+ * source (OSK-12185).
123
+ */
124
+ descriptorPath?: string;
118
125
  }
119
126
  /**
120
127
  * Explicit reasons the broker path could fail. These exist to keep the
@@ -335,6 +342,24 @@ export declare function maybeRetireStoredCliAuthFile(opts: {
335
342
  descriptorPath?: string;
336
343
  descriptorServerUrl?: string;
337
344
  }): Promise<void>;
345
+ /**
346
+ * Should this process broker BEFORE it reads the stored credential?
347
+ * (OSK-12194)
348
+ *
349
+ * True only on a brokered Dedicated guest: the guest hand-off descriptor is
350
+ * live and offers the access-token mode and NOT the durable mint (its secret
351
+ * can only redeem, so any `cli-auth.json` here predates broker mode), and that
352
+ * file carries a refresh family for the descriptor's own server — the same
353
+ * provenance guard the retirement applies.
354
+ *
355
+ * Without this, the retirement could never run: it runs after a successful
356
+ * redeem, the auto-broker runs only when the resolver found no token, and the
357
+ * resolver adopts a file that carries one — so a LIVE pre-broker family was
358
+ * used and kept forever, which is exactly the case the cleanup exists for.
359
+ * A laptop has no guest descriptor and is untouched: there `cli-auth.json` is
360
+ * a legitimate login.
361
+ */
362
+ export declare function shouldBrokerBeforeStoredCredential(descriptorPath?: string): boolean;
338
363
  /**
339
364
  * Convenience wrapper: run the broker call and, on a durable-mint success,
340
365
  * persist the returned bundle through the canonical Sky CLI auth backend
@@ -86,6 +86,7 @@ exports.redeemBrokeredHandoffToken = redeemBrokeredHandoffToken;
86
86
  exports.__resetBrokeredHandoffForTest = __resetBrokeredHandoffForTest;
87
87
  exports.findBrokeredHandoffDescriptor = findBrokeredHandoffDescriptor;
88
88
  exports.maybeRetireStoredCliAuthFile = maybeRetireStoredCliAuthFile;
89
+ exports.shouldBrokerBeforeStoredCredential = shouldBrokerBeforeStoredCredential;
89
90
  exports.attemptDaemonBrokerLoginAndPersist = attemptDaemonBrokerLoginAndPersist;
90
91
  exports.maybeAutoBroker = maybeAutoBroker;
91
92
  const fs = __importStar(require("node:fs"));
@@ -463,6 +464,7 @@ async function brokerThrough(bootstrap, opts, sourcePath) {
463
464
  ...((response.serverUrl ?? bootstrap.serverUrl)
464
465
  ? { serverUrl: response.serverUrl ?? bootstrap.serverUrl }
465
466
  : {}),
467
+ ...(sourcePath ? { descriptorPath: sourcePath } : {}),
466
468
  };
467
469
  }
468
470
  // The descriptor ALSO advertises the durable mint (a laptop island during
@@ -836,32 +838,69 @@ function findBrokeredHandoffDescriptor(profile = 'default') {
836
838
  async function maybeRetireStoredCliAuthFile(opts) {
837
839
  if (opts.descriptorPath !== exports.DEDICATED_RUNTIME_CLI_HANDOFF_DESCRIPTOR)
838
840
  return;
839
- let stored;
840
- try {
841
- stored = (0, auth_storage_1.readFromFile)();
842
- }
843
- catch {
844
- return;
845
- }
846
- if (!stored?.refreshToken)
847
- return;
848
- const storedOrigin = stored.serverOrigin ? (0, auth_storage_1.normalizeServerOrigin)(stored.serverOrigin) : null;
849
- const descriptorOrigin = opts.descriptorServerUrl
850
- ? (0, auth_storage_1.normalizeServerOrigin)(opts.descriptorServerUrl)
851
- : null;
852
- if (!storedOrigin || !descriptorOrigin || storedOrigin !== descriptorOrigin)
841
+ const stale = retirableCliAuthFamily(opts.descriptorServerUrl);
842
+ if (!stale)
853
843
  return;
854
844
  // The revoke is authenticated by the refresh token itself — presenting it
855
845
  // is the proof of ownership. `revokeDaemonRefreshSession` swallows its own
856
846
  // network/HTTP failures (best-effort by contract); the delete still runs.
857
847
  try {
858
- await (0, auth_core_1.revokeDaemonRefreshSession)(descriptorOrigin, stored.refreshToken);
848
+ await (0, auth_core_1.revokeDaemonRefreshSession)(stale.origin, stale.refreshToken);
859
849
  }
860
850
  catch {
861
851
  /* best-effort — delete proceeds regardless */
862
852
  }
863
853
  (0, auth_storage_1.deleteFileBackend)();
864
854
  }
855
+ /**
856
+ * Guards 2 and 3 of the retirement above: the stored file's refresh family,
857
+ * when its origin matches the descriptor's. Read through
858
+ * `readRefreshFamilyFromFile`, which accepts a file whose access token is
859
+ * empty — the realistic pre-broker shape once that token lapsed, and the one
860
+ * `readFromFile` reports as no credential at all (OSK-12194).
861
+ */
862
+ function retirableCliAuthFamily(descriptorServerUrl) {
863
+ let stored;
864
+ try {
865
+ stored = (0, auth_storage_1.readRefreshFamilyFromFile)();
866
+ }
867
+ catch {
868
+ return null;
869
+ }
870
+ if (!stored?.refreshToken)
871
+ return null;
872
+ const storedOrigin = stored.serverOrigin ? (0, auth_storage_1.normalizeServerOrigin)(stored.serverOrigin) : null;
873
+ const descriptorOrigin = descriptorServerUrl ? (0, auth_storage_1.normalizeServerOrigin)(descriptorServerUrl) : null;
874
+ if (!storedOrigin || !descriptorOrigin || storedOrigin !== descriptorOrigin)
875
+ return null;
876
+ return { origin: descriptorOrigin, refreshToken: stored.refreshToken };
877
+ }
878
+ /**
879
+ * Should this process broker BEFORE it reads the stored credential?
880
+ * (OSK-12194)
881
+ *
882
+ * True only on a brokered Dedicated guest: the guest hand-off descriptor is
883
+ * live and offers the access-token mode and NOT the durable mint (its secret
884
+ * can only redeem, so any `cli-auth.json` here predates broker mode), and that
885
+ * file carries a refresh family for the descriptor's own server — the same
886
+ * provenance guard the retirement applies.
887
+ *
888
+ * Without this, the retirement could never run: it runs after a successful
889
+ * redeem, the auto-broker runs only when the resolver found no token, and the
890
+ * resolver adopts a file that carries one — so a LIVE pre-broker family was
891
+ * used and kept forever, which is exactly the case the cleanup exists for.
892
+ * A laptop has no guest descriptor and is untouched: there `cli-auth.json` is
893
+ * a legitimate login.
894
+ */
895
+ function shouldBrokerBeforeStoredCredential(descriptorPath = exports.DEDICATED_RUNTIME_CLI_HANDOFF_DESCRIPTOR) {
896
+ const bootstrap = readBootstrap(descriptorPath);
897
+ if (!bootstrap)
898
+ return false;
899
+ const modes = (0, cli_handoff_wire_1.advertisedHandoffModes)(bootstrap);
900
+ if (!modes.includes('access_token') || modes.includes('refresh_family'))
901
+ return false;
902
+ return retirableCliAuthFamily(bootstrap.serverUrl) !== null;
903
+ }
865
904
  /** Bound the confirm so a slow server cannot stall a login that already worked. */
866
905
  const CONFIRM_TIMEOUT_MS = 10_000;
867
906
  /**
@@ -40,7 +40,7 @@ export interface DaemonHandoffOutcome {
40
40
  * The two absent cases are separated because they need different work — one
41
41
  * needs a binary fetched, the other only needs the service registered.
42
42
  */
43
- reason?: 'opted-out' | 'brokered' | 'no-binary' | 'legacy-binary' | 'unsupported-daemon' | 'no-service';
43
+ reason?: 'opted-out' | 'brokered' | 'no-binary' | 'legacy-binary' | 'unsupported-daemon' | 'no-service' | 'foreign-root' | 'mint-refused';
44
44
  }
45
45
  export declare function handOffToLocalDaemon(env?: NodeJS.ProcessEnv, opts?: {
46
46
  loginFlow?: string;
@@ -196,6 +196,24 @@ async function handOffToLocalDaemon(env = process.env, opts = {}) {
196
196
  detail: 'the local daemon brokered this login, so it already has a working credential',
197
197
  };
198
198
  }
199
+ // A relocated config root (`SKRR_CONFIG_DIR`) is a second sign-in on this
200
+ // machine, not the machine's sign-in. The installed daemon service carries no
201
+ // config-root override, so it reads the DEFAULT root — its credential, its
202
+ // deployment, its account belong there. Offering this root's login to it would
203
+ // re-point the machine at whatever this root signed in as, and when the server
204
+ // refused the offer the CLI used to report a healthy, signed-in daemon as
205
+ // "not signed in" and send the user to re-login it (OSK-12189).
206
+ //
207
+ // Decided on the root alone, before anything is spawned or minted: nothing
208
+ // about this machine's daemon should change because a second root signed in.
209
+ if (!(0, config_1.isDefaultConfigRoot)(env)) {
210
+ return {
211
+ status: 'skipped',
212
+ reason: 'foreign-root',
213
+ detail: `this login is in the config root ${(0, config_1.configRoot)(env)}; the local daemon ` +
214
+ `belongs to ${(0, config_1.defaultConfigRoot)()} and keeps its own sign-in`,
215
+ };
216
+ }
199
217
  const binary = (0, exec_runtime_binary_1.findRuntimeBinary)();
200
218
  if (!binary) {
201
219
  return { status: 'skipped', reason: 'no-binary', detail: 'no local daemon installed' };
@@ -264,7 +282,9 @@ async function handOffToLocalDaemon(env = process.env, opts = {}) {
264
282
  }));
265
283
  }
266
284
  catch (err) {
267
- return { status: 'failed', detail: err.message };
285
+ // Refused BEFORE the daemon was touched: it still holds exactly the
286
+ // credential it had, so this is "not handed this login", never "signed out".
287
+ return { status: 'failed', reason: 'mint-refused', detail: err.message };
268
288
  }
269
289
  const payload = JSON.stringify({
270
290
  refreshToken: bundle.refreshToken,
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The internal Mongo `_id` handed back where a command wants the `id`.
3
+ *
4
+ * `--json` prints every row's `_id` FIRST, so it is the value a person or a
5
+ * script copies. Spaces and Tasks are addressed by `id` (a UUID) everywhere in
6
+ * the API, and no resolver accepts both forms, so `spaces show <_id>`,
7
+ * `tasks show <_id>` and `tasks create --space <_id>` answered with not-found
8
+ * or a raw HTTP 404 — true, and no help: nothing said which field to use
9
+ * (OSK-12205).
10
+ *
11
+ * Only the refusal lives here. Finding the `id` that goes with an `_id` needs
12
+ * a listing, so the caller does that (it holds the API) and passes the answer
13
+ * in when it has one.
14
+ */
15
+ /** A 24-hex Mongo ObjectId, the shape of `_id`. */
16
+ export declare function looksLikeInternalObjectId(value: string): boolean;
17
+ /**
18
+ * The sentence for an `_id` passed where the `id` belongs.
19
+ *
20
+ * `canonicalId` is the row's `id` when the caller could find it — then the
21
+ * remedy is the exact value to use. Without it the remedy names the field.
22
+ */
23
+ export declare function describeInternalIdMisuse(opts: {
24
+ value: string;
25
+ noun: 'space' | 'task';
26
+ canonicalId?: string | null;
27
+ bin: string;
28
+ }): string;
@@ -0,0 +1,41 @@
1
+ "use strict";
2
+ /**
3
+ * The internal Mongo `_id` handed back where a command wants the `id`.
4
+ *
5
+ * `--json` prints every row's `_id` FIRST, so it is the value a person or a
6
+ * script copies. Spaces and Tasks are addressed by `id` (a UUID) everywhere in
7
+ * the API, and no resolver accepts both forms, so `spaces show <_id>`,
8
+ * `tasks show <_id>` and `tasks create --space <_id>` answered with not-found
9
+ * or a raw HTTP 404 — true, and no help: nothing said which field to use
10
+ * (OSK-12205).
11
+ *
12
+ * Only the refusal lives here. Finding the `id` that goes with an `_id` needs
13
+ * a listing, so the caller does that (it holds the API) and passes the answer
14
+ * in when it has one.
15
+ */
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.looksLikeInternalObjectId = looksLikeInternalObjectId;
18
+ exports.describeInternalIdMisuse = describeInternalIdMisuse;
19
+ const OBJECT_ID_RE = /^[0-9a-fA-F]{24}$/;
20
+ /** A 24-hex Mongo ObjectId, the shape of `_id`. */
21
+ function looksLikeInternalObjectId(value) {
22
+ return OBJECT_ID_RE.test(value.trim());
23
+ }
24
+ /**
25
+ * The sentence for an `_id` passed where the `id` belongs.
26
+ *
27
+ * `canonicalId` is the row's `id` when the caller could find it — then the
28
+ * remedy is the exact value to use. Without it the remedy names the field.
29
+ */
30
+ function describeInternalIdMisuse(opts) {
31
+ const { value, noun, canonicalId, bin } = opts;
32
+ const head = `"${value.trim()}" is the internal _id of a ${noun}; commands take its \`id\``;
33
+ if (canonicalId) {
34
+ return `${head}. Use \`id\` ${canonicalId}.`;
35
+ }
36
+ const alternatives = noun === 'task'
37
+ ? 'the `id` field (a UUID) or the `identifier` (OSK-123) from the same --json output'
38
+ : 'the `id` field (a UUID) or the `identifier` (OSK-S-12) from the same --json output';
39
+ return (`${head}.\n` +
40
+ ` → use ${alternatives}; \`${bin} ${noun === 'task' ? 'tasks' : 'spaces'} list --json\` prints both.`);
41
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The remedy for `tasks move --status <unknown>` (OSK-12205).
3
+ *
4
+ * Workflow states are Space-local, so the CLI cannot declare them as flag
5
+ * options and cannot list them ahead of the refusal. The server now names the
6
+ * accepted states in its message (and in `details.validStates`); this adds the
7
+ * command that lists them with their types, and quotes the states itself when
8
+ * a server sent them only in `details`.
9
+ */
10
+ export declare function describeUnknownWorkflowState(body: Record<string, unknown> | undefined, bin: string): string | null;
@@ -0,0 +1,27 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.describeUnknownWorkflowState = describeUnknownWorkflowState;
4
+ /**
5
+ * The remedy for `tasks move --status <unknown>` (OSK-12205).
6
+ *
7
+ * Workflow states are Space-local, so the CLI cannot declare them as flag
8
+ * options and cannot list them ahead of the refusal. The server now names the
9
+ * accepted states in its message (and in `details.validStates`); this adds the
10
+ * command that lists them with their types, and quotes the states itself when
11
+ * a server sent them only in `details`.
12
+ */
13
+ function describeUnknownWorkflowState(body, bin) {
14
+ if (!body || body.code !== 'WORKFLOW_STATE_UNKNOWN')
15
+ return null;
16
+ const details = (body.details ?? {});
17
+ const valid = Array.isArray(details.validStates)
18
+ ? details.validStates.filter((v) => typeof v === 'string')
19
+ : [];
20
+ let message = typeof body.error === 'string' && body.error ? body.error : 'Unknown workflow state';
21
+ if (valid.length > 0 && !message.includes(valid[0])) {
22
+ message += `. This Space accepts: ${valid.join(', ')}.`;
23
+ }
24
+ return (`${message}\n` +
25
+ ` → \`${bin} spaces states list <space-id>\` lists this Space's states with their types; ` +
26
+ '`--status` takes a state id or its display name.');
27
+ }