src/devin-backend.js
Rama master · réplica de lectura del repositorio original.
/**
* Devin web backend (app.devin.ai) REST/SSE adapter — escape-hatch PATH B.
*
* Cascade's gRPC backend (server.self-serve.windsurf.com) is scheduled to retire
* 2026-07-01 (see memory: cascade-retirement-2026-07-01). Two forward paths exist:
* PATH A — Devin CLI local subprocess (src/special-agent.js + src/devin-acp.js)
* PATH B — direct app.devin.ai REST/SSE (THIS FILE)
*
* SCOPE OF THIS SCAFFOLD: only the VERIFIED protocol surface is implemented.
* Verified facts come from dao-devin-export v1.4.3 source + official docs and are
* recorded in docs-internal/workflow-results/REF-devin-backend-protocol.md. Everything verified
* here is READ-ONLY (auth probe + list/detail/event/org reads). The WRITE surface a
* real reverse-proxy needs (create-session + send-prompt) is NOT present in any
* verified source, so it is left as explicit TODO stubs that throw — never faked.
*
* Design notes:
* - Zero npm deps; uses global fetch (Node >=20). `fetchImpl` is injectable so unit
* tests can mock the network and CI never touches app.devin.ai.
* - Nothing here runs at import time and no function dials the network unless called.
* - All values treated as data; no shell, no eval.
*
* ⚠️ STATUS (audit S3, 2026-07-11): this module is an intentional ROADMAP STUB —
* it has ZERO production importers (grep `from './devin-backend.js'` in src/ →
* none) and backend-router.selectBackend() NEVER returns BACKEND.DEVIN_REST, so
* no request path reaches this code. The dedicated unit tests
* (test/devin-backend*.test.js) were REMOVED because they inflated coverage on
* unreachable code (all fake-green: they exercised these helpers against a mock
* fetch, but nothing live calls them). Kept as the DEVIN_REST placeholder per
* product decision. If/when this is wired into selectBackend, re-add tests THEN,
* against the live route — not before. Do not "restore" the deleted tests.
*/
import { VERSION } from './version.js';
const DEFAULT_BASE_URL = 'https://app.devin.ai/api';
// Verified live REST base (P0, 2026-06-29): api.devin.ai/v3/organizations/{org}/...
// is a standard FastAPI surface reachable with the Windsurf session token. This is
// the CONFIRMED write/read API, distinct from the dao-derived app.devin.ai/api base
// above. See memory: devin-write-endpoint-cracked-2026-06-29.
const DEFAULT_REST_BASE_URL = 'https://api.devin.ai';
/**
* Build the runtime config from env. Pure — no I/O, safe to call anytime.
* Token/org are referenced by key name only and never logged by this module.
*
* DEVIN_BACKEND_BASE_URL override API base (default https://app.devin.ai/api)
* DEVIN_BACKEND_TOKEN Bearer token (devin-session-token$… / JWT / auth1_…)
* falls back to WINDSURF_API_KEY (same key system the
* Devin CLI uses — see memory: devin-acp-live-verified)
* DEVIN_BACKEND_ORG_ID org id, form `org-XXXX` (x-cog-org-id header)
* DEVIN_BACKEND_ENABLED=1 feature flag; off by default
*/
export function getDevinBackendConfig(env = process.env) {
const baseUrl = String(env.DEVIN_BACKEND_BASE_URL || DEFAULT_BASE_URL)
.trim()
.replace(/\/+$/, '');
const restBaseUrl = String(env.DEVIN_BACKEND_REST_BASE_URL || DEFAULT_REST_BASE_URL)
.trim()
.replace(/\/+$/, '');
return {
baseUrl,
restBaseUrl,
token: String(env.DEVIN_BACKEND_TOKEN || env.WINDSURF_API_KEY || '').trim(),
orgId: String(env.DEVIN_BACKEND_ORG_ID || '').trim(),
enabled: env.DEVIN_BACKEND_ENABLED === '1',
};
}
export function isDevinBackendEnabled(env = process.env) {
return getDevinBackendConfig(env).enabled;
}
/**
* Assemble the standard auth headers.
* Authorization: Bearer <token> (verified — all calls)
* x-cog-org-id: <orgId> (verified — all calls; omitted if unset,
* e.g. the post-auth call that *derives* it)
* Accept / User-Agent / Content-Type (sane defaults)
*
* `extra` is merged last so callers can add e.g. Accept: text/event-stream for SSE.
*/
export function buildDevinHeaders(cfg, extra = {}) {
if (!cfg || !cfg.token) {
throw Object.assign(new Error('Devin backend token is not configured'), {
status: 401,
type: 'backend_misconfigured',
});
}
const headers = {
Authorization: `Bearer ${cfg.token}`,
Accept: 'application/json',
'Content-Type': 'application/json',
'User-Agent': `WindsurfAPI/${VERSION}`,
};
if (cfg.orgId) headers['x-cog-org-id'] = cfg.orgId;
return { ...headers, ...extra };
}
/**
* The org path segment. dao uses `org-{bare}` where {bare} is the org id with any
* leading `org-` stripped, so `org-abc` and `abc` both yield `org-abc`.
*/
export function orgPathSegment(orgId) {
const bare = String(orgId || '').replace(/^org-/, '');
if (!bare) {
throw Object.assign(new Error('Devin backend orgId is not configured'), {
status: 400,
type: 'backend_misconfigured',
});
}
return `org-${bare}`;
}
function joinUrl(baseUrl, path) {
return `${baseUrl}${path.startsWith('/') ? '' : '/'}${path}`;
}
/**
* Low-level JSON request helper. Injectable `fetchImpl` keeps tests offline.
* Returns parsed JSON on 2xx; throws a tagged Error otherwise.
*/
async function requestJson(cfg, method, path, { body, extraHeaders, fetchImpl, baseUrl } = {}) {
const fetchFn = fetchImpl || globalThis.fetch;
if (typeof fetchFn !== 'function') {
throw Object.assign(new Error('fetch is not available'), { status: 500, type: 'backend_misconfigured' });
}
const url = joinUrl(baseUrl || cfg.baseUrl, path);
const headers = buildDevinHeaders(cfg, extraHeaders);
const init = { method, headers };
if (body !== undefined) init.body = typeof body === 'string' ? body : JSON.stringify(body);
const res = await fetchFn(url, init);
if (!res.ok) {
// Capture FastAPI {"detail": ...} when present — it carries the validation/
// permission reason (e.g. 422 field errors, 403 token-scope message).
let detail;
try { detail = (await res.json())?.detail; } catch { /* non-JSON body */ }
const err = new Error(`Devin backend ${method} ${path} failed: ${res.status}`);
err.status = res.status === 401 || res.status === 403 ? res.status : (res.status === 422 ? 422 : 502);
err.type = 'backend_error';
if (detail !== undefined) err.detail = detail;
throw err;
}
return res.json();
}
// ---------------------------------------------------------------------------
// VERIFIED READ surface
// ---------------------------------------------------------------------------
/**
* Liveness + entitlement probe. POST /users/post-auth with a Bearer token and an
* empty body returns {org_id, org_name, email}. Zero-cost (no model billed), so it
* doubles as "is this token still alive and what org does it map to" after 7/1.
*
* NOTE: callers must NOT hit the real network in unit tests — pass a mock fetchImpl.
* Returns the parsed org info; does not mutate cfg.
*/
export async function probePostAuth(cfg, { fetchImpl } = {}) {
// x-cog-org-id is intentionally not required here: this call derives org_id.
const probeCfg = { ...cfg, orgId: '' };
return requestJson(probeCfg, 'POST', '/users/post-auth', { body: {}, fetchImpl });
}
/** GET /org-{bare}/v2sessions — session list (primary). */
export async function listSessions(cfg, { fetchImpl } = {}) {
const seg = orgPathSegment(cfg.orgId);
return requestJson(cfg, 'GET', `/${seg}/v2sessions`, { fetchImpl });
}
/** GET /sessions — session list (verified fallback when v2sessions is unavailable). */
export async function listSessionsFallback(cfg, { fetchImpl } = {}) {
return requestJson(cfg, 'GET', '/sessions', { fetchImpl });
}
/** GET /sessions/{devinId} — single session detail. */
export async function getSession(cfg, devinId, { fetchImpl } = {}) {
const id = encodeURIComponent(String(devinId || ''));
if (!id) throw Object.assign(new Error('devinId is required'), { status: 400, type: 'bad_request' });
return requestJson(cfg, 'GET', `/sessions/${id}`, { fetchImpl });
}
/** GET /events/first-load/{devinId} — first-screen events for a session. */
export async function getFirstLoadEvents(cfg, devinId, { fetchImpl } = {}) {
const id = encodeURIComponent(String(devinId || ''));
if (!id) throw Object.assign(new Error('devinId is required'), { status: 400, type: 'bad_request' });
return requestJson(cfg, 'GET', `/events/first-load/${id}`, { fetchImpl });
}
/** GET /organizations/{orgId} — org settings. */
export async function getOrganization(cfg, { fetchImpl } = {}) {
const id = encodeURIComponent(String(cfg.orgId || ''));
if (!id) throw Object.assign(new Error('orgId is required'), { status: 400, type: 'bad_request' });
return requestJson(cfg, 'GET', `/organizations/${id}`, { fetchImpl });
}
/**
* Build (do NOT open) the SSE event-stream URL + headers for a session.
* GET /events/{devinId}/stream with Accept: text/event-stream.
*
* Returned as {url, headers} rather than an open connection so the caller owns the
* fetch/abort lifecycle and tests can assert the URL/headers without a live socket.
*/
export function buildEventStreamRequest(cfg, devinId) {
const id = encodeURIComponent(String(devinId || ''));
if (!id) throw Object.assign(new Error('devinId is required'), { status: 400, type: 'bad_request' });
return {
url: joinUrl(cfg.baseUrl, `/events/${id}/stream`),
headers: buildDevinHeaders(cfg, { Accept: 'text/event-stream' }),
};
}
// ---------------------------------------------------------------------------
// VERIFIED WRITE/READ surface — api.devin.ai/v3 (P0, 2026-06-29)
// ---------------------------------------------------------------------------
//
// createSession is the standard FastAPI endpoint confirmed live with the
// Windsurf session token. Field schema verified by 422 probing (no session
// created during probing — an int-typed prompt is rejected pre-creation):
// prompt string REQUIRED
// title string optional
// tags string[] optional
// max_acu_limit int optional
// playbook_id string optional
// knowledge_ids string[] optional
// secret_ids string[] optional
// Any other field is ignored by the server.
//
// sendPrompt is NOT a v3 REST endpoint: POST /sessions/{id}/messages exists but
// returns 403 for a Windsurf session token. Prompts/streaming go over ACP
// (src/devin-acp.js, live-verified). sendPrompt therefore delegates to ACP and
// is intentionally left throwing here until P3 wires the ACP path through.
const V3_SESSION_FIELDS = ['prompt', 'title', 'tags', 'max_acu_limit', 'playbook_id', 'knowledge_ids', 'secret_ids'];
/** Org segment for v3: the API expects the full `org-XXXX` form. */
function v3OrgPath(cfg) {
return `/v3/organizations/${orgPathSegment(cfg.orgId)}/sessions`;
}
/**
* Create a new agent session via the verified v3 REST endpoint.
* POST https://api.devin.ai/v3/organizations/{org}/sessions
*
* @param {object} cfg backend config from getDevinBackendConfig()
* @param {object} opts { prompt (required), title?, tags?, max_acu_limit?,
* playbook_id?, knowledge_ids?, secret_ids? }
* @returns {Promise<object>} full session object (session_id, url, status, ...)
*/
export async function createSession(cfg, opts = {}) {
const prompt = opts?.prompt;
if (typeof prompt !== 'string' || !prompt.trim()) {
throw Object.assign(new Error('createSession requires a non-empty string prompt'), {
status: 400,
type: 'bad_request',
});
}
// Whitelist known fields only; the server ignores extras but we keep the body
// tight and predictable.
const body = {};
for (const f of V3_SESSION_FIELDS) {
if (opts[f] !== undefined) body[f] = opts[f];
}
return requestJson(cfg, 'POST', v3OrgPath(cfg), {
body,
baseUrl: cfg.restBaseUrl,
fetchImpl: opts.fetchImpl,
});
}
/**
* Read sessions via the verified v3 REST endpoint (Windsurf token is read-capable
* here). Used to poll a created session's status/result.
* GET https://api.devin.ai/v3/organizations/{org}/sessions?session_ids={id}
* GET .../sessions?limit={n}
*
* @returns {Promise<{items: object[], end_cursor: any, has_next_page: boolean, total: number}>}
*/
export async function listSessionsV3(cfg, { sessionIds, limit, fetchImpl } = {}) {
const params = new URLSearchParams();
if (Array.isArray(sessionIds)) for (const id of sessionIds) params.append('session_ids', String(id));
else if (sessionIds) params.append('session_ids', String(sessionIds));
if (limit) params.set('limit', String(limit));
const qs = params.toString();
return requestJson(cfg, 'GET', `${v3OrgPath(cfg)}${qs ? `?${qs}` : ''}`, {
baseUrl: cfg.restBaseUrl,
fetchImpl,
});
}
/** Convenience: fetch a single session by id via v3, or null if not found. */
export async function getSessionV3(cfg, sessionId, { fetchImpl } = {}) {
const id = String(sessionId || '').trim();
if (!id) throw Object.assign(new Error('sessionId is required'), { status: 400, type: 'bad_request' });
const res = await listSessionsV3(cfg, { sessionIds: id, fetchImpl });
const items = Array.isArray(res?.items) ? res.items : [];
return items.find(s => s?.session_id === id) || items[0] || null;
}
const NOT_IMPLEMENTED = (name) => Object.assign(
new Error(`${name} is not implemented: Devin backend write endpoint is unverified`),
{ status: 501, type: 'not_implemented' },
);
/**
* Send a prompt to an existing session. NOT a v3 REST endpoint — prompts go over
* ACP (POST /sessions/{id}/messages returns 403 for Windsurf tokens). P3 wires
* this through src/devin-acp.js; until then it throws rather than guess a route.
*
* @param {object} cfg backend config
* @param {string} sessionId id from createSession()
* @param {object} opts { prompt, ... }
*/
// eslint-disable-next-line no-unused-vars
export async function sendPrompt(cfg, sessionId, opts = {}) {
throw NOT_IMPLEMENTED('sendPrompt');
}
export const __testing = { joinUrl, requestJson, DEFAULT_BASE_URL, DEFAULT_REST_BASE_URL, V3_SESSION_FIELDS, v3OrgPath };