paperclipai/paperclip · error · Error

device-login credential promotion rejected: the promotion…

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.

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@3f1d897a7c (2026-09-10). Data as JSON: /api/errors/9a1519ff7659a7c3. Report an issue: GitHub.

Appendix: source

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

                    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;
            // Whether the company default home ended on a DIFFERENT account
            // than this login. The promotion's own company-home write already
            // ran (a seed or same-identity refresh landed this login there; a
            // different account's claim was kept), so this read observes the
            // post-promotion state. The flag rides to the owner status read,
            // where the client offers binding the agent to this account — the
            // only way the login can take effect while another account holds
            // the company slot. Any read failure degrades to `false`: the
            // client then simply offers nothing, never a wrong bind.
            const companyIdentityDiffers = await (async () => {
              try {
                const companyAuthBytes = await readFile(
                  path.join(resolveManagedCodexHomeDir(process.env, context.companyId), "auth.json"),

View on GitHub (pinned to 3f1d897a7c)