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
9 changes: 6 additions & 3 deletions apps/server/src/bin.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
/**
* Every ACP agent spawns `t3 acp-mcp-bridge` while opening its session, and terminal-fallback
* agents run `t3 acp-mcp-call` per tool call, so their startup sits on first-message latency.
* Both dispatch here before the full CLI module graph (seconds of evaluation) loads; everything
* else defers to the real CLI in ./binCli.ts.
* They and Codex's managed-token helper dispatch before the full CLI module graph (seconds
* of evaluation) loads; everything else defers to the real CLI in ./binCli.ts.
*/
import { isEntrypoint } from "./entrypoint.ts";

Expand All @@ -14,7 +14,10 @@ if (
})
) {
const command = process.argv[2];
if (command === "acp-mcp-bridge" || command === "acp-mcp-call") {
if (command === "__managed-chatgpt-token") {
const { runManagedChatGptTokenCli } = await import("./cli/managedChatGptToken.ts");
await runManagedChatGptTokenCli(process.argv.slice(3));
} else if (command === "acp-mcp-bridge" || command === "acp-mcp-call") {
const { runAcpMcpCliFastPath } = await import("./mcp/AcpMcpStdioBridge.ts");
await runAcpMcpCliFastPath(command, process.argv.slice(3));
} else {
Expand Down
224 changes: 224 additions & 0 deletions apps/server/src/cli/managedChatGptToken.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
// @effect-diagnostics nodeBuiltinImport:off - Runs the actual configured auth command in separate Node processes.
import * as NodeURL from "node:url";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, it } from "@effect/vitest";
import { CodexSettings, EnvironmentId, ProviderInstanceId } from "@t3tools/contracts";
import * as HostProcess from "@t3tools/shared/HostProcess";
import * as Clock from "effect/Clock";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";
import * as Stream from "effect/Stream";
import { ChildProcess, ChildProcessSpawner } from "effect/process";
import { HttpClient, HttpClientResponse } from "effect/http";
import * as CodexManagedTokenBroker from "../provider/CodexManagedTokenBroker.ts";
import * as ServerConfig from "../config.ts";
import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
import * as CodexInstallation from "../provider/CodexInstallation.ts";
import { makeCodexManagedRuntime } from "../provider/CodexManagedRuntime.ts";
import * as ProviderCredentialStore from "../provider/ProviderCredentialStore.ts";
import { codexAppServerArgs } from "../provider/codexLaunchArgs.ts";

const decodeSettings = Schema.decodeSync(CodexSettings);
const decodeCommand = Schema.decodeUnknownSync(
Schema.fromJsonString(Schema.Tuple([Schema.String, Schema.Array(Schema.String)])),
);

it.live(
"configured auth command reads the exact store, rotates once across processes, and rejects replaced or logged-out profiles",
() =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const config = yield* ServerConfig.ServerConfig;
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const instanceId = ProviderInstanceId.make("codex-personal");
const home = path.join(config.baseDir, "Codex home's space");
const preload = path.join(config.baseDir, "oauth-mock.mjs");
let renewals = 0;
yield* fs.makeDirectory(home);
// Helpers must remain transport-only and avoid the full CLI/auth/store graph.
yield* fs.writeFileString(
preload,
`
import { registerHooks } from 'node:module';
if (process.env.ELECTRON_RUN_AS_NODE !== undefined) throw new Error('Node-hosted helper must not gain an Electron flag');
registerHooks({ resolve(specifier, context, next) {
if (['binCli', 'CodexChatGptAuth', 'ServerSecretStore', 'CodexManagedTokenBroker'].some(s => specifier.includes(s))) throw new Error('Server graph must not load');
return next(specifier, context);
} });
globalThis.fetch = () => { throw new Error('Transport helper cannot use network'); };
`,
);
yield* Effect.gen(function* () {
const store = yield* ProviderCredentialStore.make("codex-chatgpt", instanceId);
const credentials = {
clientId: "oaiapp_test",
subject: "fake-subject",
idToken: "fake-id-token",
accessToken: "fake-initial-access",
refreshToken: "fake-refresh",
email: null,
expiresAt: (yield* Clock.currentTimeMillis) + 3_600_000,
earliestRefreshAt: null,
scopes: ["chatgpt.tokens.use.direct"],
};
const save = (record: typeof credentials) =>
store.set(new TextEncoder().encode(JSON.stringify(record)));
yield* save(credentials);
const runtime = yield* makeCodexManagedRuntime({
instanceId,
enabled: true,
config: decodeSettings({ setupMode: "managed", homePath: home }),
environment: {
PATH: process.env.PATH,
SystemRoot: process.env.SystemRoot,
T3CODE_HOME: path.join(config.baseDir, "wrong-home"),
ACCESS_TOKEN: "ambient-secret",
OPENAI_BASE_URL: "https://wrong-endpoint.invalid",
OPENAI_API_KEY: "ambient-key",
},
});
const effective = yield* runtime.resolve;
assert.isUndefined(effective.environment.ELECTRON_RUN_AS_NODE);
const table = codexAppServerArgs(effective.config.launchArgs).find((arg) =>
arg.startsWith("model_providers.openai_token_sharing_"),
)!;
const match = /auth=\{command=(.*),args=(\[.*\]),timeout_ms=/u.exec(table)!;
const [command, args] = decodeCommand(`[${match[1]},${match[2]}]`);
const broker = yield* CodexManagedTokenBroker.CodexManagedTokenBroker;
assert.include(args, yield* broker.endpoint);
assert.notInclude(args, config.secretsDir);
for (const secret of [
credentials.accessToken,
credentials.refreshToken,
credentials.idToken,
credentials.subject,
]) {
assert.notInclude(table, secret);
assert.notInclude(JSON.stringify(effective.environment), secret);
}
const run = Effect.gen(function* () {
const child = yield* spawner.spawn(
ChildProcess.make(command, args, {
extendEnv: false,
env: {
...effective.environment,
NODE_OPTIONS: `--import ${JSON.stringify(NodeURL.pathToFileURL(preload).href)}`,
},
}),
);
const [stdout, stderr, code] = yield* Effect.all(
[
child.stdout.pipe(Stream.decodeText(), Stream.mkString),
child.stderr.pipe(Stream.decodeText(), Stream.mkString),
child.exitCode,
],
{ concurrency: "unbounded" },
);
return { stdout, stderr, code: Number(code) };
}).pipe(Effect.scoped);
assert.deepEqual(yield* run, { stdout: credentials.accessToken, stderr: "", code: 0 });
yield* save({ ...credentials, expiresAt: 0 });
const results = yield* Effect.all([run, run], { concurrency: "unbounded" });
for (const result of results)
assert.deepEqual(result, { stdout: "fake-renewed-access", stderr: "", code: 0 });
assert.equal(renewals, 1);
assert.equal(
Option.getOrThrow(yield* runtime.auth.read).refreshToken,
"fake-rotated-refresh",
);
assert.deepEqual(
(yield* fs.readDirectory(config.secretsDir)).filter(
(name) => name.endsWith(".lock") || name.endsWith(".tmp"),
),
[],
);
// Even the same registration and subject must not leak a replacement sign-in's token.
yield* save({
...credentials,
idToken: "replacement-id-token",
accessToken: "replacement-access",
});
const replaced = yield* run;
assert.equal(replaced.code, 1);
assert.equal(replaced.stdout, "");
assert.notInclude(replaced.stderr, "replacement-access");
yield* store.remove;
const loggedOut = yield* run;
assert.equal(loggedOut.code, 1);
assert.equal(loggedOut.stdout, "");
assert.isFalse(yield* fs.exists(path.join(config.baseDir, "wrong-home")));
}).pipe(
Effect.provide(
CodexManagedTokenBroker.layer.pipe(Layer.provideMerge(ServerSecretStore.layer)),
),
Effect.provideService(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.sync(() => {
if (request.url.endsWith("/.well-known/openid-configuration"))
return HttpClientResponse.fromWeb(
request,
Response.json({
issuer: "https://auth.openai.com",
authorization_endpoint: "https://auth.openai.com/authorize",
token_endpoint: "https://auth.openai.com/token",
jwks_uri: "https://auth.openai.com/jwks",
}),
);
assert.equal(request.url, "https://auth.openai.com/token");
renewals++;
assert.equal(renewals, 1, "consumed refresh must never be replayed");
return HttpClientResponse.fromWeb(
request,
Response.json({
access_token: "fake-renewed-access",
refresh_token: "fake-rotated-refresh",
token_type: "Bearer",
expires_in: 3600,
scope: "chatgpt.tokens.use.direct",
}),
);
}),
),
),
);
}).pipe(
Effect.scoped,
Effect.provideService(HostProcess.Arguments, [
process.execPath,
NodeURL.fileURLToPath(new URL("../bin.ts", import.meta.url)),
]),
Effect.provideService(ServerEnvironment.ServerEnvironmentIdentity, {
getEnvironmentId: Effect.succeed(
EnvironmentId.make("00000000-0000-4000-8000-000000000001"),
),
}),
Effect.provide(
Layer.mock(CodexInstallation.CodexInstallation)({
managedDirectory: "/test/managed-codex",
acquire: () =>
Effect.succeed({
executablePath: "/test/codex",
source: "local",
version: "0.162.1",
managedVersionDirectory: null,
}),
}),
),
Effect.provideService(
HttpClient.HttpClient,
HttpClient.make(() => Effect.die("No parent network expected")),
),
Effect.provide(
ServerConfig.layerTest(process.cwd(), { prefix: "t3-command-home's space-" }).pipe(
Layer.provideMerge(NodeServices.layer),
),
),
),
);
48 changes: 48 additions & 0 deletions apps/server/src/cli/managedChatGptToken.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// @effect-diagnostics nodeBuiltinImport:off - Lean self-invoked local IPC client; no auth/store graph.
import * as NodeNet from "node:net";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as Schema from "effect/Schema";
import * as Protocol from "../provider/CodexManagedTokenProtocol.ts";

const decodeArguments = Schema.decodeUnknownEffect(
Schema.Tuple([
Schema.NonEmptyString,
Protocol.Request.fields.instanceId,
Protocol.Request.fields.environmentId,
Protocol.Request.fields.profileKey,
]),
);
const encodeRequest = Schema.encodeSync(Schema.fromJsonString(Protocol.Request));
const decodeResponse = Schema.decodeUnknownEffect(Schema.fromJsonString(Protocol.Response), {
onExcessProperty: "error",
});

/** Codex's auth.command protocol is a bare token on stdout, never a CLI diagnostic. */
export async function runManagedChatGptTokenCli(args: ReadonlyArray<string>): Promise<void> {
const program = Effect.gen(function* () {
const [endpoint, instanceId, environmentId, profileKey] = yield* decodeArguments(args);
const socket = yield* Effect.acquireRelease(
Effect.sync(() => NodeNet.createConnection(endpoint)),
(socket) => Effect.sync(() => socket.destroy()),
);
socket.on("error", () => {});
socket.once("connect", () =>
socket.write(encodeRequest({ version: 1, instanceId, environmentId, profileKey }) + "\n"),
);
return yield* Protocol.readFrame(
socket,
Protocol.RESPONSE_BYTES,
Protocol.COMMAND_TIMEOUT_MS,
).pipe(Effect.flatMap(decodeResponse));
}).pipe(Effect.scoped);
const result = await Effect.runPromiseExit(program);
if (Exit.isFailure(result)) {
process.stderr.write(
"Could not access the managed ChatGPT connection. Reconnect in T3 Code.\n",
);
process.exitCode = 1;
return;
}
process.stdout.write(result.value.token);
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import * as ServerConfig from "../config.ts";
import * as SqlitePersistence from "../persistence/Sqlite.ts";
import * as AntigravityInstallation from "../provider/AntigravityInstallation.ts";
import * as CodexInstallation from "../provider/CodexInstallation.ts";
import * as CodexManagedTokenBroker from "../provider/CodexManagedTokenBroker.ts";
import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
import * as ModelManifest from "../provider/ModelManifest.ts";
import * as ProviderInstanceRegistryHydration from "../provider/ProviderInstanceRegistryHydration.ts";
Expand Down Expand Up @@ -115,6 +116,9 @@ const layerProviderInstanceRegistry = ProviderInstanceRegistryHydration.layer.pi
Layer.provide(FetchHttpClient.layer),
Layer.provide(layerPlatformTest),
),
Layer.mock(CodexManagedTokenBroker.CodexManagedTokenBroker)({
endpoint: Effect.succeed("unused-private-pipe"),
}),
Layer.mock(CodexInstallation.CodexInstallation)({
managedDirectory: "unused-managed-installation",
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import * as ServerConfig from "../config.ts";
import * as SqlitePersistence from "../persistence/Sqlite.ts";
import * as AntigravityInstallation from "../provider/AntigravityInstallation.ts";
import * as CodexInstallation from "../provider/CodexInstallation.ts";
import * as CodexManagedTokenBroker from "../provider/CodexManagedTokenBroker.ts";
import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
import * as ModelManifest from "../provider/ModelManifest.ts";
import * as ProviderInstanceRegistryHydration from "../provider/ProviderInstanceRegistryHydration.ts";
Expand Down Expand Up @@ -101,6 +102,9 @@ const layerProviderInstanceRegistry = ProviderInstanceRegistryHydration.layer.pi
Layer.provide(FetchHttpClient.layer),
Layer.provide(layerPlatformTest),
),
Layer.mock(CodexManagedTokenBroker.CodexManagedTokenBroker)({
endpoint: Effect.succeed("unused-private-pipe"),
}),
Layer.mock(CodexInstallation.CodexInstallation)({
managedDirectory: "unused-managed-installation",
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import * as ServerConfig from "../config.ts";
import * as SqlitePersistence from "../persistence/Sqlite.ts";
import * as AntigravityInstallation from "../provider/AntigravityInstallation.ts";
import * as CodexInstallation from "../provider/CodexInstallation.ts";
import * as CodexManagedTokenBroker from "../provider/CodexManagedTokenBroker.ts";
import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
import * as ModelManifest from "../provider/ModelManifest.ts";
import * as ProviderInstanceRegistryHydration from "../provider/ProviderInstanceRegistryHydration.ts";
Expand Down Expand Up @@ -100,6 +101,9 @@ const layerProviderInstanceRegistry = ProviderInstanceRegistryHydration.layer.pi
Layer.provide(FetchHttpClient.layer),
Layer.provide(layerPlatformTest),
),
Layer.mock(CodexManagedTokenBroker.CodexManagedTokenBroker)({
endpoint: Effect.succeed("unused-private-pipe"),
}),
Layer.mock(CodexInstallation.CodexInstallation)({
managedDirectory: "unused-managed-installation",
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import * as ServerConfig from "../config.ts";
import * as SqlitePersistence from "../persistence/Sqlite.ts";
import * as AntigravityInstallation from "../provider/AntigravityInstallation.ts";
import * as CodexInstallation from "../provider/CodexInstallation.ts";
import * as CodexManagedTokenBroker from "../provider/CodexManagedTokenBroker.ts";
import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
import * as ModelManifest from "../provider/ModelManifest.ts";
import * as ProviderInstanceRegistryHydration from "../provider/ProviderInstanceRegistryHydration.ts";
Expand Down Expand Up @@ -167,6 +168,9 @@ const layerProviderInstanceRegistry = ProviderInstanceRegistryHydration.layer.pi
Layer.provide(FetchHttpClient.layer),
Layer.provide(layerPlatformTest),
),
Layer.mock(CodexManagedTokenBroker.CodexManagedTokenBroker)({
endpoint: Effect.succeed("unused-private-pipe"),
}),
Layer.mock(CodexInstallation.CodexInstallation)({
managedDirectory: "unused-managed-installation",
}),
Expand Down
Loading
Loading