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
42 changes: 42 additions & 0 deletions .agents/docs/agent-adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,48 @@ most often forgotten.
> (single managed model) or `commandcode/` (multi-model + npm install/update + a
> synthesized terminal Login method).

## Giving a Provider Multiple Profiles

A **profile** is a second account or configuration of a provider that already
exists as a built-in agent (a second Claude account, a Cursor key for another
org). Each profile is an `AgentInstanceConfig` whose `driver` is the provider
id; it surfaces as the instance-scoped agent kind `<driver>:<instanceId>` and
gets its own adapter, sidebar entry, model-picker group, and settings page.

Everything generic already handles profiles — the sealing settings writer, the
`createProfile` / `setProfileEnvironment` IPC, the sidebar nesting, the instance
badge on the provider icon, the profile list UI, and `removeAgentInstance`'s
cleanup of profile-scoped settings. Adding profiles to a provider is four
declarations, none of which is a new branch in shared code:

- [ ] **Register the driver** — add `{ driver: "<id>" }` (plus
`credentialEnvVar` when the provider authenticates with a single secret)
to `AGENT_PROFILE_DRIVERS` in `src/shared/contracts/agentProfiles.ts`.
This is what makes shared code treat `<id>:<instance>` kinds as profiles.
- [ ] **Supervisor adapter factory** — export `create<Provider>ProfileAdapter(instance)`
from the provider's module and add it to `profileAdapterFactories` in
`src/supervisor/agents/registry.ts`. Build it from your normal adapter
factory with an overridden `kind`/`label` and the profile's credential in
`baseSpawnEnv`, so detection probes and every launch lane use that
credential (see `cursor/index.ts`). Throw when the profile is unusable:
the registry skips it with a warning instead of failing.
- [ ] **Profile descriptor** — set `profiles: <provider>ProfileSupport` on the
provider's `NATIVE_AGENT_REGISTRY_ENTRIES` entry. The descriptor supplies
only what differs: the one extra add-form field, the row subtitle
component, the removal-consequence copy, and `createPayload`. Optional
`onCreated` pins provider settings that must exist before the first
detection pass (Cursor pins its GUI runtime there).
- [ ] **Profile page** — the provider's `settingsPanel` already receives
instance-scoped kinds; branch on your own
`extract<Provider>ProfileInstanceId(agentKind)` to render the per-profile
editor instead of the base page.

Do NOT add per-provider profile branches to `mergeManagedSharedSettings`,
`ProviderIcon`, `SettingsSidebar`, `SingleAgentSettings`, or the IPC surface —
they are all driven by the registry above. Reference implementations:
`cursor` (single sealed credential) and `claude` (free-form environment plus an
opaque per-profile `config`).

## Plugin Architecture

The codebase is provider-agnostic by design (targeting 5-10 providers). Each provider is a fully self-contained plugin:
Expand Down
53 changes: 29 additions & 24 deletions src/main/ipc/localHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ import {
} from "../profile";
import {
applyAgentSecretSetting,
applyClaudeProfileEnvironment,
applyCreateProfile,
applyProfileEnvironment,
mergeManagedSharedSettings,
readSharedSettingsFile,
writeSharedSettingsFile,
Expand Down Expand Up @@ -202,6 +203,17 @@ export function createLocalIpcHandlers(
...(viewedThreadIds.length > 0 ? { viewedThreadIds: [...new Set(viewedThreadIds)] } : {}),
});
};
// Shared plumbing for the main-local secret/profile handlers: read the
// settings file, apply one encrypting transform, persist, and notify.
const applyToSharedSettingsFile = <T>(
apply: (settings: SharedSettings, baseDir: string) => { settings: SharedSettings; result: T },
): T => {
const settingsPath = options.requirePoracodePaths().settingsPath;
const applied = apply(readSharedSettingsFile(settingsPath), dirname(settingsPath));
writeSharedSettingsFile(settingsPath, applied.settings);
options.onSharedSettingsChanged?.(applied.settings);
return applied.result;
};
return defineMainLocalIpcHandlers({
pickFolder: async (defaultPath) => {
const result = await dialog.showOpenDialog(options.getMainWindow()!, {
Expand Down Expand Up @@ -383,7 +395,7 @@ export function createLocalIpcHandlers(
getSharedSettings: () => readSharedSettingsFile(options.requirePoracodePaths().settingsPath),
setSharedSettings: (settings) => {
const settingsPath = options.requirePoracodePaths().settingsPath;
// Preserve supervisor-managed fields and encrypted Claude-profile
// Preserve supervisor-managed fields and encrypted provider-profile
// environments so the renderer's persist cycle doesn't clobber writes
// made out-of-band by the supervisor. (Shared with the app-controls MCP
// `update_settings` tool via `mergeManagedSharedSettings`.)
Expand All @@ -392,17 +404,11 @@ export function createLocalIpcHandlers(
options.updatePowerSaveBlocker();
options.onSharedSettingsChanged?.(merged);
},
setAgentSecretSetting: (payload) => {
const settingsPath = options.requirePoracodePaths().settingsPath;
const { settings, storedValue } = applyAgentSecretSetting(
readSharedSettingsFile(settingsPath),
payload,
dirname(settingsPath),
);
writeSharedSettingsFile(settingsPath, settings);
options.onSharedSettingsChanged?.(settings);
return { storedValue };
},
setAgentSecretSetting: (payload) =>
applyToSharedSettingsFile((settings, baseDir) => {
const { settings: next, storedValue } = applyAgentSecretSetting(settings, payload, baseDir);
return { settings: next, result: { storedValue } };
}),
removeCrossagentRoutingOverride: ({ tags }) => {
const settingsPath = options.requirePoracodePaths().settingsPath;
const current = readSharedSettingsFile(settingsPath);
Expand Down Expand Up @@ -434,17 +440,16 @@ export function createLocalIpcHandlers(
options.onSharedSettingsChanged?.(settings);
return usage;
},
setClaudeProfileEnvironment: (payload) => {
const settingsPath = options.requirePoracodePaths().settingsPath;
const { settings, instance } = applyClaudeProfileEnvironment(
readSharedSettingsFile(settingsPath),
payload,
dirname(settingsPath),
);
writeSharedSettingsFile(settingsPath, settings);
options.onSharedSettingsChanged?.(settings);
return instance;
},
setProfileEnvironment: (payload) =>
applyToSharedSettingsFile((settings, baseDir) => {
const { settings: next, instance } = applyProfileEnvironment(settings, payload, baseDir);
return { settings: next, result: instance };
}),
createProfile: (payload) =>
applyToSharedSettingsFile((settings, baseDir) => {
const { settings: next, instance } = applyCreateProfile(settings, payload, baseDir);
return { settings: next, result: instance };
}),
setWindowChrome: async (payload: WindowChromePayload): Promise<WindowChromeResult> => {
const nativeCapable = supportsNativeWindowMaterial();
const mainWindow = options.getMainWindow();
Expand Down
163 changes: 151 additions & 12 deletions src/main/sharedSettingsFile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import {
} from "@/shared/settings";
import {
applyAgentSecretSetting,
applyClaudeProfileEnvironment,
applyCreateProfile,
applyProfileEnvironment,
mergeManagedSharedSettings,
patchSharedSettingsFile,
readSharedSettingsFile,
Expand Down Expand Up @@ -624,7 +625,149 @@ describe("applyAgentSecretSetting", () => {
});
});

describe("applyClaudeProfileEnvironment", () => {
describe("applyProfileEnvironment (single-credential provider)", () => {
function cursorProfileSettings(): SharedSettings {
return {
...defaultSharedSettings,
agentInstances: {
work: { id: "work", driver: "cursor", displayName: "Work" },
},
};
}

it("seals a Cursor profile key and pins it during renderer writes", () => {
const dir = makeTempDir();
const saved = applyProfileEnvironment(
cursorProfileSettings(),
{
instanceId: "work",
environment: { CURSOR_API_KEY: { value: "profile-secret", sensitive: false } },
},
dir,
);
const stored = saved.instance.environment?.CURSOR_API_KEY?.value ?? "";

expect(isEncryptedSecret(stored)).toBe(true);
expect(decryptSecret(dir, stored)).toBe("profile-secret");
expect(JSON.stringify(saved.settings)).not.toContain("profile-secret");

const merged = mergeManagedSharedSettings(saved.settings, {
...saved.settings,
agentInstances: {
work: { id: "work", driver: "cursor", displayName: "Renamed" },
},
});
expect(merged.agentInstances.work?.displayName).toBe("Renamed");
expect(merged.agentInstances.work?.environment?.CURSOR_API_KEY?.value).toBe(stored);
});

it("rejects environment variables outside a single-credential profile's declaration", () => {
expect(() =>
applyProfileEnvironment(
cursorProfileSettings(),
{ instanceId: "work", environment: { NODE_OPTIONS: { value: "--inspect" } } },
makeTempDir(),
),
).toThrow("only support CURSOR_API_KEY");
});

it("rejects instances whose driver does not support profiles", () => {
expect(() =>
applyProfileEnvironment(
{
...defaultSharedSettings,
agentInstances: {
gadget: { id: "gadget", driver: "acp-generic", displayName: "Gadget" },
},
},
{ instanceId: "gadget", environment: { CURSOR_API_KEY: { value: "secret" } } },
makeTempDir(),
),
).toThrow("Agent profile not found");
});

it("rejects a missing instance", () => {
expect(() =>
applyProfileEnvironment(
{ ...defaultSharedSettings, agentInstances: {} },
{ instanceId: "missing", environment: {} },
makeTempDir(),
),
).toThrow("Agent profile not found");
});

it("drops the stored key when the payload key is empty", () => {
const settings = cursorProfileSettings();
settings.agentInstances.work!.environment = {
CURSOR_API_KEY: { value: "lc-safe:encrypted", sensitive: true },
};

const cleared = applyProfileEnvironment(
settings,
{ instanceId: "work", environment: { CURSOR_API_KEY: { value: "", sensitive: true } } },
".",
);

expect(cleared.instance.environment).toBeUndefined();
expect(cleared.settings.agentInstances.work?.environment).toBeUndefined();
expect(cleared.settings.agentInstances.work?.displayName).toBe("Work");
});
});

describe("applyCreateProfile", () => {
it("creates the profile and seals its key in one settings result", () => {
const dir = makeTempDir();
const created = applyCreateProfile(
defaultSharedSettings,
{
driver: "cursor",
id: "work",
displayName: " Work ",
environment: { CURSOR_API_KEY: { value: " profile-secret " } },
},
dir,
);
const stored = created.instance.environment?.CURSOR_API_KEY?.value ?? "";

expect(created.settings.agentInstances.work).toBe(created.instance);
expect(created.instance.displayName).toBe("Work");
expect(decryptSecret(dir, stored)).toBe(" profile-secret ");
expect(JSON.stringify(created.settings)).not.toContain(" profile-secret ");
});

it("creates a config-only profile for a provider with no credential", () => {
const created = applyCreateProfile(
defaultSharedSettings,
{ driver: "claude", id: "work", displayName: "Work", config: { configDir: "~/x" } },
makeTempDir(),
);

expect(created.instance.config).toEqual({ configDir: "~/x" });
expect(created.instance.environment).toBeUndefined();
});

it("does not mutate settings when creation validation fails", () => {
const settings = { ...defaultSharedSettings, agentInstances: {} };

expect(() =>
applyCreateProfile(
settings,
{ driver: "cursor", id: "work", displayName: " " },
makeTempDir(),
),
).toThrow("require a name");
expect(() =>
applyCreateProfile(
settings,
{ driver: "acp-generic", id: "gadget", displayName: "Gadget" },
makeTempDir(),
),
).toThrow("does not support profiles");
expect(settings.agentInstances).toEqual({});
});
});

describe("applyProfileEnvironment (free-form environment provider)", () => {
function claudeProfileSettings(environment?: AgentInstanceConfig["environment"]): SharedSettings {
const instance: AgentInstanceConfig = {
id: "glm",
Expand All @@ -637,7 +780,7 @@ describe("applyClaudeProfileEnvironment", () => {
}

it("seals sensitive values and stores non-sensitive ones as plaintext", () => {
const { settings, instance } = applyClaudeProfileEnvironment(
const { settings, instance } = applyProfileEnvironment(
claudeProfileSettings(),
{
instanceId: "glm",
Expand All @@ -662,14 +805,14 @@ describe("applyClaudeProfileEnvironment", () => {

it("round-trips an already-sealed secret without re-sealing it", () => {
const dir = makeTempDir();
const first = applyClaudeProfileEnvironment(
const first = applyProfileEnvironment(
claudeProfileSettings(),
{ instanceId: "glm", environment: { TOKEN: { value: "plain", sensitive: true } } },
dir,
);
const sealed = first.instance.environment?.TOKEN?.value ?? "";

const second = applyClaudeProfileEnvironment(
const second = applyProfileEnvironment(
claudeProfileSettings(),
{ instanceId: "glm", environment: { TOKEN: { value: sealed, sensitive: true } } },
dir,
Expand All @@ -678,7 +821,7 @@ describe("applyClaudeProfileEnvironment", () => {
});

it("drops empty values and removes the environment field when all are empty", () => {
const { instance } = applyClaudeProfileEnvironment(
const { instance } = applyProfileEnvironment(
claudeProfileSettings({ OLD: { value: "x" } }),
{ instanceId: "glm", environment: { OLD: { value: "" }, "": { value: "ignored" } } },
makeTempDir(),
Expand All @@ -688,7 +831,7 @@ describe("applyClaudeProfileEnvironment", () => {

it("throws for a missing instance or a non-Claude driver", () => {
expect(() =>
applyClaudeProfileEnvironment(
applyProfileEnvironment(
claudeProfileSettings(),
{ instanceId: "nope", environment: {} },
makeTempDir(),
Expand All @@ -702,11 +845,7 @@ describe("applyClaudeProfileEnvironment", () => {
},
};
expect(() =>
applyClaudeProfileEnvironment(
acpSettings,
{ instanceId: "droid", environment: {} },
makeTempDir(),
),
applyProfileEnvironment(acpSettings, { instanceId: "droid", environment: {} }, makeTempDir()),
).toThrow(/not found/i);
});
});
Loading