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
8 changes: 7 additions & 1 deletion apps/server/scripts/acp-mock-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ const emitStaleXAiPromptCompleteBeforeSecondHang =
process.env.T3_ACP_EMIT_STALE_XAI_PROMPT_COMPLETE_BEFORE_SECOND_HANG === "1";
const emitOverlappingXAiPromptCompleteOutOfOrder =
process.env.T3_ACP_EMIT_OVERLAPPING_XAI_PROMPT_COMPLETE_OUT_OF_ORDER === "1";
const failAuthenticate = process.env.T3_ACP_FAIL_AUTHENTICATE === "1";
const authenticateEmail = process.env.T3_ACP_AUTHENTICATE_EMAIL;
const failPrompt = process.env.T3_ACP_FAIL_PROMPT === "1";
const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1";
const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1";
Expand Down Expand Up @@ -307,7 +309,11 @@ const program = Effect.gen(function* () {
}),
);

yield* agent.handleAuthenticate(() => Effect.succeed({}));
yield* agent.handleAuthenticate(() =>
failAuthenticate
? Effect.fail(AcpError.AcpRequestError.authRequired())
: Effect.succeed(authenticateEmail ? { _meta: { email: authenticateEmail } } : {}),
);

yield* agent.handleCreateSession(() =>
Effect.succeed({
Expand Down
74 changes: 74 additions & 0 deletions apps/server/src/provider/Layers/GrokProvider.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
// @effect-diagnostics nodeBuiltinImport:off - locates the ACP mock agent for the fake Grok CLI.
import * as NodePath from "node:path";
import * as NodeURL from "node:url";

import * as NodeServices from "@effect/platform-node/NodeServices";
import { describe, expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
Expand All @@ -10,6 +14,39 @@ import { buildInitialGrokProviderSnapshot, checkGrokProviderStatus } from "./Gro

const decodeGrokSettings = Schema.decodeSync(GrokSettings);

const mockAgentPath = NodePath.join(
NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)),
"../../../scripts/acp-mock-agent.ts",
);

// The API-key branch is covered in GrokAcpSupport.test.ts; drop the key here so
// a developer's real credentials cannot change what the probe negotiates.
const { XAI_API_KEY: _ignoredApiKey, ...acpProbeEnv } = process.env;

/** A `grok` stand-in that answers `--version` itself and defers `agent stdio` to the ACP mock. */
const writeMockGrokCli = () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-acp-" });
const grokPath = path.join(dir, "grok");
yield* fs.writeFileString(
grokPath,
[
"#!/bin/sh",
'if [ "$1" = "--version" ]; then',
' printf "grok-cli 0.0.99\\n"',
" exit 0",
"fi",
// @effect-diagnostics-next-line preferSchemaOverJson:off - quotes paths for the shell wrapper.
`exec ${JSON.stringify(process.execPath)} ${JSON.stringify(mockAgentPath)} "$@"`,
"",
].join("\n"),
);
yield* fs.chmod(grokPath, 0o755);
return grokPath;
});

describe("buildInitialGrokProviderSnapshot", () => {
it.effect("returns a disabled snapshot when settings.enabled is false", () =>
Effect.gen(function* () {
Expand Down Expand Up @@ -107,4 +144,41 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => {
expect(snapshot.message).toContain("ACP startup failed");
}),
);

it.effect("reports the account the Grok CLI authenticates as", () =>
Effect.gen(function* () {
const snapshot = yield* Effect.scoped(
Effect.gen(function* () {
const grokPath = yield* writeMockGrokCli();
return yield* checkGrokProviderStatus(
decodeGrokSettings({ enabled: true, binaryPath: grokPath }),
{ ...acpProbeEnv, T3_ACP_AUTHENTICATE_EMAIL: "grok-user@example.com" },
);
}),
);

expect(snapshot.status).toBe("ready");
expect(snapshot.installed).toBe(true);
expect(snapshot.auth).toEqual({ status: "authenticated", email: "grok-user@example.com" });
}),
);

it.effect("reports an unauthenticated CLI when the agent demands sign-in", () =>
Effect.gen(function* () {
const snapshot = yield* Effect.scoped(
Effect.gen(function* () {
const grokPath = yield* writeMockGrokCli();
return yield* checkGrokProviderStatus(
decodeGrokSettings({ enabled: true, binaryPath: grokPath }),
{ ...acpProbeEnv, T3_ACP_FAIL_AUTHENTICATE: "1" },
);
}),
);

expect(snapshot.status).toBe("error");
expect(snapshot.installed).toBe(true);
expect(snapshot.auth).toEqual({ status: "unauthenticated" });
expect(snapshot.message).toContain("not authenticated");
}),
);
});
31 changes: 21 additions & 10 deletions apps/server/src/provider/Layers/GrokProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,12 @@ import {
enrichProviderSnapshotWithVersionAdvisory,
type ProviderMaintenanceCapabilities,
} from "../providerMaintenance.ts";
import { makeGrokAcpRuntime, resolveGrokAcpBaseModelId } from "../acp/GrokAcpSupport.ts";
import {
grokAuthFailureFromAcpCause,
grokAuthFromAcpAuthenticate,
makeGrokAcpRuntime,
resolveGrokAcpBaseModelId,
} from "../acp/GrokAcpSupport.ts";

const GROK_PRESENTATION = {
displayName: "Grok",
Expand Down Expand Up @@ -123,7 +128,7 @@ function buildGrokDiscoveredModelsFromSessionModelState(
.filter((model): model is ServerProviderModel => model !== undefined);
}

const discoverGrokModelsViaAcp = (
const probeGrokViaAcp = (
grokSettings: GrokSettings,
environment: NodeJS.ProcessEnv = process.env,
) =>
Expand All @@ -137,7 +142,10 @@ const discoverGrokModelsViaAcp = (
clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" },
});
const started = yield* acp.start();
return buildGrokDiscoveredModelsFromSessionModelState(started.sessionSetupResult.models);
return {
models: buildGrokDiscoveredModelsFromSessionModelState(started.sessionSetupResult.models),
auth: grokAuthFromAcpAuthenticate(started.authenticateResult, environment),
};
}).pipe(Effect.scoped);

const runGrokVersionCommand = (
Expand Down Expand Up @@ -251,14 +259,15 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func
});
}

const discoveryExit = yield* discoverGrokModelsViaAcp(grokSettings, environment).pipe(
const discoveryExit = yield* probeGrokViaAcp(grokSettings, environment).pipe(
Effect.timeoutOption(GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS),
Effect.exit,
);
if (Exit.isFailure(discoveryExit)) {
yield* Effect.logWarning("Grok ACP model discovery failed", {
errorTag: causeErrorTag(discoveryExit.cause),
});
const authFailure = grokAuthFailureFromAcpCause(discoveryExit.cause);
return buildServerProvider({
presentation: GROK_PRESENTATION,
enabled: grokSettings.enabled,
Expand All @@ -268,8 +277,10 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func
installed: true,
version,
status: "error",
auth: { status: "unknown" },
message: "Grok CLI is installed but ACP startup failed. Check server logs for details.",
auth: authFailure?.auth ?? { status: "unknown" },
message:
authFailure?.message ??
"Grok CLI is installed but ACP startup failed. Check server logs for details.",
},
});
}
Expand All @@ -291,10 +302,10 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func
},
});
}
const discoveredModels = discoveryExit.value.value;
const probeResult = discoveryExit.value.value;
const models =
discoveredModels.length > 0
? grokModelsFromSettings(grokSettings.customModels, discoveredModels)
probeResult.models.length > 0
? grokModelsFromSettings(grokSettings.customModels, probeResult.models)
: fallbackModels;

return buildServerProvider({
Expand All @@ -306,7 +317,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func
installed: true,
version,
status: "ready",
auth: { status: "unknown" },
auth: probeResult.auth,
},
});
});
Expand Down
5 changes: 4 additions & 1 deletion apps/server/src/provider/acp/AcpSessionRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ export interface AcpSessionRequestLogEvent {
export interface AcpSessionRuntimeStartResult {
readonly sessionId: string;
readonly initializeResult: EffectAcpSchema.InitializeResponse;
/** Agent response to `authenticate`, the only place most agents report account identity. */
readonly authenticateResult: EffectAcpSchema.AuthenticateResponse;
readonly sessionSetupResult:
| EffectAcpSchema.LoadSessionResponse
| EffectAcpSchema.NewSessionResponse
Expand Down Expand Up @@ -545,7 +547,7 @@ export const make = (
methodId: options.authMethodId,
} satisfies EffectAcpSchema.AuthenticateRequest;

yield* runLoggedRequest(
const authenticateResult = yield* runLoggedRequest(
"authenticate",
authenticatePayload,
acp.agent.authenticate(authenticatePayload),
Expand Down Expand Up @@ -650,6 +652,7 @@ export const make = (
const nextState = {
sessionId,
initializeResult,
authenticateResult,
sessionSetupResult,
modelConfigId: extractModelConfigId(sessionSetupResult),
} satisfies AcpStartedState;
Expand Down
15 changes: 14 additions & 1 deletion apps/server/src/provider/acp/GrokAcpCliProbe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import * as Effect from "effect/Effect";
import { ChildProcessSpawner } from "effect/unstable/process";
import { describe, expect } from "vite-plus/test";

import { makeGrokAcpRuntime } from "./GrokAcpSupport.ts";
import { grokAuthFromAcpAuthenticate, makeGrokAcpRuntime } from "./GrokAcpSupport.ts";

const makeProbeRuntime = Effect.gen(function* () {
const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
Expand All @@ -35,6 +35,19 @@ describe.runIf(process.env.T3_GROK_ACP_PROBE === "1")("Grok ACP CLI probe", () =
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("authenticate carries credentials the provider snapshot can report", () =>
Effect.gen(function* () {
const runtime = yield* makeProbeRuntime;
const started = yield* runtime.start();

// A successful `authenticate` is the only auth signal the Grok CLI gives
// us. If this regresses, the settings card falls back to claiming
// authentication could not be verified.
const auth = grokAuthFromAcpAuthenticate(started.authenticateResult, process.env);
expect(auth.status).toBe("authenticated");
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("session/new advertises typed SessionModelState with at least one model", () =>
Effect.gen(function* () {
const runtime = yield* makeProbeRuntime;
Expand Down
50 changes: 50 additions & 0 deletions apps/server/src/provider/acp/GrokAcpSupport.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { describe, expect, it } from "@effect/vitest";
import * as Cause from "effect/Cause";
import * as Effect from "effect/Effect";
import * as EffectAcpErrors from "effect-acp/errors";

import {
applyGrokAcpModelSelection,
buildGrokAcpSpawnInput,
grokAuthFailureFromAcpCause,
grokAuthFromAcpAuthenticate,
resolveGrokAcpBaseModelId,
} from "./GrokAcpSupport.ts";

Expand Down Expand Up @@ -35,6 +38,53 @@ describe("buildGrokAcpSpawnInput", () => {
});
});

describe("grokAuthFromAcpAuthenticate", () => {
it("reports the account email Grok returns from authenticate", () => {
expect(
grokAuthFromAcpAuthenticate({
_meta: { email: " grok-user@example.com ", auth_mode: "Oidc", team_id: "team-1" },
}),
).toEqual({ status: "authenticated", email: "grok-user@example.com" });
});

it("labels API key credentials when authenticate reports no account", () => {
expect(grokAuthFromAcpAuthenticate({}, { XAI_API_KEY: "secret" })).toEqual({
status: "authenticated",
type: "API key",
});
});

it("treats a bare authenticate success as authenticated without identity", () => {
expect(grokAuthFromAcpAuthenticate({ _meta: { email: " " } }, {})).toEqual({
status: "authenticated",
});
expect(grokAuthFromAcpAuthenticate({ _meta: null }, {})).toEqual({ status: "authenticated" });
});
});

describe("grokAuthFailureFromAcpCause", () => {
it("maps an ACP auth-required failure to an unauthenticated snapshot", () => {
const failure = grokAuthFailureFromAcpCause(
Cause.fail(EffectAcpErrors.AcpRequestError.authRequired()),
);
expect(failure?.auth).toEqual({ status: "unauthenticated" });
expect(failure?.message).toContain("not authenticated");
});

it("leaves unrelated ACP failures to the generic startup message", () => {
expect(
grokAuthFailureFromAcpCause(
Cause.fail(EffectAcpErrors.AcpRequestError.invalidParams("session id not known")),
),
).toBeUndefined();
expect(
grokAuthFailureFromAcpCause(
Cause.fail(new EffectAcpErrors.AcpSpawnError({ command: "grok", cause: "boom" })),
),
).toBeUndefined();
});
});

describe("applyGrokAcpModelSelection", () => {
const makeRecordingRuntime = (failure?: EffectAcpErrors.AcpError) => {
const modelCalls: Array<string> = [];
Expand Down
Loading
Loading