Skip to content
Merged
33 changes: 33 additions & 0 deletions server/drivers/acp/acp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,39 @@ describe("ACP turns (fake CLI)", () => {
expect(done).toMatchObject({ ok: true });
});

it("applyTurnEnv sees the picker model after resolveTurnModel", async () => {
const dump = join(scratch, "turn-env.json");
process.env.FAKE_ACP_DUMP = dump;
const TurnEnvDriver = createAcpDriver({
...SELECT_MODEL_SUPPORT,
driverKind: "turnEnvTest",
selectModel: undefined,
resolveTurnModel: (model) => (model ? `resolved/${model}` : model),
applyTurnEnv: (env, { model, requestedModel }) => {
env.TEST_TURN_MODEL = `${model ?? ""}|${requestedModel ?? ""}`;
},
});
instance = await TurnEnvDriver.create({
instanceId: "turn-env-test",
displayName: undefined,
environment: {},
enabled: true,
config: { cli: FAKE_CLI, fullAuto: false },
});
recorder = recordEvents(instance.adapter);

await instance.adapter.sendTurn({
threadId: "t-turn-env",
text: "go",
model: "ollama::ornith:35b-bf16",
});
await recorder.until((e) => e.type === "turn.completed");

expect(JSON.parse(readFileSync(dump, "utf8")).env.TEST_TURN_MODEL).toBe(
"resolved/ollama::ornith:35b-bf16|ollama::ornith:35b-bf16",
);
});

it("transformEnv sees the instance config", async () => {
const dump = join(scratch, "policy.json");
process.env.FAKE_ACP_DUMP = dump;
Expand Down
7 changes: 7 additions & 0 deletions server/drivers/acp/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,12 @@ export interface AcpSupport {
/** Mutate the child env in place: strip a key, inject a policy. Receives the
* instance config so a support can vary with fullAuto. */
transformEnv?(env: Record<string, string | undefined>, config: AcpConfig): void;
/** Mutate the child env after the turn model is known. Catalog refresh and
* snapshot share `transformEnv` and must not see a per-turn overlay. */
applyTurnEnv?(
env: Record<string, string | undefined>,
ctx: { model?: string; requestedModel?: string },
): void;
/** Pick the ACP authenticate methodId from initialize's advertised
* authMethods; return null to skip the authenticate step. */
pickAuthMethod(authMethods: Array<{ id?: string }>): string | null;
Expand Down Expand Up @@ -278,6 +284,7 @@ export function createAcpDriver(support: AcpSupport): ProviderDriver<AcpConfig>
const cwd = turn.cwd ?? config.workspace ?? homedir();
const env = childEnv();
const resolvedModel = support.resolveTurnModel?.(turn.model, env);
support.applyTurnEnv?.(env, { model: resolvedModel, requestedModel: turn.model });
const cliTurn =
resolvedModel !== undefined && resolvedModel !== turn.model
? { ...turn, model: resolvedModel }
Expand Down
21 changes: 21 additions & 0 deletions server/drivers/acp/droid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,24 @@ export function ensureDroidInjectModel(
return id;
}

/** ACP `session/new` throws "Authentication required" unless a Factory
* login or FACTORY_API_KEY is present — even for a BYOK custom model.
* Droid 0.198 only checks that the env var is set, then uses the
* custom row's own key for the local host. Do not invent a key for
* subscription models, and do not overwrite a real Factory key. */
export function applyDroidLocalAuthEnv(
env: Record<string, string | undefined>,
modelId: string | undefined,
): void {
if (!decodeInjectId(modelId)) return;
if (env.FACTORY_API_KEY?.trim()) return;
// session/new already succeeds on a Factory login file. A placeholder
// FACTORY_API_KEY can take precedence over that login, so leave env
// alone when one of the auth files is present.
if (authFilePaths(env).some(existsSync)) return;
env.FACTORY_API_KEY = "openmausbot-local";
}

function readSettings(env: Record<string, string | undefined>): FactorySettings {
return JSON.parse(readFileSync(join(factoryHome(env), ".factory", "settings.json"), "utf8")) as FactorySettings;
}
Expand Down Expand Up @@ -233,6 +251,9 @@ const support: AcpSupport = {
isAuthenticated: (env) => authFilePaths(env).some(existsSync) || Boolean(env.FACTORY_API_KEY),
resolveModels,
resolveTurnModel: (model, env) => (model ? ensureDroidInjectModel(model, env) : model),
applyTurnEnv: (env, { requestedModel }) => {
applyDroidLocalAuthEnv(env, requestedModel);
},

async configureSession({ request, sessionId, config, turn }) {
const modeId = config.fullAuto ? MODE_FULL_AUTO : MODE_DEFAULT;
Expand Down
Loading
Loading