Compare commits

...

2 Commits

Author SHA1 Message Date
Marc
38841c71e0 fix: merge subagent auth profiles 2026-01-16 20:19:19 +00:00
Marc
ec1b430a2c fix: avoid format keyword in tool schemas
Co-authored-by: marcmarg <marcmarg@users.noreply.github.com>
2026-01-16 20:19:15 +00:00
9 changed files with 155 additions and 10 deletions

View File

@ -107,6 +107,7 @@
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause. - Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.
- Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed). - Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed).
- Tool schema guardrails (google-antigravity): avoid `Type.Union` in tool input schemas; no `anyOf`/`oneOf`/`allOf`. Use `stringEnum`/`optionalStringEnum` (Type.Unsafe enum) for string lists, and `Type.Optional(...)` instead of `... | null`. Keep top-level tool schema as `type: "object"` with `properties`. - Tool schema guardrails (google-antigravity): avoid `Type.Union` in tool input schemas; no `anyOf`/`oneOf`/`allOf`. Use `stringEnum`/`optionalStringEnum` (Type.Unsafe enum) for string lists, and `Type.Optional(...)` instead of `... | null`. Keep top-level tool schema as `type: "object"` with `properties`.
- Tool schema guardrails: avoid raw `format` property names in tool schemas; some validators treat `format` as a reserved keyword and reject the schema.
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/agents/main/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there. - When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/agents/main/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdbot variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item. - Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdbot variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac. - Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.

View File

@ -50,6 +50,8 @@
### Fixes ### Fixes
- WhatsApp: default response prefix only for self-chat, using identity name when set. - WhatsApp: default response prefix only for self-chat, using identity name when set.
- Auth: merge main auth profiles into per-agent stores for sub-agents and document inheritance. (#1013) — thanks @marcmarg.
- Agents: avoid JSON Schema `format` collisions in tool params by renaming snapshot format fields. (#1013) — thanks @marcmarg.
- Fix: make `clawdbot update` auto-update global installs when installed via a package manager. - Fix: make `clawdbot update` auto-update global installs when installed via a package manager.
- Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj. - Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.
- Fix: align OpenAI image-gen defaults with DALL-E 3 standard quality and document output formats. (#880) — thanks @mkbehr. - Fix: align OpenAI image-gen defaults with DALL-E 3 standard quality and document output formats. (#880) — thanks @mkbehr.

View File

@ -43,6 +43,15 @@ Auto-archive:
- Auto-archive is best-effort; pending timers are lost if the gateway restarts. - Auto-archive is best-effort; pending timers are lost if the gateway restarts.
- `runTimeoutSeconds` does **not** auto-archive; it only stops the run. The session remains until auto-archive. - `runTimeoutSeconds` does **not** auto-archive; it only stops the run. The session remains until auto-archive.
## Authentication
Sub-agent auth is resolved by **agent id**, not by session type:
- The sub-agent session key is `agent:<agentId>:subagent:<uuid>`.
- The auth store is loaded from that agents `agentDir`.
- The main agents auth profiles are merged in as a **fallback**; agent profiles override main profiles on conflicts.
Note: the merge is additive, so main profiles are always available as fallbacks. Fully isolated auth per agent is not supported yet.
## Announce ## Announce
Sub-agents report back via an announce step: Sub-agents report back via an announce step:

View File

@ -3,6 +3,7 @@ import os from "node:os";
import path from "node:path"; import path from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { ensureAuthProfileStore } from "./auth-profiles.js"; import { ensureAuthProfileStore } from "./auth-profiles.js";
import { AUTH_STORE_VERSION } from "./auth-profiles/constants.js";
describe("ensureAuthProfileStore", () => { describe("ensureAuthProfileStore", () => {
it("migrates legacy auth.json and deletes it (PR #368)", () => { it("migrates legacy auth.json and deletes it (PR #368)", () => {
@ -45,4 +46,80 @@ describe("ensureAuthProfileStore", () => {
fs.rmSync(agentDir, { recursive: true, force: true }); fs.rmSync(agentDir, { recursive: true, force: true });
} }
}); });
it("merges main auth profiles into agent store and keeps agent overrides", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-auth-merge-"));
const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR;
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
try {
const mainDir = path.join(root, "main-agent");
const agentDir = path.join(root, "agent-x");
fs.mkdirSync(mainDir, { recursive: true });
fs.mkdirSync(agentDir, { recursive: true });
process.env.CLAWDBOT_AGENT_DIR = mainDir;
process.env.PI_CODING_AGENT_DIR = mainDir;
const mainStore = {
version: AUTH_STORE_VERSION,
profiles: {
"openai:default": {
type: "api_key",
provider: "openai",
key: "main-key",
},
"anthropic:default": {
type: "api_key",
provider: "anthropic",
key: "main-anthropic-key",
},
},
};
fs.writeFileSync(
path.join(mainDir, "auth-profiles.json"),
`${JSON.stringify(mainStore, null, 2)}\n`,
"utf8",
);
const agentStore = {
version: AUTH_STORE_VERSION,
profiles: {
"openai:default": {
type: "api_key",
provider: "openai",
key: "agent-key",
},
},
};
fs.writeFileSync(
path.join(agentDir, "auth-profiles.json"),
`${JSON.stringify(agentStore, null, 2)}\n`,
"utf8",
);
const store = ensureAuthProfileStore(agentDir);
expect(store.profiles["anthropic:default"]).toMatchObject({
type: "api_key",
provider: "anthropic",
key: "main-anthropic-key",
});
expect(store.profiles["openai:default"]).toMatchObject({
type: "api_key",
provider: "openai",
key: "agent-key",
});
} finally {
if (previousAgentDir === undefined) {
delete process.env.CLAWDBOT_AGENT_DIR;
} else {
process.env.CLAWDBOT_AGENT_DIR = previousAgentDir;
}
if (previousPiAgentDir === undefined) {
delete process.env.PI_CODING_AGENT_DIR;
} else {
process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
}
fs.rmSync(root, { recursive: true, force: true });
}
});
}); });

View File

@ -111,6 +111,34 @@ function coerceAuthStore(raw: unknown): AuthProfileStore | null {
}; };
} }
function mergeRecord<T>(
base?: Record<string, T>,
override?: Record<string, T>,
): Record<string, T> | undefined {
if (!base && !override) return undefined;
if (!base) return { ...override };
if (!override) return { ...base };
return { ...base, ...override };
}
function mergeAuthProfileStores(base: AuthProfileStore, override: AuthProfileStore): AuthProfileStore {
if (
Object.keys(override.profiles).length === 0 &&
!override.order &&
!override.lastGood &&
!override.usageStats
) {
return base;
}
return {
version: Math.max(base.version, override.version ?? base.version),
profiles: { ...base.profiles, ...override.profiles },
order: mergeRecord(base.order, override.order),
lastGood: mergeRecord(base.lastGood, override.lastGood),
usageStats: mergeRecord(base.usageStats, override.usageStats),
};
}
function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean { function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean {
const oauthPath = resolveOAuthPath(); const oauthPath = resolveOAuthPath();
const oauthRaw = loadJsonFile(oauthPath); const oauthRaw = loadJsonFile(oauthPath);
@ -191,7 +219,7 @@ export function loadAuthProfileStore(): AuthProfileStore {
return store; return store;
} }
export function ensureAuthProfileStore( function loadAuthProfileStoreForAgent(
agentDir?: string, agentDir?: string,
options?: { allowKeychainPrompt?: boolean }, options?: { allowKeychainPrompt?: boolean },
): AuthProfileStore { ): AuthProfileStore {
@ -207,6 +235,19 @@ export function ensureAuthProfileStore(
return asStore; return asStore;
} }
// Fallback: inherit auth-profiles from main agent if subagent has none
if (agentDir) {
const mainAuthPath = resolveAuthStorePath(); // without agentDir = main
const mainRaw = loadJsonFile(mainAuthPath);
const mainStore = coerceAuthStore(mainRaw);
if (mainStore && Object.keys(mainStore.profiles).length > 0) {
// Clone main store to subagent directory for auth inheritance
saveJsonFile(authPath, mainStore);
log.info("inherited auth-profiles from main agent", { agentDir });
return mainStore;
}
}
const legacyRaw = loadJsonFile(resolveLegacyAuthStorePath(agentDir)); const legacyRaw = loadJsonFile(resolveLegacyAuthStorePath(agentDir));
const legacy = coerceLegacyStore(legacyRaw); const legacy = coerceLegacyStore(legacyRaw);
const store: AuthProfileStore = { const store: AuthProfileStore = {
@ -274,6 +315,21 @@ export function ensureAuthProfileStore(
return store; return store;
} }
export function ensureAuthProfileStore(
agentDir?: string,
options?: { allowKeychainPrompt?: boolean },
): AuthProfileStore {
const store = loadAuthProfileStoreForAgent(agentDir, options);
const authPath = resolveAuthStorePath(agentDir);
const mainAuthPath = resolveAuthStorePath();
if (!agentDir || authPath === mainAuthPath) {
return store;
}
const mainStore = loadAuthProfileStoreForAgent(undefined, options);
return mergeAuthProfileStores(mainStore, store);
}
export function saveAuthProfileStore(store: AuthProfileStore, agentDir?: string): void { export function saveAuthProfileStore(store: AuthProfileStore, agentDir?: string): void {
const authPath = resolveAuthStorePath(agentDir); const authPath = resolveAuthStorePath(agentDir);
const payload = { const payload = {

View File

@ -129,16 +129,16 @@ describe("createClawdbotCodingTools", () => {
expect(Array.isArray(action?.enum)).toBe(true); expect(Array.isArray(action?.enum)).toBe(true);
expect(action?.enum).toContain("act"); expect(action?.enum).toContain("act");
const format = parameters.properties?.format as const snapshotFormat = parameters.properties?.snapshotFormat as
| { | {
type?: unknown; type?: unknown;
enum?: unknown[]; enum?: unknown[];
anyOf?: unknown[]; anyOf?: unknown[];
} }
| undefined; | undefined;
expect(format?.type).toBe("string"); expect(snapshotFormat?.type).toBe("string");
expect(format?.anyOf).toBeUndefined(); expect(snapshotFormat?.anyOf).toBeUndefined();
expect(format?.enum).toEqual(["aria", "ai"]); expect(snapshotFormat?.enum).toEqual(["aria", "ai"]);
}); });
it("inlines local $ref before removing unsupported keywords", () => { it("inlines local $ref before removing unsupported keywords", () => {
const cleaned = __testing.cleanToolSchemaForGemini({ const cleaned = __testing.cleanToolSchemaForGemini({

View File

@ -91,7 +91,7 @@ export const BrowserToolSchema = Type.Object({
limit: Type.Optional(Type.Number()), limit: Type.Optional(Type.Number()),
maxChars: Type.Optional(Type.Number()), maxChars: Type.Optional(Type.Number()),
mode: optionalStringEnum(BROWSER_SNAPSHOT_MODES), mode: optionalStringEnum(BROWSER_SNAPSHOT_MODES),
format: optionalStringEnum(BROWSER_SNAPSHOT_FORMATS), snapshotFormat: optionalStringEnum(BROWSER_SNAPSHOT_FORMATS),
refs: optionalStringEnum(BROWSER_SNAPSHOT_REFS), refs: optionalStringEnum(BROWSER_SNAPSHOT_REFS),
interactive: Type.Optional(Type.Boolean()), interactive: Type.Optional(Type.Boolean()),
compact: Type.Optional(Type.Boolean()), compact: Type.Optional(Type.Boolean()),

View File

@ -191,8 +191,8 @@ export function createBrowserTool(opts?: {
} }
case "snapshot": { case "snapshot": {
const format = const format =
params.format === "ai" || params.format === "aria" params.snapshotFormat === "ai" || params.snapshotFormat === "aria"
? (params.format as "ai" | "aria") ? (params.snapshotFormat as "ai" | "aria")
: "ai"; : "ai";
const mode = params.mode === "efficient" ? "efficient" : undefined; const mode = params.mode === "efficient" ? "efficient" : undefined;
const labels = typeof params.labels === "boolean" ? params.labels : undefined; const labels = typeof params.labels === "boolean" ? params.labels : undefined;

View File

@ -40,7 +40,7 @@ const CanvasToolSchema = Type.Object({
// eval // eval
javaScript: Type.Optional(Type.String()), javaScript: Type.Optional(Type.String()),
// snapshot // snapshot
format: optionalStringEnum(CANVAS_SNAPSHOT_FORMATS), outputFormat: optionalStringEnum(CANVAS_SNAPSHOT_FORMATS),
maxWidth: Type.Optional(Type.Number()), maxWidth: Type.Optional(Type.Number()),
quality: Type.Optional(Type.Number()), quality: Type.Optional(Type.Number()),
delayMs: Type.Optional(Type.Number()), delayMs: Type.Optional(Type.Number()),
@ -127,7 +127,7 @@ export function createCanvasTool(): AnyAgentTool {
return jsonResult({ ok: true }); return jsonResult({ ok: true });
} }
case "snapshot": { case "snapshot": {
const formatRaw = typeof params.format === "string" ? params.format.toLowerCase() : "png"; const formatRaw = typeof params.outputFormat === "string" ? params.outputFormat.toLowerCase() : "png";
const format = formatRaw === "jpg" || formatRaw === "jpeg" ? "jpeg" : "png"; const format = formatRaw === "jpg" || formatRaw === "jpeg" ? "jpeg" : "png";
const maxWidth = const maxWidth =
typeof params.maxWidth === "number" && Number.isFinite(params.maxWidth) typeof params.maxWidth === "number" && Number.isFinite(params.maxWidth)