Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/model-credential-url.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": patch
---

`eve dev` can take its AI Gateway credential from the program that starts it: set `EVE_MODEL_CREDENTIAL_URL` and `EVE_MODEL_CREDENTIAL_TOKEN`, and every Gateway model call, including compaction, uses the credential that URL serves, ahead of every other connection. When Gateway refuses the token, eve asks the URL for a fresh one.
2 changes: 2 additions & 0 deletions docs/guides/dev-tui.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ Type to filter a menu, press `Enter` to select, or `Esc` to return to chat. Dism

eve saves API keys and eve-owned OAuth refresh credentials in the OS secret store through just-secrets. It saves the last successful login as the machine default and records the project's connection and team separately as nonsecret metadata in `.eve/provider.json`. Newly entered keys are never written into project files. A key explicitly selected through `/login` takes precedence over another key for that provider in your shell; a project connected through environment credentials continues to use its environment. Vercel CLI retains ownership of its credentials and refresh tokens.

A program that starts `eve dev` and already holds a Gateway credential can serve it instead. Set `EVE_MODEL_CREDENTIAL_URL` and `EVE_MODEL_CREDENTIAL_TOKEN` in the environment of `eve dev`. eve then sends a `GET` to that URL with `Authorization: Bearer <token>` for every Gateway model call, including compaction, ahead of every other connection. The response is `{ "kind": "oauth", "token": "...", "teamId": "..." }` (`kind` may also be `api-key` or `oidc`; `teamId` is optional), or `{ "error": "..." }` with a failure status. When Gateway refuses an `oauth` token, eve asks again with `x-eve-model-rejected-token-sha256` set to the refused token's SHA-256 hex digest, so the server can return a fresh one.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"for every Gateway model call, including compaction" claims more than the code does. The override lives in localGatewayModel, and only string model IDs reach it (runtime/agent/resolve-model.ts:74). An authored model instance such as gateway("..."), or a dynamic selection that returns a model object, is returned as-is (resolve-model.ts:70-71, :113) and never fetches from this URL. The same goes for a compaction.model instance. Suggest "for every model call that names a Gateway model by its string ID, including compaction".


Local discovery runs only in development. Deployments need explicitly provisioned `AI_GATEWAY_API_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, or supported project OIDC credentials. ChatGPT subscription models are local-only. `/login` does not link a deployment or authenticate a remote server; `/deploy` handles Vercel CLI installation and account login when needed.

### Models and settings
Expand Down
36 changes: 27 additions & 9 deletions packages/eve/src/internal/model-auth/development-broker-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,21 +30,39 @@ export async function readDevelopmentModelCredential(
if (!isLoopbackHostname(url.hostname) || url.protocol !== "http:" || url.username || url.password)
throw new Error("The local model connection is unavailable. Restart eve dev.");
url.searchParams.set("provider", provider);
const headers: Record<string, string> = {
[DEVELOPMENT_WORKFLOW_TRANSPORT_HEADER]: process.env[DEVELOPMENT_WORKFLOW_SECRET_ENV]!,
};
return fetchModelCredential(
url.href,
{ [DEVELOPMENT_WORKFLOW_TRANSPORT_HEADER]: process.env[DEVELOPMENT_WORKFLOW_SECRET_ENV]! },
rejectedToken,
signal,
);
}

/** A credential server's own failure message, written for the person running eve. */
export class ModelCredentialServerError extends Error {}

/** GETs a credential from a broker: `rejectedToken`, hashed, asks it for a fresh one. */
export async function fetchModelCredential(
url: string,
headers: Record<string, string>,
rejectedToken?: string,
signal?: AbortSignal | null,
): Promise<DevelopmentModelCredential> {
if (rejectedToken)
headers[DEVELOPMENT_MODEL_REJECTED_HEADER] = createHash("sha256")
.update(rejectedToken)
.digest("hex");
// The host may wait for the refresh lock and complete two OAuth requests.
const value = await authJson(url.href, { headers, signal }, 256_000, 75_000);
headers = {
...headers,
[DEVELOPMENT_MODEL_REJECTED_HEADER]: createHash("sha256").update(rejectedToken).digest("hex"),
};
// The broker may wait for a refresh lock and complete two OAuth requests.
const value = await authJson(url, { headers, signal }, 256_000, 75_000);
if (
typeof value.token !== "string" ||
!value.token ||
!["api-key", "oauth", "oidc"].includes(String(value.kind))
)
throw new Error("The model connection is unavailable. Run /login to reconnect.");
throw typeof value.error === "string"
? new ModelCredentialServerError(value.error.slice(0, 500))
: new Error("The model connection is unavailable. Run /login to reconnect.");
return {
kind: value.kind as DevelopmentModelCredential["kind"],
token: value.token,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { DEVELOPMENT_WORKFLOW_TRANSPORT_HEADER } from "#internal/workflow/develo
import { getDefaultCodexTokenBroker } from "#public/models/openai/chatgpt/token-broker.js";
import { resolveModelApiKey } from "./api-key.js";
import { resolveGatewayModelCredential } from "./gateway-credential.js";
import { ModelCredentialServerError } from "./development-broker-client.js";
import {
DEVELOPMENT_MODEL_CREDENTIAL_ROUTE,
DEVELOPMENT_MODEL_REJECTED_HEADER,
Expand Down Expand Up @@ -51,9 +52,15 @@ export async function handleDevelopmentModelCredentialRequest(
};
}
return Response.json(credential, { headers });
} catch {
} catch (error) {
// A served credential URL's own message reaches the turn; resolver internals don't.
return Response.json(
{ error: "The model connection is unavailable. Run /login to reconnect." },
{
error:
error instanceof ModelCredentialServerError
? error.message
: "The model connection is unavailable. Run /login to reconnect.",
},
{ status: 503, headers },
);
}
Expand Down
66 changes: 66 additions & 0 deletions packages/eve/src/internal/model-auth/gateway-credential.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { createHash } from "node:crypto";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
vi.mock("./store.js", () => ({
readModelSecret: vi.fn(),
modelKeySecretName: (name: string) => name,
}));
import { resolveGatewayModelCredential } from "./gateway-credential.js";
import { localGatewayModel } from "./transport.js";

const fetch = vi.fn();
beforeEach(() => {
vi.stubGlobal("fetch", fetch);
vi.stubEnv("EVE_DEV", "1");
vi.stubEnv("EVE_MODEL_CONNECTION", "");
vi.stubEnv("AI_GATEWAY_API_KEY", "machine-key");
vi.stubEnv("EVE_MODEL_CREDENTIAL_URL", "https://hub.example/api/model-credential");
vi.stubEnv("EVE_MODEL_CREDENTIAL_TOKEN", "bot-token");
Comment on lines +11 to +17

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests depend on the environment they run in: EVE_DEV_CONTROL_URL and EVE_DEV_WORKFLOW_TRANSPORT_SECRET aren't stubbed. If both are inherited (say, the suite runs from a shell inside eve dev), developmentModelBrokerAvailable() returns true and the final localGatewayModel(...)).toBeUndefined() fails even with the served URL blank. Stub both to "" here.

});
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
fetch.mockReset();
});

it("takes the gateway credential from the served URL over any machine credential", async () => {
fetch.mockResolvedValue(Response.json({ kind: "oauth", token: "owner-token", teamId: "team_1" }));
expect(await resolveGatewayModelCredential()).toEqual({
kind: "oauth",
token: "owner-token",
teamId: "team_1",
});
const [url, init] = fetch.mock.calls[0]!;
expect(url).toBe("https://hub.example/api/model-credential");
expect(init.headers).toEqual({ authorization: "Bearer bot-token" });
});

it("names a rejected token by its hash so the server refreshes it", async () => {
fetch.mockResolvedValue(Response.json({ kind: "oauth", token: "fresh" }));
await resolveGatewayModelCredential("stale");
expect(fetch.mock.calls[0]![1].headers).toEqual({
authorization: "Bearer bot-token",
"x-eve-model-rejected-token-sha256": createHash("sha256").update("stale").digest("hex"),
});
});

it("fails with the server's own error", async () => {
fetch.mockResolvedValue(
Response.json({ error: "the Vercel sign-in needs renewing in the app" }, { status: 401 }),
);
await expect(resolveGatewayModelCredential()).rejects.toThrow(
"the Vercel sign-in needs renewing in the app",
);
});

it("refuses a served URL without its token", async () => {
vi.stubEnv("EVE_MODEL_CREDENTIAL_TOKEN", "");
await expect(resolveGatewayModelCredential()).rejects.toThrow("EVE_MODEL_CREDENTIAL_TOKEN");
expect(fetch).not.toHaveBeenCalled();
});

it("routes local models through the gateway when only the served URL is set", () => {
vi.stubEnv("AI_GATEWAY_API_KEY", "");
expect(localGatewayModel("anthropic/claude-sonnet-4.5")).toBeDefined();
vi.stubEnv("EVE_MODEL_CREDENTIAL_URL", "");
expect(localGatewayModel("anthropic/claude-sonnet-4.5")).toBeUndefined();
});
12 changes: 12 additions & 0 deletions packages/eve/src/internal/model-auth/gateway-credential.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,25 @@
import { getVercelOidcToken } from "#compiled/@vercel/oidc/index.js";
import { MODEL_CONNECTION_ENV } from "#shared/model-helper.js";
import { resolveModelApiKey } from "./api-key.js";
import { fetchModelCredential } from "./development-broker-client.js";
import { readVercelCliConnection, refreshVercelCliConnection } from "./vercel-cli.js";
import { resolveVercelSession } from "./vercel.js";
import type { DevelopmentModelCredential } from "./development-broker-protocol.js";

/** Set by a process that launches eve and serves its credential: it outranks every other source. */
export const MODEL_CREDENTIAL_URL_ENV = "EVE_MODEL_CREDENTIAL_URL";
export const MODEL_CREDENTIAL_TOKEN_ENV = "EVE_MODEL_CREDENTIAL_TOKEN";

export async function resolveGatewayModelCredential(
rejectedToken?: string,
): Promise<DevelopmentModelCredential> {
const served = process.env[MODEL_CREDENTIAL_URL_ENV];
if (served) {
const token = process.env[MODEL_CREDENTIAL_TOKEN_ENV];
if (!token)
throw new Error(`${MODEL_CREDENTIAL_URL_ENV} is set without ${MODEL_CREDENTIAL_TOKEN_ENV}.`);
return fetchModelCredential(served, { authorization: `Bearer ${token}` }, rejectedToken);
Comment on lines +16 to +21

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sends the bearer token to any URL and accepts an OAuth token back, with no check on scheme or host. http://hub.example/... would send both the launcher token and the user's Gateway OAuth token over the network in plaintext. The sibling broker client already rejects anything other than loopback http: with no userinfo (development-broker-client.ts:29-31). Can we require https:, or http: only on a loopback host (isLoopbackHostname), and reject url.username/url.password, so a misconfigured launcher fails loudly instead of leaking?

}
const selected = process.env[MODEL_CONNECTION_ENV];
if (selected === "vercel" || selected === "vercel-cli") {
if (selected === "vercel-cli" && rejectedToken) await refreshVercelCliConnection();
Expand Down
104 changes: 104 additions & 0 deletions packages/eve/src/internal/model-auth/served-credential.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// A served credential URL (EVE_MODEL_CREDENTIAL_URL) on the path a dev turn
// takes: the worker's Gateway model asks eve dev's credential relay, and the
// relay asks the served URL with the real resolver.
import { createHash } from "node:crypto";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { DEVELOPMENT_WORKFLOW_SECRET_ENV } from "#internal/workflow/development-world-protocol.js";
import { DEVELOPMENT_MODEL_REJECTED_HEADER } from "./development-broker-protocol.js";

vi.mock("#cli/dev/environment.js", () => ({ loadDevelopmentEnvironmentFiles: vi.fn() }));
vi.mock("./store.js", () => ({ readModelSecret: vi.fn(), modelKeySecretName: (n: string) => n }));
vi.mock("#public/models/openai/chatgpt/token-broker.js", () => ({
getDefaultCodexTokenBroker: vi.fn(),
}));
import { handleDevelopmentModelCredentialRequest } from "./development-broker-server.js";
import { readDevelopmentModelCredential } from "./development-broker-client.js";
import { localGatewayModel } from "./transport.js";

const origin = "http://localhost:4567";
const secret = "fixture-transport-secret";
const served = "https://hub.example/api/model-credential";
const hub = vi.fn<(request: Request) => Promise<Response>>();
const gateway = vi.fn<(url: string, init: RequestInit) => Promise<Response>>();
beforeEach(() => {
vi.stubEnv("EVE_DEV", "1");
vi.stubEnv("EVE_DEV_CONTROL_URL", origin);
vi.stubEnv(DEVELOPMENT_WORKFLOW_SECRET_ENV, secret);
vi.stubEnv("EVE_MODEL_CONNECTION", "");
vi.stubEnv("AI_GATEWAY_API_KEY", "");
vi.stubEnv("EVE_MODEL_CREDENTIAL_URL", served);
vi.stubEnv("EVE_MODEL_CREDENTIAL_TOKEN", "bot-token");
vi.stubGlobal(
"fetch",
vi.fn(async (url: Parameters<typeof fetch>[0], init?: RequestInit) => {
const request = new Request(url, init);
if (request.url.startsWith(origin))
return (
(await handleDevelopmentModelCredentialRequest(request, {
appRoot: "/fixture",
secret,
})) ?? new Response(null, { status: 404 })
);
if (request.url === served) return hub(request);
return gateway(String(url), init ?? {});
}),
);
});
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
hub.mockReset();
gateway.mockReset();
});

it("refreshes a token Gateway refused by asking the served URL with its hash", async () => {
const stale = createHash("sha256").update("stale").digest("hex");
// The served URL hands out its live token until it is named as refused.
hub.mockImplementation(async (request) =>
Response.json({
kind: "oauth",
token: request.headers.get(DEVELOPMENT_MODEL_REJECTED_HEADER) === stale ? "fresh" : "stale",
}),
);
// The transport reuses one Headers across its retry, so read it as each call is sent.
const sent: (string | null)[] = [];
gateway.mockImplementation(async (_url, init) =>
sent.push(new Headers(init.headers).get("authorization")) && sent.at(-1) === "Bearer stale"
? new Response(null, { status: 401 })
: new Response(
'data: {"type":"text-start","id":"t"}\n\ndata: {"type":"text-delta","id":"t","delta":"pong"}\n\ndata: {"type":"text-end","id":"t"}\n\n',
{
headers: { "content-type": "text/event-stream" },
},
),
);
const model = localGatewayModel("anthropic/claude-sonnet-4.5");
if (!model || typeof model === "string") throw new Error("Expected a Gateway model");
const result = await model.doStream({
prompt: [{ role: "user", content: [{ type: "text", text: "ping" }] }],
});
let text = "";
for await (const event of result.stream) {
if (event.type === "error") throw event.error;
if (event.type === "text-delta") text += event.delta;
}
expect(text).toBe("pong");
expect(sent).toEqual(["Bearer stale", "Bearer fresh"]);
expect(hub.mock.calls.map(([r]) => r.headers.get("authorization"))).toEqual(
Array(3).fill("Bearer bot-token"),
);
expect(hub.mock.calls.map(([r]) => r.headers.get(DEVELOPMENT_MODEL_REJECTED_HEADER))).toEqual([
null,
null,
stale,
]);
});

it("brings the served URL's own error to the turn", async () => {
hub.mockResolvedValue(
Response.json({ error: "the Vercel sign-in needs renewing in the app" }, { status: 401 }),
);
await expect(readDevelopmentModelCredential("gateway")).rejects.toThrow(
"the Vercel sign-in needs renewing in the app",
);
});
3 changes: 2 additions & 1 deletion packages/eve/src/internal/model-auth/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
developmentModelBrokerAvailable,
readDevelopmentModelCredential,
} from "./development-broker-client.js";
import { resolveGatewayModelCredential } from "./gateway-credential.js";
import { MODEL_CREDENTIAL_URL_ENV, resolveGatewayModelCredential } from "./gateway-credential.js";
import { VERCEL_TEAM_HEADER } from "./vercel.js";
import { MODEL_CONNECTION_ENV } from "#shared/model-helper.js";

Expand All @@ -34,6 +34,7 @@ function createLocalGateway(): ReturnType<typeof createGateway> | undefined {
const selected = process.env[MODEL_CONNECTION_ENV];
if (
!developmentModelBrokerAvailable() &&
!process.env[MODEL_CREDENTIAL_URL_ENV] &&
selected !== "vercel" &&
selected !== "vercel-cli" &&
selected !== "ai-gateway-key" &&
Expand Down
Loading