paperclipai/paperclip · error · Error

device-login credential promotion rejected: the promotion ca

Error message

device-login credential promotion rejected: the promotion carried no account home

What it means

After an accepted promotion ('promoted'/'kept'), promote() derives the account handle from result.accountId and requires result.accountHomeDir to build the CODEX_HOME_<handle> company secret. If the promotion result carries no usable accountId or no accountHomeDir, the login cannot bind agents to a durable home, so it fails closed with this error. This guards an adapter-internal invariant: a successful promotion must always report where the account home was written.

Source

Thrown at server/src/routes/agents.ts:832

                    logger.info({ sessionId: context.sessionId }, line);
                  },
                }),
            );
            // A resolved promotion is not necessarily an accepted promotion. In
            // particular, a reaper/expiry race can revoke this session's sole
            // ownership between the service transition and Decision H. Fail closed:
            // only a credential write or a deliberate safe keep can authenticate.
            if (result.outcome !== "promoted" && result.outcome !== "kept") {
              throw new Error(`device-login credential promotion rejected: ${result.outcome}`);
            }
            // The account's own home is durable at this point (the promotion above
            // wrote it fail-loud). Name it with a company secret, so any agent can
            // bind to it. Reading the secret by name first keeps a repeat login for
            // the same account idempotent: `create` throws a conflict when the name
            // already exists.
            const handle = result.accountId ? toAccountHandle(result.accountId) : null;
            if (!handle || !result.accountHomeDir) {
              throw new Error(
                "device-login credential promotion rejected: the promotion carried no account home",
              );
            }
            const secretName = `CODEX_HOME_${handle}`;
            const accountHomeDir = result.accountHomeDir;
            const existingSecret = await secretsSvc.getByName(context.companyId, secretName);
            if (existingSecret) {
              // A same-name secret already exists. Confirm it still names this
              // account's own home before treating a repeat login as a success:
              // the name alone is not proof of a match.
              //
              // Run the check inside the same lock a `local_encrypted` secret
              // rotate holds for its whole write, the same lock a rotate
              // takes. This is an early fail-fast only: the lock is fully
              // released once this call returns, well before the login
              // service commits its terminal state, so queue the same check
              // for `runTerminalCommit` to run again right before that
              // commit, under a fresh lock acquisition it holds across the

View on GitHub (pinned to 01ad858492)

Solutions

  1. Inspect the promotion result from promoteDeviceLoginCredential for this adapter and fix the adapter/service to always populate accountId and accountHomeDir on promoted/kept outcomes.
  2. Update or pin the adapter package version if a recent adapter upgrade changed the result shape.
  3. Check server logs for the promotion lines (logger.info with sessionId) to see which field was missing.
  4. If the account exists on disk but the dir wasn't reported, locate the Codex home and verify the adapter's home-derivation logic.

Example fix

// before (adapter returns partial result)
return { outcome: 'promoted', accountId: null, accountHomeDir: null };
// after
return { outcome: 'promoted', accountId: accountId, accountHomeDir: homeDir }; // always set on promoted/kept
Defensive patterns

Strategy: try-catch

Type guard

function promotionCarriedAccountHome(
  r: { outcome: string; accountId?: string | null; accountHomeDir?: string | null },
): r is { outcome: 'promoted' | 'kept'; accountId: string; accountHomeDir: string } {
  return (r.outcome === 'promoted' || r.outcome === 'kept')
    && typeof r.accountId === 'string' && r.accountId.length > 0
    && typeof r.accountHomeDir === 'string' && r.accountHomeDir.length > 0;
}

Try / catch

try {
  await promoteDeviceLogin(...);
} catch (e) {
  if (e instanceof Error && e.message.includes('the promotion carried no account home')) {
    console.error('Adapter promotion result missing accountId/accountHomeDir — check adapter version/bug');
  } else throw e;
}

Prevention

When it happens

Trigger: promoteDeviceLoginCredential returns outcome 'promoted' or 'kept' but result.accountId is null/undefined (handle can't be derived) or result.accountHomeDir is empty/null — i.e. the adapter's promotion service resolved without propagating the account-home path it was supposed to write.

Common situations: Adapter (Codex) version change altering the promotion result shape; an adapter bug where the home write is skipped for a 'kept' outcome; custom/mock adapter in tests returning a partial result object.

Understand the failure class

Background: "This is a bug, please report it": internal invariant violations, unreachable panics, and SNH errors explained — this error's family across 47 libraries.

Related errors


AI-assisted analysis of paperclipai/paperclip@01ad858492 (2026-09-10). Data as JSON: /api/errors/9a1519ff7659a7c3. Report an issue: GitHub.