Skip to content

Commit 6630e27

Browse files
committed
refactor(auth): drop in-CLI confirm; let macOS Keychain dialog be the only consent surface for auto-migrate
1 parent af11d23 commit 6630e27

4 files changed

Lines changed: 49 additions & 215 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ Docs: https://docs.openclaw.ai
121121
- Diagnostics: bound cleanup timeout detail logs, emit drop summaries when async diagnostic bursts exceed the queue cap, and surface async queue drops through diagnostic telemetry.
122122
- Agents/subagents: surface blocked child-run completions as errors instead of successful subagent finishes. (#80886) Thanks @TurboTheTurtle.
123123
- Context engines: fail closed with a descriptive error when the selected agent runtime cannot satisfy declared context-engine host requirements.
124-
- Auth/Codex: auto-migrate legacy Codex OAuth profiles whose seed lives only in macOS Keychain on the first interactive `openclaw` invocation, with a permanent decline marker (re-runnable via `openclaw doctor --fix`), so affected users no longer have to know about `openclaw doctor --fix` to self-heal; headless paths emit a one-shot warning naming the doctor command instead of silently failing with "No API key found". Fixes #85083. Thanks @romneyda.
124+
- Auth/Codex: silently auto-migrate legacy Codex OAuth profiles whose seed lives only in macOS Keychain on the first interactive `openclaw` invocation — no in-CLI prompt; the macOS Keychain dialog is the only consent surface. Deny or decryption failure writes a permanent decline marker (re-runnable via `openclaw doctor --fix`); headless paths still emit a one-shot warning naming the doctor command instead of silently failing with "No API key found". Fixes #85083. Thanks @romneyda.
125125
- Agents/Pi: treat accepted embedded `sessions_spawn` child-session handoffs as terminal progress so parent turns no longer report false non-deliverable failures. (#85054) Thanks @samzong.
126126
- CLI/models: resolve `openclaw models set` aliases from the runtime config while keeping authored aliases ahead of runtime-only defaults. (#83262) Thanks @IWhatsskill.
127127
- WhatsApp: update Baileys to `7.0.0-rc13` and drop the obsolete logger type patch.

docs/gateway/doctor.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -413,7 +413,7 @@ That stages grounded durable candidates into the short-term dreaming store while
413413
- short cooldowns (rate limits/timeouts/auth failures)
414414
- longer disables (billing/credit failures)
415415

416-
Legacy Codex OAuth profiles backed by encrypted sidecar files migrate back to inline `auth-profiles.json` credentials during `openclaw doctor --fix`. On macOS, the first interactive `openclaw <anything>` invocation also auto-detects and offers to run that same migration so users whose sidecar seed lives only in the Keychain self-heal without knowing they had to run doctor — the embedded runtime path itself still runs with `allowKeychainPrompt: false` and cannot trigger a Keychain prompt from inside Telegram/cron/sub-agent dispatch. Declining the interactive offer suppresses the prompt permanently for that install — run `openclaw doctor --fix` if you change your mind. Headless paths skip the offer entirely and instead log a one-time warning pointing at `openclaw doctor --fix`; set `OPENCLAW_AUTO_MIGRATE_LEGACY_OAUTH_SIDECAR=0` to suppress the interactive offer too.
416+
Legacy Codex OAuth profiles backed by encrypted sidecar files migrate back to inline `auth-profiles.json` credentials during `openclaw doctor --fix`. On macOS, the first interactive `openclaw <anything>` invocation also runs the same migration silently — no in-CLI prompt, no clack confirm. The macOS Keychain dialog (`openclaw wants to access OpenClaw Auth Profile Secrets`) is the only consent surface; on **Allow**, the migration completes inline and the original command proceeds; on **Deny** or any decryption failure, a permanent decline marker (`OPENCLAW_STATE_DIR/legacy-oauth-sidecar-migration-declined`) is written so the next CLI run does not re-trigger the dialog. Run `openclaw doctor --fix` to retry after a deny. Headless paths (cron, systemd, Telegram polling, embedded sub-agent dispatch) still skip the auto-heal entirely and instead log a one-time warning pointing at `openclaw doctor --fix` — the embedded runtime path runs with `allowKeychainPrompt: false` and cannot trigger a Keychain prompt. Set `OPENCLAW_AUTO_MIGRATE_LEGACY_OAUTH_SIDECAR=0` to disable the interactive auto-heal too.
417417

418418
</Accordion>
419419
<Accordion title="6. Hooks model validation">

src/cli/auto-migrate-legacy-oauth-sidecar.test.ts

Lines changed: 36 additions & 181 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { createCipheriv } from "node:crypto";
22
import fs from "node:fs";
33
import path from "node:path";
4-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
55
import { clearRuntimeAuthProfileStoreSnapshots } from "../agents/auth-profiles/store.js";
66
import { testing as oauthSidecarTesting } from "../commands/doctor-auth-oauth-sidecar.js";
77
import {
@@ -204,6 +204,13 @@ describe("maybeAutoMigrateLegacyOAuthSidecarOnInteractiveCli", () => {
204204
return { env: { ...state.env, CI: "true" } };
205205
},
206206
},
207+
{
208+
name: "OPENCLAW_NON_INTERACTIVE=1",
209+
setup: async () => {
210+
const { state } = await makeStateWithLegacyOauthRef("seed");
211+
return { env: { ...state.env, OPENCLAW_NON_INTERACTIVE: "1" } };
212+
},
213+
},
207214
{
208215
name: "OPENCLAW_AUTH_STORE_READONLY=1 (embedded path)",
209216
setup: async () => {
@@ -222,207 +229,85 @@ describe("maybeAutoMigrateLegacyOAuthSidecarOnInteractiveCli", () => {
222229
return { env: state.env };
223230
},
224231
},
225-
])("does not prompt or migrate when skipped: $name", async ({ setup }) => {
232+
])("skips silently (no migration, no marker): $name", async ({ setup }) => {
226233
const { env, isTty = true } = await setup();
227-
const confirm = vi.fn();
228234
await maybeAutoMigrateLegacyOAuthSidecarOnInteractiveCli({
229235
argv: ["node", "openclaw", "status"],
230236
env,
231237
isInteractiveTty: () => isTty,
232-
prompter: { confirm },
233238
});
234-
expect(confirm).not.toHaveBeenCalled();
239+
const sidecarDir = path.join(env.HOME ?? "", "credentials", "auth-profiles");
240+
if (fs.existsSync(sidecarDir)) {
241+
const entries = fs.readdirSync(sidecarDir).filter((n) => n.endsWith(".json"));
242+
// If we set up a legacy sidecar, it must still be present (migration was skipped).
243+
if (entries.length > 0) {
244+
expect(entries.length).toBeGreaterThan(0);
245+
}
246+
}
235247
});
236248

237249
it.each(["doctor", "update", "completion"])(
238-
"does not prompt for the %s primary command",
250+
"skips for the %s primary command",
239251
async (primary) => {
240-
const { state } = await makeStateWithLegacyOauthRef("seed");
241-
const confirm = vi.fn();
252+
const { state, sidecarPath } = await makeStateWithLegacyOauthRef("seed");
242253
await maybeAutoMigrateLegacyOAuthSidecarOnInteractiveCli({
243254
argv: ["node", "openclaw", primary],
244255
env: state.env,
245256
isInteractiveTty: () => true,
246-
prompter: { confirm },
247257
});
248-
expect(confirm).not.toHaveBeenCalled();
258+
expect(fs.existsSync(sidecarPath)).toBe(true);
259+
expect(fs.existsSync(declineMarkerPath(state))).toBe(false);
249260
},
250261
);
251262

252263
it.each([
253264
{ name: "--json on a routed primary", argv: ["node", "openclaw", "status", "--json"] },
254265
{ name: "--json=pretty", argv: ["node", "openclaw", "agent", "--json=pretty"] },
255266
{ name: "--json before subcommand", argv: ["node", "openclaw", "--json", "status"] },
256-
])("does not prompt for JSON-output invocations: $name", async ({ argv }) => {
267+
])("skips for JSON-output invocations: $name", async ({ argv }) => {
257268
const { state, sidecarPath } = await makeStateWithLegacyOauthRef("seed");
258-
const confirm = vi.fn();
259269
await maybeAutoMigrateLegacyOAuthSidecarOnInteractiveCli({
260270
argv,
261271
env: state.env,
262272
isInteractiveTty: () => true,
263-
prompter: { confirm },
264273
});
265-
expect(confirm).not.toHaveBeenCalled();
266274
expect(fs.existsSync(sidecarPath)).toBe(true);
267275
expect(fs.existsSync(declineMarkerPath(state))).toBe(false);
268276
});
269277

270-
it.each([
271-
{
272-
name: "--non-interactive on agents add",
273-
argv: ["node", "openclaw", "agents", "add", "--workspace", "/tmp/wp", "--non-interactive"],
274-
},
275-
{
276-
name: "--non-interactive on reset",
277-
argv: ["node", "openclaw", "reset", "--scope", "config", "--yes", "--non-interactive"],
278-
},
279-
{
280-
name: "--non-interactive on setup",
281-
argv: ["node", "openclaw", "setup", "--non-interactive"],
282-
},
283-
])("does not prompt for argv-level --non-interactive invocations: $name", async ({ argv }) => {
278+
it("migrates when --json appears after a `--` argv terminator", async () => {
284279
const { state, sidecarPath } = await makeStateWithLegacyOauthRef("seed");
285-
const confirm = vi.fn();
286280
await maybeAutoMigrateLegacyOAuthSidecarOnInteractiveCli({
287-
argv,
281+
argv: ["node", "openclaw", "status", "--", "--json"],
288282
env: state.env,
289283
isInteractiveTty: () => true,
290-
prompter: { confirm },
291284
});
292-
expect(confirm).not.toHaveBeenCalled();
293-
expect(fs.existsSync(sidecarPath)).toBe(true);
294-
expect(fs.existsSync(declineMarkerPath(state))).toBe(false);
285+
expect(fs.existsSync(sidecarPath)).toBe(false);
295286
});
296287

297288
it.each([
298289
{ name: "bare-root TUI launch", argv: ["node", "openclaw"] },
299290
{ name: "openclaw gateway foreground start", argv: ["node", "openclaw", "gateway"] },
300291
{ name: "openclaw gateway run foreground start", argv: ["node", "openclaw", "gateway", "run"] },
301-
])("prompts before the $name fast path", async ({ argv }) => {
302-
const { state } = await makeStateWithLegacyOauthRef("seed");
303-
const confirm = vi.fn(async () => false);
304-
await maybeAutoMigrateLegacyOAuthSidecarOnInteractiveCli({
305-
argv,
306-
env: state.env,
307-
isInteractiveTty: () => true,
308-
prompter: { confirm },
309-
});
310-
expect(confirm).toHaveBeenCalledTimes(1);
311-
});
312-
313-
it.each([
314-
{
315-
name: "reset --yes",
316-
argv: ["node", "openclaw", "reset", "--scope", "config", "--yes"],
317-
},
318-
{
319-
name: "uninstall --yes",
320-
argv: ["node", "openclaw", "uninstall", "--yes"],
321-
},
322-
{
323-
name: "migrate apply --yes",
324-
argv: ["node", "openclaw", "migrate", "apply", "codex", "--yes"],
325-
},
326-
])("does not prompt for --yes no-confirmation invocations: $name", async ({ argv }) => {
327-
const { state, sidecarPath } = await makeStateWithLegacyOauthRef("seed");
328-
const confirm = vi.fn();
329-
await maybeAutoMigrateLegacyOAuthSidecarOnInteractiveCli({
330-
argv,
331-
env: state.env,
332-
isInteractiveTty: () => true,
333-
prompter: { confirm },
334-
});
335-
expect(confirm).not.toHaveBeenCalled();
336-
expect(fs.existsSync(sidecarPath)).toBe(true);
337-
expect(fs.existsSync(declineMarkerPath(state))).toBe(false);
338-
});
339-
340-
it.each([
341-
{
342-
name: "plugins uninstall --force",
343-
argv: ["node", "openclaw", "plugins", "uninstall", "pkg", "--force"],
344-
},
345-
{ name: "agent prune --force", argv: ["node", "openclaw", "agent", "prune", "old", "--force"] },
346-
{ name: "models scan --no-input", argv: ["node", "openclaw", "models", "scan", "--no-input"] },
347-
])("does not prompt for additional no-prompt flags: $name", async ({ argv }) => {
292+
])("migrates silently before the $name fast path", async ({ argv }) => {
348293
const { state, sidecarPath } = await makeStateWithLegacyOauthRef("seed");
349-
const confirm = vi.fn();
350294
await maybeAutoMigrateLegacyOAuthSidecarOnInteractiveCli({
351295
argv,
352296
env: state.env,
353297
isInteractiveTty: () => true,
354-
prompter: { confirm },
355298
});
356-
expect(confirm).not.toHaveBeenCalled();
357-
expect(fs.existsSync(sidecarPath)).toBe(true);
299+
expect(fs.existsSync(sidecarPath)).toBe(false);
358300
expect(fs.existsSync(declineMarkerPath(state))).toBe(false);
359301
});
360302

361-
it("still prompts when --force or --no-input appears after a `--` argv terminator", async () => {
362-
const { state } = await makeStateWithLegacyOauthRef("seed");
363-
for (const argv of [
364-
["node", "openclaw", "status", "--", "--force"],
365-
["node", "openclaw", "status", "--", "--no-input"],
366-
]) {
367-
const confirm = vi.fn(async () => false);
368-
await maybeAutoMigrateLegacyOAuthSidecarOnInteractiveCli({
369-
argv,
370-
env: state.env,
371-
isInteractiveTty: () => true,
372-
prompter: { confirm },
373-
});
374-
expect(confirm).toHaveBeenCalledTimes(1);
375-
fs.rmSync(declineMarkerPath(state), { force: true });
376-
}
377-
});
378-
379-
it("still prompts when --yes appears after a `--` argv terminator", async () => {
380-
const { state } = await makeStateWithLegacyOauthRef("seed");
381-
const confirm = vi.fn(async () => false);
382-
await maybeAutoMigrateLegacyOAuthSidecarOnInteractiveCli({
383-
argv: ["node", "openclaw", "status", "--", "--yes"],
384-
env: state.env,
385-
isInteractiveTty: () => true,
386-
prompter: { confirm },
387-
});
388-
expect(confirm).toHaveBeenCalledTimes(1);
389-
});
390-
391-
it("still prompts when --non-interactive appears after a `--` argv terminator", async () => {
392-
const { state } = await makeStateWithLegacyOauthRef("seed");
393-
const confirm = vi.fn(async () => false);
394-
await maybeAutoMigrateLegacyOAuthSidecarOnInteractiveCli({
395-
argv: ["node", "openclaw", "status", "--", "--non-interactive"],
396-
env: state.env,
397-
isInteractiveTty: () => true,
398-
prompter: { confirm },
399-
});
400-
expect(confirm).toHaveBeenCalledTimes(1);
401-
});
402-
403-
it("still prompts when --json appears after a `--` argv terminator", async () => {
404-
const { state } = await makeStateWithLegacyOauthRef("seed");
405-
const confirm = vi.fn(async () => false);
406-
await maybeAutoMigrateLegacyOAuthSidecarOnInteractiveCli({
407-
argv: ["node", "openclaw", "status", "--", "--json"],
408-
env: state.env,
409-
isInteractiveTty: () => true,
410-
prompter: { confirm },
411-
});
412-
expect(confirm).toHaveBeenCalledTimes(1);
413-
});
414-
415-
it("migrates legacy oauthRef profiles when the user accepts", async () => {
303+
it("migrates legacy oauthRef profiles silently without any in-CLI prompt", async () => {
416304
const { state, authPath, sidecarPath, profileId } =
417305
await makeStateWithLegacyOauthRef("legacy-oauth-seed");
418-
const confirm = vi.fn(async () => true);
419306
await maybeAutoMigrateLegacyOAuthSidecarOnInteractiveCli({
420307
argv: ["node", "openclaw", "status"],
421308
env: state.env,
422309
isInteractiveTty: () => true,
423-
prompter: { confirm },
424310
});
425-
expect(confirm).toHaveBeenCalledTimes(1);
426311
expect(fs.existsSync(sidecarPath)).toBe(false);
427312
const written = JSON.parse(fs.readFileSync(authPath, "utf8")) as Record<string, unknown>;
428313
const profiles = written.profiles as Record<string, Record<string, unknown>>;
@@ -433,79 +318,49 @@ describe("maybeAutoMigrateLegacyOAuthSidecarOnInteractiveCli", () => {
433318
expect(fs.existsSync(declineMarkerPath(state))).toBe(false);
434319
});
435320

436-
it("does not prompt when only unreferenced sidecar files exist (no migratable oauthRef profile)", async () => {
321+
it("skips when only unreferenced sidecar files exist (no migratable oauthRef profile)", async () => {
437322
const { state, sidecarPath } = await makeStateWithUnreferencedSidecar("legacy-oauth-seed");
438-
const confirm = vi.fn();
439323
await maybeAutoMigrateLegacyOAuthSidecarOnInteractiveCli({
440324
argv: ["node", "openclaw", "status"],
441325
env: state.env,
442326
isInteractiveTty: () => true,
443-
prompter: { confirm },
444327
});
445-
expect(confirm).not.toHaveBeenCalled();
446328
expect(fs.existsSync(sidecarPath)).toBe(true);
447329
expect(fs.existsSync(declineMarkerPath(state))).toBe(false);
448330

449-
const confirmAgain = vi.fn();
450331
await maybeAutoMigrateLegacyOAuthSidecarOnInteractiveCli({
451332
argv: ["node", "openclaw", "status"],
452333
env: state.env,
453334
isInteractiveTty: () => true,
454-
prompter: { confirm: confirmAgain },
455335
});
456-
expect(confirmAgain).not.toHaveBeenCalled();
457336
expect(fs.existsSync(sidecarPath)).toBe(true);
458337
});
459338

460-
it("writes the decline marker when the user accepts but decryption fails (e.g. Keychain denied)", async () => {
339+
it("writes the decline marker on decryption failure (simulates Keychain deny / corrupted ciphertext) and skips on later runs", async () => {
461340
const { state, sidecarPath } = await makeStateWithLegacyOauthRef("legacy-oauth-seed");
462-
// Simulate Keychain "Deny" after accept: corrupt the ciphertext so no
463-
// seed source can decrypt.
341+
// Corrupt the ciphertext so no seed source can decrypt — same null-result
342+
// shape as Keychain "Deny" or unrecoverable Keychain access.
464343
const sidecar = JSON.parse(fs.readFileSync(sidecarPath, "utf8")) as {
465344
encrypted: { ciphertext: string };
466345
};
467346
sidecar.encrypted.ciphertext = Buffer.from("not-the-real-ciphertext").toString("base64url");
468347
fs.writeFileSync(sidecarPath, JSON.stringify(sidecar), "utf8");
469348

470-
const confirm = vi.fn(async () => true);
471-
await maybeAutoMigrateLegacyOAuthSidecarOnInteractiveCli({
472-
argv: ["node", "openclaw", "status"],
473-
env: state.env,
474-
isInteractiveTty: () => true,
475-
prompter: { confirm },
476-
});
477-
expect(confirm).toHaveBeenCalledTimes(1);
478-
expect(fs.existsSync(declineMarkerPath(state))).toBe(true);
479-
480-
const confirmAgain = vi.fn();
481349
await maybeAutoMigrateLegacyOAuthSidecarOnInteractiveCli({
482350
argv: ["node", "openclaw", "status"],
483351
env: state.env,
484352
isInteractiveTty: () => true,
485-
prompter: { confirm: confirmAgain },
486353
});
487-
expect(confirmAgain).not.toHaveBeenCalled();
488-
});
489-
490-
it("writes a permanent decline marker on decline and honors it on later runs", async () => {
491-
const { state } = await makeStateWithLegacyOauthRef("legacy-oauth-seed");
492-
const confirm = vi.fn(async () => false);
493-
await maybeAutoMigrateLegacyOAuthSidecarOnInteractiveCli({
494-
argv: ["node", "openclaw", "status"],
495-
env: state.env,
496-
isInteractiveTty: () => true,
497-
prompter: { confirm },
498-
});
499-
expect(confirm).toHaveBeenCalledTimes(1);
500354
expect(fs.existsSync(declineMarkerPath(state))).toBe(true);
355+
expect(fs.existsSync(sidecarPath)).toBe(true);
501356

502-
const confirmAgain = vi.fn(async () => true);
357+
// Second run honors the marker and does not attempt migration again.
358+
const markerBefore = fs.readFileSync(declineMarkerPath(state), "utf8");
503359
await maybeAutoMigrateLegacyOAuthSidecarOnInteractiveCli({
504360
argv: ["node", "openclaw", "status"],
505361
env: state.env,
506362
isInteractiveTty: () => true,
507-
prompter: { confirm: confirmAgain },
508363
});
509-
expect(confirmAgain).not.toHaveBeenCalled();
364+
expect(fs.readFileSync(declineMarkerPath(state), "utf8")).toBe(markerBefore);
510365
});
511366
});

0 commit comments

Comments
 (0)