{"record":{"id":"a62da9c55aa460fd","repo":"paperclipai/paperclip","slug":"device-login-credential-promotion-rejected-resu","errorCode":null,"errorMessage":"device-login credential promotion rejected: ${result.outcome}","messagePattern":"device-login credential promotion rejected: (.+?)","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"server/src/routes/agents.ts","lineNumber":823,"sourceCode":"                    // active owner of the company credential slot. The read runs\n                    // inside the lock, so it observes a reaper reclaim that committed\n                    // before this section acquired the lock.\n                    const row = await adapterLoginStore.get(context.sessionId);\n                    return row?.status === \"promoting\" && row.companyId === context.companyId;\n                  },\n                  log: (line) => {\n                    // The promotion lines carry no token bytes and no raw account id,\n                    // so it is safe to log them with the session identifier.\n                    logger.info({ sessionId: context.sessionId }, line);\n                  },\n                }),\n            );\n            // A resolved promotion is not necessarily an accepted promotion. In\n            // particular, a reaper/expiry race can revoke this session's sole\n            // ownership between the service transition and Decision H. Fail closed:\n            // only a credential write or a deliberate safe keep can authenticate.\n            if (result.outcome !== \"promoted\" && result.outcome !== \"kept\") {\n              throw new Error(`device-login credential promotion rejected: ${result.outcome}`);\n            }\n            // The account's own home is durable at this point (the promotion above\n            // wrote it fail-loud). Name it with a company secret, so any agent can\n            // bind to it. Reading the secret by name first keeps a repeat login for\n            // the same account idempotent: `create` throws a conflict when the name\n            // already exists.\n            const handle = result.accountId ? toAccountHandle(result.accountId) : null;\n            if (!handle || !result.accountHomeDir) {\n              throw new Error(\n                \"device-login credential promotion rejected: the promotion carried no account home\",\n              );\n            }\n            const secretName = `CODEX_HOME_${handle}`;\n            const accountHomeDir = result.accountHomeDir;\n            const existingSecret = await secretsSvc.getByName(context.companyId, secretName);\n            if (existingSecret) {\n              // A same-name secret already exists. Confirm it still names this\n              // account's own home before treating a repeat login as a success:","sourceCodeStart":805,"sourceCodeEnd":841,"githubUrl":"https://github.com/paperclipai/paperclip/blob/01ad8584922b5d85292b1723cae71fa0d9b07a19/server/src/routes/agents.ts#L805-L841","documentation":"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.","triggerScenarios":"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'.","commonSituations":"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.","solutions":["Restart the device-login flow from the beginning so a fresh `promoting` session row is created, then complete it promptly.","Check adapter login session state (adapterLoginStore.get(sessionId).status) — if it is not 'promoting', the session was reclaimed and cannot be promoted.","Avoid running two concurrent device logins for the same company+adapter; serialize them.","If reaper expiry is too aggressive for slow login flows, tune the reaper's staleness threshold."],"exampleFix":null,"handlingStrategy":"retry","validationCode":"// Before completing device login, confirm the session still owns the slot\nconst row = await adapterLoginStore.get(sessionId);\nif (!row || row.status !== 'promoting' || row.companyId !== companyId) {\n  throw new Error('Login session expired or reclaimed; restart the device-login flow');\n}","typeGuard":null,"tryCatchPattern":"try {\n  await promoteDeviceLogin(...);\n} catch (e) {\n  if (e instanceof Error && e.message.startsWith('device-login credential promotion rejected:')\n      && !e.message.includes('no account home')) {\n    // outcome-level rejection (reaper/expiry race): restart the login flow\n    await startNewDeviceLoginFlow();\n  } else throw e;\n}","preventionTips":["Complete the device-login flow promptly; do not leave it idle until the session reaper fires","Never run two device logins for the same company+adapter concurrently","Monitor adapter login session ages and alert when sessions approach the reaper threshold","After any rejection, always restart the flow — do not reuse a stale session id"],"tags":["device-login","auth","race-condition","session-expired"],"backgroundTag":"invalid-state-transition","analyzedSha":"01ad8584922b5d85292b1723cae71fa0d9b07a19","analyzedAt":"2026-09-10T03:14:50.855Z","contentChangedAt":"2026-09-10T03:14:50.855Z","schemaVersion":2},"datasetVersion":"2026-09-14T05:17:10.506Z"}