JuliusBrussee/caveman · error
private device login requires a keyless project grant with…
Error message
private device login requires a keyless project grant with a refresh token and delivery acknowledgement
What it means
For private instances the CLI enforces that the token response is a 'keyless project grant': no gateway API key material, a non-empty refresh_token, project_id, delivery_ack_token, and a scope without write privileges (proxy:write / sdk:write). If the status is not 2xx or any invariant fails, this error is thrown. It guards private instances from receiving over-privileged or undeliverable credentials.
Solutions
- Configure the private instance's authorization server to issue keyless project grants (credential_kind "none") with refresh tokens
- Narrow the issued scopes so they exclude proxy:write and sdk:write
- Ensure the token response includes non-empty refresh_token, project_id, delivery_ack_token, and scope
- Verify the --instance points at the correct, properly configured authorization server
Example fix
// before
{ "credential_kind": "gateway_api_key", "gateway_api_key": "...", "scope": "proxy:write" }
// after
{ "credential_kind": "none", "refresh_token": "...", "project_id": "...", "delivery_ack_token": "...", "scope": "read" } Defensive patterns
Strategy: validation
Validate before calling
function isValidKeylessGrant(tok) {
return tok.credential_kind === "none" && typeof tok.refresh_token === "string" && tok.refresh_token &&
typeof tok.project_id === "string" && tok.project_id && typeof tok.delivery_ack_token === "string" && tok.delivery_ack_token &&
typeof tok.scope === "string" && tok.scope && !tok.scope.split(/\s+/).some(s => s === "proxy:write" || s === "sdk:write");
} Type guard
function isKeylessGrant(tok) { return !!tok && tok.credential_kind === "none" && !tok.gateway_api_key && !tok.gateway_key_id && !tok.gateway_url; } Try / catch
try { await login({ instance }) } catch (e) { if (e.message.includes("keyless project grant")) console.error("Instance issued an over-privileged or incomplete token; fix the authorization server config"); } Prevention
- Configure the private IdP to issue credential_kind "none" with refresh tokens
- Keep issued scopes free of proxy:write and sdk:write for this client
- Validate a token response against the grant shape after any IdP upgrade
When it happens
Trigger: A private-instance device token poll returns a payload where credential_kind is not "none", any gateway_api_key/gateway_key_id/gateway_url is present, refresh_token/project_id/delivery_ack_token/scope is missing or empty, or the scope contains proxy:write or sdk:write; or the HTTP status is outside 200–299.
Common situations: Authorization server misconfigured to issue gateway API keys for private instances; missing refresh-token grant on the client; overly broad scope configuration on the server; wrong audience/authorization server returning a cloud-style token bundle.
Understand the failure class
Background: "invalid response format", "malformed payload", "missing data field": when an API returns 200 but the response shape is wrong — this error's family across 23 libraries.
Related errors
- device authorization failed: missing device code
- device login failed: server did not provide a delivery…
- private device authorization returned an unsafe browser URL
- caveman build: config must use strict lock and required…
- device authorization failed: HTTP
AI-assisted analysis of JuliusBrussee/caveman@3ee70a1026 (2026-09-20).
Data as JSON: /api/errors/c0b1a795d9277fea.
Report an issue: GitHub.
Appendix: source
Thrown at packages/cli/src/index.ts:9737
}
await sleep(Math.max(intervalMs, retryAfterMs, 200));
continue;
}
if (tokenStatus >= 300 && tokenStatus < 400) throw new Error("device login refused a redirected token endpoint");
if (tokenStatus === 429) {
// rateLimitAuth returns a nested cave error envelope rather than the RFC
// `error` string. Status is the authoritative retry signal here.
await sleep(Math.max(intervalMs, retryAfterMs, 200));
continue;
}
const accessToken = typeof tok.access_token === "string" ? tok.access_token : "";
if (accessToken) {
if (instance && (tokenStatus < 200 || tokenStatus >= 300 || tok.credential_kind !== "none" ||
["gateway_api_key", "gateway_key_id", "gateway_url"].some((key) => tok[key] != null) ||
typeof tok.refresh_token !== "string" || !tok.refresh_token || typeof tok.project_id !== "string" || !tok.project_id ||
typeof tok.delivery_ack_token !== "string" || !tok.delivery_ack_token || typeof tok.scope !== "string" || !tok.scope ||
tok.scope.split(/\s+/).some((scope) => scope === "proxy:write" || scope === "sdk:write"))) {
throw new Error("private device login requires a keyless project grant with a refresh token and delivery acknowledgement");
}
const credentials: StoredCredentials = {
access_token: accessToken,
...(typeof tok.refresh_token === "string" && tok.refresh_token ? { refresh_token: tok.refresh_token } : {}),
...(typeof tok.gateway_api_key === "string" && tok.gateway_api_key ? { gateway_api_key: tok.gateway_api_key } : {}),
...(typeof tok.gateway_key_id === "string" && tok.gateway_key_id ? { gateway_key_id: tok.gateway_key_id } : {}),
...(typeof tok.project_id === "string" && tok.project_id ? { project_id: tok.project_id } : {}),
};
const tokenStore = storeCredentials(credentials);
const organizationId = orgFromToken(accessToken);
const gateway = instance ? "" : resolveLoginGatewayUrl(baseURL, tok, code, argv);
const saved: Config = { baseURL, token: "", tokenStore };
if (organizationId) saved.organizationId = organizationId;
if (credentials.project_id) saved.projectId = credentials.project_id;
if (gateway) saved.gatewayUrl = gateway;
// Persist the complete local login state before the server-side receipt fence:
// an ACK may permanently purge the replay bundle, so a config write that fails
// must leave the grant retryable rather than acknowledging an undiscoverableView on GitHub (pinned to 3ee70a1026)