Skip to content
Closed
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 @@ -187,7 +187,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
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 @@ -944,6 +944,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 @@ -504,6 +504,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 @@ -554,6 +570,7 @@ export const makeProviderInstanceRegistry = Effect.fn(
const mutator: ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutatorShape = {
reconcile,
reloadSecrets,
rebuild,
};

return { registry, 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
164 changes: 164 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,165 @@ it.layer(
);
});

it.effect.each(["replay", "in-flight missing", "in-flight installed"] as const)(
"recovers a copied driver environment after late PATH (%s)",
(mode) =>
Effect.gen(function* () {
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("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 installed =
mode === "in-flight installed" || copied.PATH?.includes("/late/bin") === true;
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: "pi:late-path" },
snapshot: {
resolveMaintenance: () =>
Effect.succeed(
makeManualOnlyProviderMaintenanceCapabilities({
provider: driverKind,
packageName: null,
}),
),
getSnapshot: Ref.get(current),
refresh: Effect.gen(function* () {
if (first && mode !== "replay") {
first = false;
yield* Deferred.succeed(entered, undefined);
yield* Deferred.await(release);
}
const checked = {
...snapshot,
installed,
status: installed ? ("ready" as const) : ("error" as const),
};
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 =
mode === "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 (mode === "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"
? ["/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
31 changes: 31 additions & 0 deletions apps/server/src/provider/ProviderRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ export class ProviderRegistry extends Context.Service<
instanceId?: ProviderInstanceId,
) => Effect.Effect<ReadonlyArray<ServerProvider>>;

/**
* Rebuilds and re-checks enabled instances not yet found installed, after the host environment
* changed. Drivers copy the environment when built, so only a rebuild sees a later PATH.
*/
readonly rebuildMissingAndRefresh: Effect.Effect<ReadonlyArray<ServerProvider>>;

/** Only refreshed instances can verify an update; the published list still includes every row. */
readonly refreshAfterUpdate: (
provider: ProviderDriverKind,
Expand Down Expand Up @@ -692,6 +698,28 @@ export const layer = Layer.effect(
return yield* instanceId === undefined ? refresh() : refreshInstance(instanceId);
});

// Instances that found their CLI keep running; restarting them could end live sessions.
const rebuildMissingAndRefresh = Effect.fn("rebuildMissingAndRefresh")(function* () {
const instances = yield* instanceRegistry.listInstances;
yield* refresh();
const missing = yield* Effect.filter(instances, (instance) =>
Effect.gen(function* () {
// ACP also reports installed:false for setup states unrelated to executable discovery.
if (
!instance.enabled ||
instance.driverKind === "acpRegistry" ||
!(yield* isCurrentInstance(instance))
)
return false;
const snapshot = yield* instance.snapshot.getSnapshot;
return !snapshot.installed;
}),
);
if (missing.length === 0) return yield* Ref.get(providersRef);
yield* instanceRegistryMutator.rebuild(missing);
return yield* refresh();
});

const refreshAfterUpdate = Effect.fn("refreshAfterUpdate")(function* (
provider: ProviderDriverKind,
selectedInstanceId: ProviderInstanceId,
Expand Down Expand Up @@ -1077,6 +1105,9 @@ export const layer = Layer.effect(
refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)),
reloadSecretsAndRefresh: (instanceId?: ProviderInstanceId) =>
reloadSecretsAndRefresh(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)),
rebuildMissingAndRefresh: rebuildMissingAndRefresh().pipe(
Effect.catchCause(recoverRefreshFailure),
),
refreshWorkspaceSnapshot: (input) =>
refreshWorkspaceSnapshot(input).pipe(Effect.catchCause(recoverRefreshFailure)),
refreshAfterUpdate,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/provider/providerMaintenanceRunner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ function makeRegistry(
refresh: () => Ref.get(providersRef),
refreshInstance: () => Ref.get(providersRef),
reloadSecretsAndRefresh: () => Ref.get(providersRef),
rebuildMissingAndRefresh: Ref.get(providersRef),
refreshAfterUpdate: () => {
const version = options?.installedVersionAfterUpdate;
if (version === undefined) {
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/provider/testUtils/providerRegistryMock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const makeProviderRegistryMock = (
refresh: () => Effect.succeed(providers),
refreshInstance: () => Effect.succeed(providers),
reloadSecretsAndRefresh: () => Effect.succeed(providers),
rebuildMissingAndRefresh: Effect.succeed(providers),
refreshAfterUpdate: () => Effect.succeed(providers),
refreshWorkspaceSnapshot: () => Effect.succeed(providers),
invalidateModelCaches: () => Effect.void,
Expand Down
Loading