connect the opencode credential at sidecar boot
opencode keeps credentials in two unrelated places. The CLI, opencode run and the legacy /session surface read auth.json. The newer /api surface — the one with steer, queue, interrupt and a resumable per-session stream — reads its own integration store and knows nothing about that file. With none connected it does not fail. It falls back to what needs no credential, the free tier, and a request for a paid model is never executed: prompt accepted, admitted, prompted, then no step, no error, no message, forever. That silence cost most of an afternoon and would cost it again on every new machine — alpha included. So the sidecar does it, rather than depending on someone having run a curl. Best-effort and never blocking: turns go through opencode run, which reads auth.json and does not care. Retried, because /api/health answers before the integration store is ready — the first version of this shipped without a retry and failed on its very first real boot with a 500, while the identical request succeeded seconds later. Only 5xx retries; a 4xx means the request is wrong and repeating it just prints the same complaint six times. Verified by deleting the credential, restarting, and running sonnet on the new pipeline with no manual step. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// Hand the serve's NEW api surface the provider key it cannot find on its own.
|
||||
//
|
||||
// ── The problem this exists to prevent ──
|
||||
//
|
||||
// opencode keeps credentials in two unrelated places. The CLI, `opencode run` and the legacy
|
||||
// `/session/*` surface read `~/.local/share/opencode/auth.json`. The newer `/api/session/*` surface —
|
||||
// the one with `delivery: "steer" | "queue"`, `/interrupt` and a resumable per-session event stream —
|
||||
// reads its own integration store instead (`/api/integration`, `/api/credential`), and knows nothing
|
||||
// about that file.
|
||||
//
|
||||
// With no credential the new pipeline does not fail. It falls back to whatever needs none, which is the
|
||||
// free tier, and a request for a paid model is simply never executed: prompt accepted, `prompt.admitted`
|
||||
// and `prompted` emitted, no step, no error, no assistant message, forever. That silence cost most of an
|
||||
// afternoon to diagnose (docs/opencode-fork-decision.md) and would cost it again on every new machine.
|
||||
//
|
||||
// So the sidecar connects it at start-up rather than relying on somebody having run a curl by hand.
|
||||
//
|
||||
// ── Deliberately best-effort ──
|
||||
//
|
||||
// Never throws and never blocks start-up. Turns run through `opencode run`, which uses `auth.json` and
|
||||
// is unaffected by any of this; failing here costs the new pipeline only, and the sidecar is far more
|
||||
// useful up than down. The connection persists in opencode's own store, so this is a no-op on every
|
||||
// start after the first.
|
||||
|
||||
const AUTH_PATH = join(process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'), 'opencode', 'auth.json');
|
||||
|
||||
const ATTEMPTS = 6;
|
||||
const RETRY_DELAY_MS = 1_500;
|
||||
|
||||
/** The provider key opencode already holds for itself, or null. Never logged, never returned to callers. */
|
||||
function readProviderKey(providerId: string): string | null {
|
||||
try {
|
||||
const auth = JSON.parse(readFileSync(AUTH_PATH, 'utf8')) as Record<string, { type?: string; key?: string }>;
|
||||
const entry = auth[providerId];
|
||||
return entry?.type === 'api' && typeof entry.key === 'string' && entry.key ? entry.key : null;
|
||||
} catch {
|
||||
return null; // no auth file, unreadable, or not JSON — nothing to connect
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect `auth.json`'s key for one provider to the serve's integration store.
|
||||
*
|
||||
* `providerId` doubles as the integration id: opencode names them the same, so the Zen key stored under
|
||||
* `opencode` connects to integration `opencode`.
|
||||
*/
|
||||
export async function connectProviderCredential(baseUrl: string, providerId = 'opencode'): Promise<void> {
|
||||
const key = readProviderKey(providerId);
|
||||
if (!key) {
|
||||
console.log(`[opencode] no ${providerId} key in auth.json; the new API surface will only reach free models`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Retried, because `/api/health` answers before the integration store is ready: connecting immediately
|
||||
// after the health check returns 500, and the identical request succeeds seconds later. Measured, not
|
||||
// assumed — the first version of this shipped without the retry and failed on its first real boot.
|
||||
//
|
||||
// Only 5xx is retried. A 4xx means the request itself is wrong (bad key, unknown integration) and
|
||||
// repeating it just prints the same complaint five times.
|
||||
for (let attempt = 1; attempt <= ATTEMPTS; attempt++) {
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/integration/${providerId}/connect/key`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ key, label: 'officer-opencode sidecar' }),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
console.log(`[opencode] connected the ${providerId} credential to the api surface`);
|
||||
return;
|
||||
}
|
||||
// The body is deliberately not logged: a credential endpoint's error may quote what it was given.
|
||||
if (res.status < 500) {
|
||||
console.error(`[opencode] could not connect the ${providerId} credential: HTTP ${res.status}`);
|
||||
return;
|
||||
}
|
||||
if (attempt === ATTEMPTS) {
|
||||
console.error(
|
||||
`[opencode] could not connect the ${providerId} credential after ${ATTEMPTS} attempts: HTTP ${res.status}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
if (attempt === ATTEMPTS) {
|
||||
console.error(
|
||||
`[opencode] could not connect the ${providerId} credential:`,
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await Bun.sleep(RETRY_DELAY_MS);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { join } from 'node:path';
|
||||
import { DATA_PATH } from '../../data-path';
|
||||
import { createSidecarConnector } from '../connect';
|
||||
import { sweepRecordedServe } from './serve-sweep';
|
||||
import { connectProviderCredential } from './connect-credential';
|
||||
import { createSessionLogStore } from '../claude/session-log';
|
||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
||||
import { runOpenCodeTurn, killOpenCodeTurn, listRunningOpenCodeTurns, stopAllOpenCodeTurns } from './runner';
|
||||
@@ -132,6 +133,11 @@ if (!(await waitHealthy(baseUrl, HEALTH_TIMEOUT_MS))) {
|
||||
}
|
||||
console.log(`[opencode] serve healthy on port ${port}`);
|
||||
|
||||
// The new /api surface keeps credentials separately from auth.json and would otherwise reach free models
|
||||
// only — silently. Best-effort and not awaited for correctness: turns go through `opencode run`, which
|
||||
// reads auth.json directly and does not depend on this.
|
||||
void connectProviderCredential(baseUrl);
|
||||
|
||||
// ── Command handlers ──
|
||||
|
||||
type ReplyFn = (msg: SidecarEvent) => void;
|
||||
|
||||
Reference in New Issue
Block a user