Skip to content
Open
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
10 changes: 9 additions & 1 deletion apps/server/src/policy/ManagedPolicy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,15 @@ it.layer(NodeServices.layer)("ManagedPolicy.load", (it) => {
]);
// The host variable that replaces Codex's launch arguments is ignored too.
const host = { PATH: "/usr/bin", T3CODE_CODEX_LAUNCH_ARGS: "-c mcp_servers.x.command=sh" };
assert.deepEqual(providerHostEnvironment(policy.summary, host), { PATH: "/usr/bin" });
const view = providerHostEnvironment(policy.summary, host);
assert.deepEqual({ ...view }, { PATH: "/usr/bin" });
host.PATH = "/late/bin";
Object.assign(host, { NEW_VARIABLE: "late", t3code_codex_launch_args: "hidden" });
assert.deepEqual({ ...view }, { PATH: "/late/bin", NEW_VARIABLE: "late" });
assert.isUndefined(view.T3CODE_CODEX_LAUNCH_ARGS);
assert.isFalse("t3code_codex_launch_args" in view);
assert.isFalse(Reflect.set(view, "PATH", "/replaced"));
assert.equal(host.PATH, "/late/bin");
assert.strictEqual(providerHostEnvironment(undefined, host), host);
}),
);
Expand Down
30 changes: 23 additions & 7 deletions apps/server/src/policy/managedProviders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,13 +162,29 @@ export const providerHostEnvironment = (
environment: NodeJS.ProcessEnv,
): NodeJS.ProcessEnv => {
if (summary === undefined) return environment;
const next = { ...environment };
for (const name of Object.keys(next)) {
if (LAUNCH_OVERRIDE_VARIABLES.some((override) => override === name.toUpperCase())) {
delete next[name];
}
}
return next;
const allowed = (name: string | symbol) =>
typeof name !== "string" ||
!LAUNCH_OVERRIDE_VARIABLES.some((override) => override === name.toUpperCase());
return new Proxy(
{},
{
get: (_, name) => (allowed(name) ? Reflect.get(environment, name) : undefined),
has: (_, name) => allowed(name) && Reflect.has(environment, name),
ownKeys: () => Reflect.ownKeys(environment).filter(allowed),
getOwnPropertyDescriptor: (_, name) =>
allowed(name) && Reflect.has(environment, name)
? {
configurable: true,
enumerable: true,
value: Reflect.get(environment, name),
writable: false,
}
: undefined,
set: () => false,
deleteProperty: () => false,
defineProperty: () => false,
},
);
};

/** Each driver's home variable, and the settings that rewrite it at launch. */
Expand Down
11 changes: 10 additions & 1 deletion apps/server/src/provider/CodexProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -736,6 +736,15 @@ function accountProbeStatus({ account, rateLimits }: CodexAppServerProviderSnaps
return { status: "ready", auth };
}

const CODEX_PROBE_FAILED_PREFIX = "Codex app-server provider probe failed: ";

/** The CLI spawned but neither the probe nor `codex --version` ran, e.g. its launcher lacks node. */
export const isCodexLaunchFailure = (provider: ServerProvider): boolean =>
provider.installed &&
provider.status === "error" &&
provider.version === null &&
provider.message?.startsWith(CODEX_PROBE_FAILED_PREFIX) === true;

export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(function* (
codexSettings: CodexSettings,
probe: (input: {
Expand Down Expand Up @@ -815,7 +824,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
status: "error",
auth: { status: "unknown" },
message: installed
? `Codex app-server provider probe failed: ${error.message}.`
? `${CODEX_PROBE_FAILED_PREFIX}${error.message}.`
: `Could not start Codex CLI (\`${codexSettings.binaryPath}\`). Check Settings → Providers → Codex → Binary path on the server.` +
(codexSettings.binaryPath === "codex"
? " Installing ChatGPT or Codex desktop may not add codex to PATH."
Expand Down
18 changes: 18 additions & 0 deletions apps/server/src/provider/ProviderHostPathWatcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Stream from "effect/Stream";

import * as DesktopTelemetryReceiver from "../resourceTelemetry/DesktopTelemetryReceiver.ts";
import * as ProviderRegistry from "./ProviderRegistry.ts";

/** The desktop's shell PATH can arrive after drivers were built and checked against a bare one. */
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const receiver = yield* DesktopTelemetryReceiver.DesktopTelemetryReceiver;
const providers = yield* ProviderRegistry.ProviderRegistry;
yield* receiver.pathChanges.pipe(
Stream.runForEach(() => providers.rebuildMissingAndRefresh),
Effect.forkScoped,
);
}),
);
17 changes: 17 additions & 0 deletions apps/server/src/provider/ProviderInstanceRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -957,6 +957,23 @@ describe("ProviderInstanceRegistry secret environment", () => {
}).pipe(Effect.scoped);
});

it.effect("does not rebuild an instance replaced after selection", () => {
const fixture = setup({ [secret.reference]: "sk-live" });
return Effect.gen(function* () {
const { registry, mutator } = yield* fixture.make;
const selected = yield* registry.listInstances;
fixture.vault.set(secret.reference, "sk-rotated");
yield* mutator.reloadSecrets();
const replacement = yield* registry.getInstance(instanceId);
expect(replacement).not.toBe(selected[0]);
yield* mutator.rebuild(selected);
expect(yield* registry.getInstance(instanceId)).toBe(replacement);
expect(fixture.created).toHaveLength(2);
yield* mutator.rebuild([replacement!]);
expect(fixture.created).toHaveLength(3);
}).pipe(Effect.scoped);
});

it.effect("reads a config field typed SettingsSecretValue and rebuilds when it rotates", () => {
const apiKey: SecretReference = { ...secret, reference: "op://Private/Gateway/key" };
const fixture = setup(
Expand Down
17 changes: 17 additions & 0 deletions apps/server/src/provider/ProviderInstanceRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,22 @@ export const makeProviderInstanceRegistry = Effect.fn(
Effect.provideContext(driverContext),
);

const rebuild: ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutatorShape["rebuild"] =
Effect.fn("ProviderInstanceRegistry.rebuild")(
function* (instances) {
const live = yield* Ref.get(entries);
const ids = new Set(
instances
.filter((instance) => live.get(instance.instanceId)?.instance === instance)
.map((instance) => instance.instanceId),
);
if (ids.size === 0) return;
yield* reconcileWithR(yield* Ref.get(configMapRef), ids);
},
(effect) => semaphore.withPermits(1)(effect),
Effect.provideContext(driverContext),
);

// Reading secrets can wait on a vault unlock, so the server does not wait for it to start.
yield* semaphore.withPermits(1)(
Ref.set(configMapRef, input.configMap).pipe(
Expand Down Expand Up @@ -592,6 +608,7 @@ export const makeProviderInstanceRegistry = Effect.fn(
const mutator: ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutatorShape = {
reconcile,
reloadSecrets,
rebuild,
};

return { registry, mutator };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ describe("layerSettingsWatcher", () => {
const mutator = Layer.succeed(ProviderInstanceRegistryMutator, {
reconcile: () => Queue.offer(reconciled, undefined),
reloadSecrets: () => Effect.sync(() => void reloads++),
rebuild: () => Effect.void,
});
yield* Layer.build(
layerSettingsWatcher(Stream.fromQueue(changes), initial).pipe(Layer.provide(mutator)),
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/provider/ProviderInstanceRegistryMutator.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
/** Reconcile closes removed/replaced scopes before creating replacements: at most one live instance per id. */
import type { ProviderInstanceConfigMap, ProviderInstanceId } from "@t3tools/contracts";
import type { ProviderInstance } from "@t3tools/provider-core/server/driver";
import * as Context from "effect/Context";
import type * as Effect from "effect/Effect";

export interface ProviderInstanceRegistryMutatorShape {
readonly reconcile: (configMap: ProviderInstanceConfigMap) => Effect.Effect<void>;
/** Re-reads the secrets of every instance, or one, and rebuilds those whose secrets changed. */
readonly reloadSecrets: (instanceId?: ProviderInstanceId) => Effect.Effect<void>;
/** Rebuilds these instances from their current config, so their drivers read the environment again. */
readonly rebuild: (instances: ReadonlyArray<ProviderInstance>) => Effect.Effect<void>;
}

export class ProviderInstanceRegistryMutator extends Context.Service<
Expand Down
185 changes: 185 additions & 0 deletions apps/server/src/provider/ProviderRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import {
} from "./providerStatusCache.ts";
import { COMPACT_SLASH_COMMAND } from "@t3tools/provider-core/server/snapshotProbe";
import type {
AnyProviderDriver,
ProviderInstance,
ProviderWorkspaceSnapshot,
} from "@t3tools/provider-core/server/driver";
Expand All @@ -76,6 +77,10 @@ import * as ProviderInstanceRegistryMutator from "./ProviderInstanceRegistryMuta
import * as ProviderRegistry from "./ProviderRegistry.ts";
import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment";
import { makeManualOnlyProviderMaintenanceCapabilities } from "@t3tools/provider-core/server/maintenanceResolver";
import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver";
import * as ProviderHostPathWatcher from "./ProviderHostPathWatcher.ts";
import * as DesktopTelemetryReceiver from "../resourceTelemetry/DesktopTelemetryReceiver.ts";
import { providerHostEnvironment } from "../policy/managedProviders.ts";
vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true });

const decodeServerProvider = Schema.decodeSync(ServerProvider);
Expand Down Expand Up @@ -1156,6 +1161,186 @@ it.layer(
);
});

it.effect.each([
"replay",
"in-flight missing",
"in-flight installed",
"launcher",
"codex timeout",
"other error",
] as const)("recovers a copied driver environment after late PATH (%s)", (mode) =>
Effect.gen(function* () {
// "launcher": the Codex CLI is found on the early PATH, but its check fails until the
// late PATH brings the node its npm launcher runs. "other error" is the same failure on a
// driver whose errors may be auth or network, so it must not be rebuilt. "codex timeout" is a
// Codex error other than a launcher failure, which must not be rebuilt either.
const failsEarly =
mode === "launcher" || mode === "codex timeout" || mode === "other error";
const replay = mode === "replay" || failsEarly;
const host = { PATH: "/early/bin", T3CODE_CODEX_LAUNCH_ARGS: "hidden" };
const environment = providerHostEnvironment(
{ organization: "PATH test", lockedSettings: [] },
host,
);
const updates = yield* DesktopTelemetryReceiver.makeDesktopShellEnvironmentUpdates(
host,
"linux",
);
const entered = yield* Deferred.make<void>();
const release = yield* Deferred.make<void>();
const finished = yield* Deferred.make<void>();
const paths: Array<string | undefined> = [];
let first = true;
const driverKind = ProviderDriverKind.make(
mode === "launcher" || mode === "codex timeout" ? "codex" : "pi",
);
const instanceId = ProviderInstanceId.make("late-path");
const driver: AnyProviderDriver = {
driverKind,
metadata: { displayName: "PATH fixture" },
configSchema: Schema.Unknown,
defaultConfig: () => ({}),
create: (input) =>
Effect.gen(function* () {
const copied = { ...environment };
paths.push(copied.PATH);
const late = copied.PATH?.includes("/late/bin") === true;
const installed = mode === "in-flight installed" || failsEarly || late;
const ready = failsEarly ? late : installed;
const snapshot: ServerProvider = {
instanceId,
driver: driverKind,
enabled: true,
installed: false,
status: "warning",
auth: { status: "unknown" },
version: null,
checkedAt: "2026-10-03T00:00:00.000Z",
models: [],
slashCommands: [],
skills: [],
};
const current = yield* Ref.make(snapshot);
return {
instanceId: input.instanceId,
driverKind,
enabled: true,
displayName: undefined,
continuationIdentity: { driverKind, continuationKey: `${driverKind}:late-path` },
snapshot: {
resolveMaintenance: () =>
Effect.succeed(
makeManualOnlyProviderMaintenanceCapabilities({
provider: driverKind,
packageName: null,
}),
),
getSnapshot: Ref.get(current),
refresh: Effect.gen(function* () {
if (first && !replay) {
first = false;
yield* Deferred.succeed(entered, undefined);
yield* Deferred.await(release);
}
const checked = {
...snapshot,
installed,
status: ready ? ("ready" as const) : ("error" as const),
...(ready
? {}
: {
message:
mode === "codex timeout"
? "Timed out while checking Codex app-server provider status."
: "Codex app-server provider probe failed: transport failed.",
}),
};
yield* Ref.set(current, checked);
return checked;
}),
streamChanges: Stream.empty,
applyUsageLimits: () => Effect.void,
},
orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"],
textGeneration: {} as ProviderInstance["textGeneration"],
} satisfies ProviderInstance;
}),
};
const built = yield* ProviderInstanceRegistry.makeProviderInstanceRegistry({
drivers: [driver],
configMap: { [instanceId]: { driver: driverKind, enabled: true } },
}).pipe(Effect.provide(Layer.mock(SecretResolver.SecretResolver)({})));
yield* Effect.gen(function* () {
const registry = yield* ProviderRegistry.ProviderRegistry;
const running = replay ? undefined : yield* registry.refresh().pipe(Effect.forkScoped);
if (running) yield* Deferred.await(entered);
yield* updates.apply({
version: 1,
type: "shellEnvironment",
platform: "linux",
discovered: true,
environment: { PATH: "/early/bin:/late/bin" },
});
expect(paths).toEqual(["/early/bin"]);
if (replay) {
yield* Layer.build(
ProviderHostPathWatcher.layer.pipe(
Layer.provide(
DesktopTelemetryReceiver.layerTest({ pathChanges: updates.pathChanges }),
),
Layer.provide(
Layer.succeed(ProviderRegistry.ProviderRegistry, {
...registry,
rebuildMissingAndRefresh: registry.rebuildMissingAndRefresh.pipe(
Effect.tap(() => Deferred.succeed(finished, undefined)),
),
}),
),
),
);
} else {
yield* registry.rebuildMissingAndRefresh.pipe(
Effect.tap(() => Deferred.succeed(finished, undefined)),
Effect.forkScoped({ startImmediately: true }),
);
expect(paths).toEqual(["/early/bin"]);
}
yield* Deferred.succeed(release, undefined);
if (running) yield* Fiber.join(running);
yield* Deferred.await(finished);
expect(
(yield* registry.getProviders).find((row) => row.instanceId === instanceId)?.installed,
).toBe(true);
expect(paths).toEqual(
mode === "in-flight installed" || mode === "codex timeout" || mode === "other error"
? ["/early/bin"]
: ["/early/bin", "/early/bin:/late/bin"],
);
}).pipe(
Effect.provide(
ProviderRegistry.layer.pipe(
Layer.provide(
Layer.succeed(ProviderInstanceRegistry.ProviderInstanceRegistry, built.registry),
),
Layer.provide(
Layer.succeed(
ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutator,
built.mutator,
),
),
),
),
);
}).pipe(
Effect.scoped,
Effect.provide(
ServerConfig.layerTest(process.cwd(), { prefix: "t3-late-path-" }).pipe(
Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
),
),
),
);

describe("Codex model inventories", () => {
const cachedProvider = {
instanceId: ProviderInstanceId.make("codex-personal"),
Expand Down
Loading
Loading