Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ Fusion surfaces are agent prompts, not a second runtime command. Claude Code use
when the caller explicitly asks for full auto-approval.
- **Handoffs require `DEVIN_API_KEY`** in the environment; nothing writes
secrets to disk.
- **`DEVIN_API_URL` is validated before any authenticated request**: it must be
unset or exactly `https://api.devin.ai` (optional trailing slash). Userinfo,
paths, queries, fragments, other ports, and other hosts are rejected, and
redirects are never followed. Tests only: with
`DEVIN_COMPANION_ALLOW_LOOPBACK_API=1`, an `http(s)` URL on `127.0.0.1`,
`[::1]`, or `localhost` (any port) is accepted. `setup` reports a rejected URL.

## Per-tool wiring

Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,10 @@ Fusion keeps a frontier model as the lead for intent, planning, ambiguity, archi

No. Local review and task commands use an installed and authenticated Devin CLI. Only cloud handoffs require `DEVIN_API_KEY`.

### Can I point the cloud API at another URL?

No. Cloud calls send your API key as a bearer token, so `DEVIN_API_URL` is validated before any request. Unset it (the default) or set it to exactly `https://api.devin.ai`. Userinfo, paths, query strings, fragments, other ports, other hosts, and plain `http://` are rejected, and authenticated requests never follow redirects. The only exception is for tests: with `DEVIN_COMPANION_ALLOW_LOOPBACK_API=1` set, `DEVIN_API_URL` may be an `http(s)` URL on `127.0.0.1`, `[::1]`, or `localhost` (any port). `/devin:setup` reports a rejected URL.

## Development

```bash
Expand Down
1 change: 1 addition & 0 deletions plugins/devin/commands/handoff.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Raw slash-command arguments:
Core constraint:
- This command creates a cloud Devin session on Devin's infrastructure, not locally.
- It requires `DEVIN_API_KEY` in the environment. If unset, the companion reports that and stops — tell the user to get a key at https://app.devin.ai/settings/api-keys and re-run.
- If `DEVIN_API_URL` is set, it must be exactly `https://api.devin.ai`; any other value is rejected before a request is made, and `/devin:setup` reports it.
- The companion automatically attaches the repo slug, current branch, and uncommitted diff as context. Warn the user if the diff may contain secrets they do not want sent to the cloud.
- Your only job is to run the handoff and return the companion's output verbatim to the user.

Expand Down
5 changes: 4 additions & 1 deletion plugins/devin/scripts/devin-companion.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ async function cmdSetup(cwd, argv) {
authenticated: auth.authenticated,
authDetail: auth.detail ?? null
},
cloud: { available: cloud.available, keyKind: cloud.keyKind },
cloud: { available: cloud.available, keyKind: cloud.keyKind, apiUrlError: cloud.apiUrlError },
git: { available: gitProbe.available, detail: gitProbe.detail ?? null },
node: { version: process.versions.node, ok: nodeMajor > 18 || (nodeMajor === 18 && nodeMinor >= 18) },
config
Expand Down Expand Up @@ -388,6 +388,9 @@ async function cmdHandoff(cwd, argv) {
if (!config.available) {
throw new Error("DEVIN_API_KEY is not set. Get a key at https://app.devin.ai/settings/api-keys — /devin:handoff needs it.");
}
if (config.apiUrlError) {
throw new Error(config.apiUrlError);
}

const gitContext = getGitHandoffContext(cwd);
const wantsWait = Boolean(options.wait || options.background);
Expand Down
65 changes: 64 additions & 1 deletion plugins/devin/scripts/lib/cloud.mjs
Original file line number Diff line number Diff line change
@@ -1,19 +1,76 @@
const DEFAULT_API_URL = "https://api.devin.ai";
const LOOPBACK_OPT_IN_ENV = "DEVIN_COMPANION_ALLOW_LOOPBACK_API";
const LOOPBACK_URL_PATTERN = /^https?:\/\/(?:127\.0\.0\.1|localhost|\[::1\])(?::\d{1,5})?\/?$/i;
const MAX_DIFF_BYTES = 100 * 1024;

// Resolve the origin that bearer-authenticated requests are sent to. Throws a
// descriptive Error (never echoing the raw value, which may hold credentials)
// unless DEVIN_API_URL is unset, exactly https://api.devin.ai (optional single
// trailing slash, any host casing), or, only with DEVIN_COMPANION_ALLOW_LOOPBACK_API=1,
// an http(s) loopback URL (127.0.0.1, [::1], localhost) on any port. Everything else
// is rejected: userinfo, paths, queries, fragments, other ports, other hosts.
export function resolveApiOrigin(env = process.env) {
const raw = env.DEVIN_API_URL;
if (raw === undefined || raw === "") {
return DEFAULT_API_URL;
}
if (/[\s\u0000-\u001f\u007f\\]/.test(raw)) {
throw new Error("DEVIN_API_URL must not contain whitespace, control characters, or backslashes.");
}
let parsed;
try {
parsed = new URL(raw);
} catch {
throw new Error(`DEVIN_API_URL is not a valid URL. Leave it unset to use ${DEFAULT_API_URL}.`);
}
if (parsed.username || parsed.password || raw.includes("@")) {
throw new Error("DEVIN_API_URL must not contain credentials (user:pass@).");
}
if (parsed.search || parsed.hash || raw.includes("?") || raw.includes("#")) {
throw new Error("DEVIN_API_URL must not contain a query string or fragment.");
}
const isLoopback = LOOPBACK_URL_PATTERN.test(raw);
if (isLoopback) {
if (env[LOOPBACK_OPT_IN_ENV] !== "1") {
throw new Error(`DEVIN_API_URL points at a loopback address, which is only allowed for tests when ${LOOPBACK_OPT_IN_ENV}=1 is set.`);
}
return parsed.origin;
}
const normalized = raw.toLowerCase();
if (normalized !== DEFAULT_API_URL && normalized !== `${DEFAULT_API_URL}/`) {
throw new Error(`DEVIN_API_URL must be exactly ${DEFAULT_API_URL} (no other host, scheme, port, or path). Leave it unset to use the default.`);
}
return DEFAULT_API_URL;
}

export function getCloudConfig(env = process.env) {
const apiKey = env.DEVIN_API_KEY ?? null;
let apiUrl = null;
let apiUrlError = null;
try {
apiUrl = resolveApiOrigin(env);
} catch (error) {
apiUrlError = error instanceof Error ? error.message : String(error);
}
return {
apiKey,
apiUrl: env.DEVIN_API_URL ?? DEFAULT_API_URL,
apiUrl,
apiUrlError,
orgId: env.DEVIN_ORG_ID ?? null,
userId: env.DEVIN_USER_ID ?? null,
available: Boolean(apiKey),
keyKind: apiKey ? (apiKey.startsWith("cog_") ? "service" : "personal") : null
};
}

function assertApiUrlValid(config) {
if (config.apiUrlError || !config.apiUrl) {
throw new Error(config.apiUrlError ?? "DEVIN_API_URL failed validation.");
}
}

function resolveApiBase(config) {
assertApiUrlValid(config);
if (config.keyKind === "service") {
if (!config.orgId) {
throw new Error("DEVIN_ORG_ID (or --org-id) is required for service keys (cog_*).");
Expand All @@ -24,15 +81,21 @@ function resolveApiBase(config) {
}

async function apiRequest(config, method, url, body) {
assertApiUrlValid(config);
const response = await fetch(url, {
method,
redirect: "error",
headers: {
Authorization: `Bearer ${config.apiKey}`,
"Content-Type": "application/json"
},
...(body !== undefined ? { body: JSON.stringify(body) } : {})
});

if (response.status >= 300 && response.status < 400) {
throw new Error(`Devin API returned HTTP ${response.status}; redirects are not followed on authenticated requests.`);
}

const text = await response.text();
let parsed = null;
try {
Expand Down
6 changes: 6 additions & 0 deletions plugins/devin/scripts/lib/render.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ export function renderSetupReport(report) {
lines.push(`- devin CLI: ${report.devin.installed ? `installed (${report.devin.version ?? "unknown version"})` : "NOT FOUND"}`);
lines.push(`- auth: ${report.devin.authenticated ? `ok — ${report.devin.authDetail}` : `not authenticated (${report.devin.authDetail ?? "unknown"})`}`);
lines.push(`- cloud API (DEVIN_API_KEY): ${report.cloud.available ? `set (${report.cloud.keyKind} key)` : "not set — /devin:handoff unavailable"}`);
if (report.cloud.apiUrlError) {
lines.push(`- cloud API URL (DEVIN_API_URL): REJECTED, cloud calls are disabled (${report.cloud.apiUrlError})`);
}
lines.push(`- git: ${report.git.available ? "ok" : `missing (${report.git.detail})`}`);
lines.push(`- review gate: ${report.config.stopReviewGate ? "enabled" : "disabled"}`);
lines.push("");
Expand All @@ -42,6 +45,9 @@ export function renderSetupReport(report) {
if (!report.cloud.available) {
lines.push("Set DEVIN_API_KEY to enable cloud handoffs (https://app.devin.ai/settings/api-keys). Local review/rescue work without it.");
}
if (report.cloud.apiUrlError) {
lines.push("Unset DEVIN_API_URL (or set it to exactly https://api.devin.ai) so the API key is only ever sent to Devin.");
}
if (report.ready) {
lines.push("Devin is ready.");
}
Expand Down
Loading
Loading