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
29 changes: 29 additions & 0 deletions electron/codex-guardrail.js
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,31 @@ function writeOverlayConfigToml(dest, sourceHome, trustToml) {
fs.writeFileSync(destCfg, body + String(trustToml || ""), "utf8");
}

/**
* @param {string} dest
* @param {string} sourceHome
*/
function unlinkForeignLinks(dest, sourceHome) {
let entries = [];
try {
entries = fs.readdirSync(dest, { withFileTypes: true });
} catch {
return;
}
const home = sourceHome ? path.resolve(sourceHome) : "";
for (const ent of entries) {
if (!ent.isSymbolicLink()) continue;
const link = path.join(dest, ent.name);
try {
const target = path.resolve(dest, fs.readlinkSync(link));
if (home && path.dirname(target) === home) continue;
fs.unlinkSync(link);
} catch {
// best-effort; linkOrSkip below leaves an unreadable link alone
}
}
}

function linkOrSkip(src, dst) {
if (!fs.existsSync(src) || fs.existsSync(dst)) return;
try {
Expand All @@ -210,6 +235,10 @@ function materializeCodexGuardrailHome(opts) {
fs.mkdirSync(dest, { recursive: true });

const sourceHome = String((opts && opts.sourceHome) || "");
// The overlay is per thread, and a thread can move to another named
// instance (#453). A link left from the previous home would run the turn
// on that account (auth.json), so drop every link not into this home.
unlinkForeignLinks(dest, sourceHome);
if (sourceHome && fs.existsSync(sourceHome)) {
let names = [];
try {
Expand Down
4 changes: 3 additions & 1 deletion electron/ipc-app.js
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,9 @@ module.exports = {
// Sign-in probes (#1501): the boot list kicks them in the background; a
// later list (picker or Agents pane opening) waits for a rate-limited one.
const first = !providerAuth.started();
const probing = providerAuth.refresh();
const probing = providerAuth.refresh({
instances: services.instanceAuthProbes(ctx.store),
});
if (!first) await probing;
return services.listProvidersForApi(ctx.store);
},
Expand Down
18 changes: 17 additions & 1 deletion electron/ipc-devserver.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ const devservers = require("./devservers.js");
const terminal = require("./terminal.js");
const preview = require("./preview.js");
const providerAuth = require("./providerAuth.js");
const {
parseProviderRef,
findInstance,
instanceEnv,
CONFIG_DIR_ENV,
} = require("./providerInstances.js");

/** Mirrors SIGNIN_TERMINAL_ID in src/shared/ipc.ts. */
const SIGNIN_TERMINAL_ID = "__signin__";
Expand Down Expand Up @@ -183,7 +189,17 @@ module.exports = {
// command typed in. The command comes from a fixed table, never the caller.
"terminal:signIn": async (ctx, input) => {
const provider = String((input && input.provider) || "");
const command = providerAuth.loginCommand(provider);
// A named instance (#453) logs in under its own config dir.
const { provider: base, instance } = parseProviderRef(provider);
const inst = instance
? findInstance(ctx.store.getSettings(), base, instance)
: null;
if (instance && !inst) throw new Error(`Unknown provider instance: ${provider}`);
const dirVar = CONFIG_DIR_ENV[/** @type {"claude" | "codex"} */ (base)];
const configEnv = inst ? instanceEnv(inst) : {};
const command = providerAuth.loginCommand(base, {
configEnv: inst && configEnv[dirVar] ? { [dirVar]: configEnv[dirVar] } : undefined,
});
if (!command) throw new Error(`No sign-in command for ${provider || "this provider"}`);
const threadId = (input && input.threadId) || SIGNIN_TERMINAL_ID;
const { root, project, logDir } = terminalRoot(ctx, threadId);
Expand Down
6 changes: 4 additions & 2 deletions electron/ipc-insights.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@ module.exports = {
threadsByDay: ctx.store.getUsageThreadsByDay(),
};
},
"usage:providerLimits": async () => {
"usage:providerLimits": async (ctx) => {
const { fetchProviderLimits } = require("./providerUsage.js");
return fetchProviderLimits();
return fetchProviderLimits({
instances: require("./services.js").instanceAuthProbes(ctx.store),
});
},
"insights:failureModes": async (ctx) => {
const { clusterFailureModes } = require("./failuremodes.js");
Expand Down
44 changes: 31 additions & 13 deletions electron/providerAuth.js
Original file line number Diff line number Diff line change
Expand Up @@ -140,21 +140,35 @@ async function probeOne(id, env) {

/**
* Re-probe every provider unless the last probe is younger than TTL_MS.
* Named instances (#453) probe their base CLI under the instance's env and
* cache under their ref; a new instance skips the TTL so it shows at once.
*
* @param {{ env?: NodeJS.ProcessEnv, force?: boolean, now?: number }} [opts]
* @param {{
* env?: NodeJS.ProcessEnv,
* force?: boolean,
* now?: number,
* instances?: Array<{ ref: string, provider: string, env: Record<string, string> }>,
* }} [opts]
* @returns {Promise<void>}
*/
function refresh(opts = {}) {
const now = opts.now ?? Date.now();
if (inflight) return inflight;
if (!opts.force && probedAt && now - probedAt < TTL_MS) return Promise.resolve();
const instances = opts.instances || [];
const fresh = probedAt && now - probedAt < TTL_MS;
if (!opts.force && fresh && instances.every((i) => cache.has(i.ref))) {
return Promise.resolve();
}
const env = opts.env || process.env;
probedAt = now;
inflight = Promise.all(
Object.keys(PROBES).map(async (id) => {
inflight = Promise.all([
...Object.keys(PROBES).map(async (id) => {
cache.set(id, await probeOne(id, env));
}),
).finally(() => {
...instances.map(async (i) => {
cache.set(i.ref, await probeOne(i.provider, { ...env, ...i.env }));
}),
]).finally(() => {
inflight = null;
});
return inflight;
Expand All @@ -177,14 +191,16 @@ function invalidate() {
*/
function get(id) {
if (cache.has(id)) return cache.get(id);
return started() && getProvider(id) ? "unknown" : undefined;
return started() && getProvider(id.split(":")[0]) ? "unknown" : undefined;
}

/**
* Shell line that starts the provider's login flow, or null.
* `configEnv` is a named instance's config-dir var (#453). Only that one
* goes on the line: instance env may hold keys, and a terminal echoes.
*
* @param {string} id
* @param {{ env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform }} [opts]
* @param {{ env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, configEnv?: Record<string, string> }} [opts]
* @returns {string | null}
*/
function loginCommand(id, opts = {}) {
Expand All @@ -194,12 +210,14 @@ function loginCommand(id, opts = {}) {
const bin = resolveBin(entry, opts.env || process.env);
if (!bin) return null;
const win = (opts.platform || process.platform) === "win32";
const quoted = /^[\w.-]+$/.test(bin)
? bin
: win
? `"${bin}"`
: `'${bin.replace(/'/g, `'\\''`)}'`;
return [quoted, ...args].join(" ");
/** @param {string} v */
const q = (v) => (win ? `"${v}"` : `'${v.replace(/'/g, `'\\''`)}'`);
const quoted = /^[\w.-]+$/.test(bin) ? bin : q(bin);
const line = [quoted, ...args].join(" ");
const pairs = Object.entries(opts.configEnv || {});
if (!pairs.length) return line;
if (win) return `${pairs.map(([k, v]) => `set "${k}=${v}"`).join(" && ")} && ${line}`;
return `${pairs.map(([k, v]) => `${k}=${q(v)}`).join(" ")} ${line}`;
}

/** Test hook: drop all state. */
Expand Down
Loading
Loading