paperclipai/paperclip · error · Error

device-login credential promotion rejected: ${result.outcome

Error message

device-login credential promotion rejected: ${result.outcome}

What it means

After promoteDeviceLoginCredential resolves, the promote() route accepts only the outcomes 'promoted' (credential written) or 'kept' (deliberate safe keep). Any other outcome — e.g. a reaper/expiry race revoked the session's sole active ownership of the company credential slot between the service transition and Decision H — fails closed and throws with the raw outcome embedded in the message. A resolved promise is explicitly not treated as an accepted promotion.

Source

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

                    // active owner of the company credential slot. The read runs
                    // inside the lock, so it observes a reaper reclaim that committed
                    // before this section acquired the lock.
                    const row = await adapterLoginStore.get(context.sessionId);
                    return row?.status === "promoting" && row.companyId === context.companyId;
                  },
                  log: (line) => {
                    // The promotion lines carry no token bytes and no raw account id,
                    // so it is safe to log them with the session identifier.
                    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:

View on GitHub (pinned to 01ad858492)

Solutions

  1. Restart the device-login flow from the beginning so a fresh `promoting` session row is created, then complete it promptly.
  2. Check adapter login session state (adapterLoginStore.get(sessionId).status) — if it is not 'promoting', the session was reclaimed and cannot be promoted.
  3. Avoid running two concurrent device logins for the same company+adapter; serialize them.
  4. If reaper expiry is too aggressive for slow login flows, tune the reaper's staleness threshold.
Defensive patterns

Strategy: retry

Validate before calling

// Before completing device login, confirm the session still owns the slot
const row = await adapterLoginStore.get(sessionId);
if (!row || row.status !== 'promoting' || row.companyId !== companyId) {
  throw new Error('Login session expired or reclaimed; restart the device-login flow');
}

Try / catch

try {
  await promoteDeviceLogin(...);
} catch (e) {
  if (e instanceof Error && e.message.startsWith('device-login credential promotion rejected:')
      && !e.message.includes('no account home')) {
    // outcome-level rejection (reaper/expiry race): restart the login flow
    await startNewDeviceLoginFlow();
  } else throw e;
}

Prevention

When it happens

Trigger: Calling the device-login promote flow when the adapter-login session row is no longer status 'promoting' for this company (isSoleActiveOwner returns false): session expired and the reaper reclaimed the `promoting` row, a concurrent login for the same company/adapter took the slot, or the service returned an unusual outcome value like 'skipped'/'reclaimed'.

Common situations: User left the device-login flow idle until the session expired, then completed the flow; two parallel device logins for the same company+adapter racing for the single active credential slot; slow network causing the reaper window to open mid-promotion.

Understand the failure class

Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.

Related errors


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