Compare commits

..
Author SHA1 Message Date
Garry TanandClaude Fable 5 7b8676be3e ci: raise unit-test matrix shard timeout 15 -> 20 min
Shard 4 runs ~14.5 min on master (dream.test.ts at ~29s/test dominates
its wallclock) and hit the 15-min job timeout twice on this PR with
1328 tests passing and 0 failing — the gate then failed on
'gated job did not succeed (got cancelled)'. Real fix is re-mining
scripts/test-weights.json to rebalance the shards; this unblocks CI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 10:59:25 -07:00
SinabinaandClaude Fable 5 b8376f7327 fix(doctor): stop treating ClawHub workspace skills as required gbrain-routable skills (#1767)
ClawHub-installed skills (detected via .clawhub/origin.json) are external
runtime integrations, not gbrain resolver skills. The derived manifest now
skips them, so doctor resolver_health no longer hard-fails with
unreachable/mece_gap on e.g. an email integration skill. A ClawHub skill
opts back into strict checking by declaring triggers: in its SKILL.md
frontmatter (the same surface that makes it routable); an explicit
manifest.json listing also keeps strict checks. Also keeps dry-fix from
rewriting externally-managed skill files.

Fixes #1767

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 14:27:57 -07:00
10 changed files with 133 additions and 148 deletions
+5 -1
View File
@@ -206,7 +206,11 @@ jobs:
needs: cache-check
if: needs.cache-check.outputs.hit != 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
# 20 (was 15): shard 4 runs ~14.5 min on master (dream.test.ts ~29s/test
# dominates it) and hits the 15-min ceiling on slower runners, cancelling
# mid-run with 0 test failures. Rebalancing via
# scripts/mine-shard-weights.ts is the real fix; this stops the bleeding.
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
+12 -42
View File
@@ -161,7 +161,7 @@ interface ResolveAIOptionsArgs {
nonInteractive: boolean; // --non-interactive (forces D3 fail-loud, no picker)
}
export interface ResolvedAIOptions {
interface ResolvedAIOptions {
embedding_model?: string;
embedding_dimensions?: number;
expansion_model?: string;
@@ -170,41 +170,6 @@ export interface ResolvedAIOptions {
noEmbedding?: boolean;
}
/**
* Seed init's AI options from persisted config, falling back to the raw env
* vars when loadConfig() returned null (#1058). On a cold install (no
* config.json AND no DATABASE_URL) loadConfig short-circuits BEFORE its env
* merge, so GBRAIN_EMBEDDING_MODEL / GBRAIN_EMBEDDING_DIMENSIONS /
* GBRAIN_EXPANSION_MODEL / GBRAIN_CHAT_MODEL were silently ignored by init
* and Tier-3 detection auto-picked by API key instead. Exported for unit
* tests (env injectable).
*/
export function seedAIOptionsFromConfig(
cfg: GBrainConfig | null,
env: NodeJS.ProcessEnv = process.env,
): ResolvedAIOptions {
const envDims = env.GBRAIN_EMBEDDING_DIMENSIONS
? parseInt(env.GBRAIN_EMBEDDING_DIMENSIONS, 10)
: NaN;
const seed = cfg ?? {
embedding_disabled: undefined,
embedding_model: env.GBRAIN_EMBEDDING_MODEL,
embedding_dimensions: Number.isFinite(envDims) ? envDims : undefined,
expansion_model: env.GBRAIN_EXPANSION_MODEL,
chat_model: env.GBRAIN_CHAT_MODEL,
};
const out: ResolvedAIOptions = {};
if (seed.embedding_disabled) {
out.noEmbedding = true;
} else if (seed.embedding_model) {
out.embedding_model = seed.embedding_model;
if (seed.embedding_dimensions) out.embedding_dimensions = seed.embedding_dimensions;
}
if (seed.expansion_model) out.expansion_model = seed.expansion_model;
if (seed.chat_model) out.chat_model = seed.chat_model;
return out;
}
/**
* Resolve AI provider options for `gbrain init`.
*
@@ -238,13 +203,18 @@ async function resolveAIOptions(opts: ResolveAIOptionsArgs): Promise<ResolvedAIO
// user already opted into deferred mode.
try {
const { loadConfig } = await import('../core/config.ts');
// #1058: loadConfig() returns null on a cold install (no config.json AND
// no DATABASE_URL) — before it ever reaches its env merge. The seed helper
// falls back to the same GBRAIN_* env vars directly in that case.
Object.assign(out, seedAIOptionsFromConfig(loadConfig()));
const cfg = loadConfig();
if (cfg?.embedding_disabled) {
out.noEmbedding = true;
} else if (cfg?.embedding_model) {
out.embedding_model = cfg.embedding_model;
if (cfg.embedding_dimensions) out.embedding_dimensions = cfg.embedding_dimensions;
}
if (cfg?.expansion_model) out.expansion_model = cfg.expansion_model;
if (cfg?.chat_model) out.chat_model = cfg.chat_model;
} catch {
// loadConfig threw — treat as first-time install, fall through to env
// detection.
// loadConfig throws when no brain configured — first-time install, fall
// through to env detection.
}
// --- Tier 1+2: explicit flags ---------------------------------------------
+3 -19
View File
@@ -332,15 +332,6 @@ export interface OperationContext {
* remote/untrusted (defense in depth in case the type is bypassed via cast).
*/
remote: boolean;
/**
* Transport marker for auth-less remote surfaces (#1061). The stdio MCP
* dispatch sets 'stdio' — it is deliberately `remote: true` (agent-facing,
* untrusted) but has no per-token auth (local pipe), so identity ops like
* whoami need a way to distinguish "known auth-less transport" from "a
* transport bug forgot to thread ctx.auth". Trust decisions MUST NOT key
* off this field — only `ctx.remote === false` grants trust.
*/
transport?: 'stdio';
/**
* Subagent runtime context (v0.16+). Set by the subagent tool dispatcher when
* dispatching an op as a tool call from an LLM loop. Used to enforce per-op
@@ -3722,10 +3713,9 @@ const whoami: Operation = {
'Introspect the calling identity. Returns one of three transport shapes: ' +
'{transport: "oauth", client_id, client_name, scopes, expires_at}, ' +
'{transport: "legacy", token_name, scopes, expires_at: null}, or ' +
'{transport: "local", scopes: []}, or {transport: "stdio", scopes: []} ' +
'for the auth-less stdio MCP pipe. Throws unknown_transport when the ' +
'context is ambiguous (remote=true without auth and no transport marker) ' +
'— fail-closed posture mirroring the v0.26.9 trust-boundary contract.',
'{transport: "local", scopes: []}. Throws unknown_transport when the ' +
'context is ambiguous (remote=true without auth) — fail-closed posture ' +
'mirroring the v0.26.9 trust-boundary contract.',
params: {},
scope: 'read',
handler: async (ctx) => {
@@ -3737,12 +3727,6 @@ const whoami: Operation = {
if (ctx.remote === false) {
return { transport: 'local', scopes: [] };
}
// #1061: stdio MCP is remote/untrusted by design but has no per-token
// auth (local pipe) — a known transport, not a bug. Report it instead of
// throwing. Empty scopes: nothing here may be used to gate anything.
if (!ctx.auth && ctx.transport === 'stdio') {
return { transport: 'stdio', scopes: [] };
}
if (!ctx.auth) {
throw new OperationError(
'unknown_transport',
+34 -1
View File
@@ -23,6 +23,15 @@
* hold conventions and shared rule files, not skills. Files like
* `_brain-filing-rules.md` live at the root and are not considered
* skills by either loader.
*
* ClawHub-installed workspace skills (#1767): a skill dir carrying
* `.clawhub/origin.json` is an externally-managed runtime integration
* (e.g. an email or catalog skill), not a gbrain-routable skill. The
* derive path SKIPS those so `gbrain doctor` resolver_health doesn't
* hard-fail on them — UNLESS the skill's SKILL.md frontmatter declares
* `triggers:`, which is the explicit opt-in to gbrain routing (and the
* same surface that makes it reachable). An explicit manifest.json that
* lists a ClawHub skill also keeps strict checking (verbatim path).
*/
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
@@ -60,9 +69,27 @@ function parseSkillName(skillMdPath: string): string | null {
}
}
/**
* Does the SKILL.md frontmatter declare a `triggers:` key? A ClawHub-
* installed skill that ships gbrain `triggers:` has explicitly opted in
* to gbrain routing and gets full resolver checks (#1767).
*/
function declaresTriggers(skillMdPath: string): boolean {
try {
const content = readFileSync(skillMdPath, 'utf-8');
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
if (!fmMatch) return false;
return /^triggers:/m.test(fmMatch[1]);
} catch {
return false;
}
}
/**
* Walk skillsDir, return every `<skillsDir>/<dir>/SKILL.md` as a
* ManifestEntry. Dotfile and underscore-prefixed dirs are skipped.
* ManifestEntry. Dotfile and underscore-prefixed dirs are skipped, as
* are ClawHub-installed external skills that haven't opted in to gbrain
* routing via `triggers:` frontmatter (#1767).
*/
function deriveManifest(skillsDir: string): ManifestEntry[] {
const out: ManifestEntry[] = [];
@@ -93,6 +120,12 @@ function deriveManifest(skillsDir: string): ManifestEntry[] {
const skillMd = join(subdirAbs, 'SKILL.md');
if (!existsSync(skillMd)) continue;
// ClawHub-installed external skill (#1767): skip unless it opts in
// to gbrain routing by declaring `triggers:` in its frontmatter.
if (existsSync(join(subdirAbs, '.clawhub', 'origin.json')) && !declaresTriggers(skillMd)) {
continue;
}
const frontmatterName = parseSkillName(skillMd);
const name = frontmatterName && frontmatterName !== '' ? frontmatterName : entry;
out.push({ name, path: `${entry}/SKILL.md` });
-7
View File
@@ -32,12 +32,6 @@ export interface DispatchOpts {
remote?: boolean;
/** Override the default stderr logger (e.g. CLI uses console.* directly). */
logger?: OperationContext['logger'];
/**
* #1061: transport marker for auth-less remote surfaces. The stdio MCP
* server passes 'stdio' so identity ops (whoami) can report the transport
* instead of throwing unknown_transport. Never used for trust decisions.
*/
transport?: OperationContext['transport'];
/**
* v0.28: per-token allow-list for the takes.holder field. Threaded by
* the HTTP/stdio transport from `access_tokens.permissions.takes_holders`.
@@ -209,7 +203,6 @@ export function buildOperationContext(
logger: opts.logger || stderrLogger,
dryRun: !!params.dry_run,
remote: opts.remote ?? true,
transport: opts.transport,
takesHoldersAllowList: opts.takesHoldersAllowList,
// v0.34 D4: sourceId is REQUIRED at the type level. Auto-fill 'default'
// for single-source brains and any caller who didn't resolve a sourceId.
-4
View File
@@ -42,10 +42,6 @@ export async function startMcpServer(engine: BrainEngine) {
// `gbrain call <op>` (sets remote=false in src/cli.ts).
return dispatchToolCall(engine, name, params, {
remote: true,
// #1061: mark the transport so whoami can report {transport: 'stdio'}
// instead of throwing unknown_transport. Trust posture unchanged —
// stdio stays remote/untrusted.
transport: 'stdio',
takesHoldersAllowList: ['world'],
// v0.31: source defaults to 'default' for stdio (no per-token scope).
// Operators who want a different source on stdio MCP should set
+29
View File
@@ -382,6 +382,35 @@ describe("DRY detection — checkResolvable", () => {
});
});
describe("#1767 — ClawHub workspace skills are not resolver-required", () => {
let dir: string;
afterEachCleanup(() => dir && rmSync(dir, { recursive: true, force: true }));
test("ClawHub skill without gbrain metadata produces no unreachable/mece_gap", () => {
dir = mkdtempSync(join(tmpdir(), "gbrain-clawhub-"));
// Native gbrain skill: routable via frontmatter triggers. No manifest.json
// (the OpenClaw derive path from the issue repro).
mkdirSync(join(dir, "query"), { recursive: true });
writeFileSync(
join(dir, "query", "SKILL.md"),
`---\nname: query\ndescription: test\ntriggers:\n - "what do we know"\n---\n\n# query\n`
);
// ClawHub-installed integration: no triggers, no resolver row.
mkdirSync(join(dir, "agentmail", ".clawhub"), { recursive: true });
writeFileSync(
join(dir, "agentmail", ".clawhub", "origin.json"),
JSON.stringify({ registry: "https://clawhub.ai", slug: "agentmail" })
);
writeFileSync(join(dir, "agentmail", "SKILL.md"), `---\nname: agentmail\ndescription: email integration\n---\n\n# agentmail\n`);
const report = checkResolvable(dir);
const agentmailIssues = report.issues.filter(i => i.skill === "agentmail");
expect(agentmailIssues).toEqual([]);
expect(report.ok).toBe(true);
expect(report.summary.total_skills).toBe(1);
});
});
describe("v0.22.4 regression — actual repo skills/ has 0 errors", () => {
test("repo skills/ pass check-resolvable cleanly (zero errors AND zero warnings)", () => {
// The v0.22.4 (Part A) contract was zero warnings AND zero errors.
+1 -45
View File
@@ -12,7 +12,7 @@
*/
import { describe, test, expect } from 'bun:test';
import { groupReadyByProvider, findEnvKeyTypos, seedAIOptionsFromConfig } from '../src/commands/init.ts';
import { groupReadyByProvider, findEnvKeyTypos } from '../src/commands/init.ts';
describe('groupReadyByProvider — embedding touchpoint', () => {
test('OPENAI_API_KEY alone → openai is ready', async () => {
@@ -149,47 +149,3 @@ describe('findEnvKeyTypos', () => {
expect(got.find(t => t.userSet === 'COMPLETELY_UNRELATED_KEY')).toBeUndefined();
});
});
describe('seedAIOptionsFromConfig — #1058 cold-install env fallback', () => {
test('null config (no config.json, no DATABASE_URL) falls back to GBRAIN_* env vars', () => {
const got = seedAIOptionsFromConfig(null, {
GBRAIN_EMBEDDING_MODEL: 'voyage:voyage-3-large',
GBRAIN_EMBEDDING_DIMENSIONS: '1024',
GBRAIN_EXPANSION_MODEL: 'openai:gpt-5-mini',
GBRAIN_CHAT_MODEL: 'anthropic:claude-sonnet-4-6',
});
expect(got.embedding_model).toBe('voyage:voyage-3-large');
expect(got.embedding_dimensions).toBe(1024);
expect(got.expansion_model).toBe('openai:gpt-5-mini');
expect(got.chat_model).toBe('anthropic:claude-sonnet-4-6');
});
test('null config + no env vars → empty seed (Tier-3 detection takes over)', () => {
const got = seedAIOptionsFromConfig(null, {});
expect(got).toEqual({});
});
test('persisted config wins (loadConfig already merged env when non-null)', () => {
const got = seedAIOptionsFromConfig(
{ engine: 'pglite', embedding_model: 'openai:text-embedding-3-small', embedding_dimensions: 1536 } as any,
{ GBRAIN_EMBEDDING_MODEL: 'voyage:voyage-3-large' },
);
expect(got.embedding_model).toBe('openai:text-embedding-3-small');
expect(got.embedding_dimensions).toBe(1536);
});
test('embedding_disabled sentinel honored on re-init', () => {
const got = seedAIOptionsFromConfig({ engine: 'pglite', embedding_disabled: true } as any, {});
expect(got.noEmbedding).toBe(true);
expect(got.embedding_model).toBeUndefined();
});
test('non-numeric GBRAIN_EMBEDDING_DIMENSIONS ignored, model still seeds', () => {
const got = seedAIOptionsFromConfig(null, {
GBRAIN_EMBEDDING_MODEL: 'voyage:voyage-3-large',
GBRAIN_EMBEDDING_DIMENSIONS: 'not-a-number',
});
expect(got.embedding_model).toBe('voyage:voyage-3-large');
expect(got.embedding_dimensions).toBeUndefined();
});
});
+49
View File
@@ -166,6 +166,55 @@ describe('loadOrDeriveManifest', () => {
expect(r.skills.map(s => s.name)).toEqual(['apple', 'mango', 'zebra']);
});
// #1767 — ClawHub-installed workspace skills are external integrations,
// not gbrain-routable skills. The derive path skips them unless they
// opt in via `triggers:` frontmatter.
it('skips ClawHub-origin skills without triggers frontmatter (#1767)', () => {
const dir = scratch();
writeSkill(dir, 'query', 'query');
writeSkill(dir, 'agentmail', 'agentmail');
mkdirSync(join(dir, 'agentmail', '.clawhub'), { recursive: true });
writeFileSync(
join(dir, 'agentmail', '.clawhub', 'origin.json'),
JSON.stringify({ registry: 'https://clawhub.ai', slug: 'agentmail' })
);
const r = loadOrDeriveManifest(dir);
expect(r.derived).toBe(true);
expect(r.skills.map(s => s.name)).toEqual(['query']);
});
it('includes ClawHub-origin skills that opt in via triggers frontmatter (#1767)', () => {
const dir = scratch();
writeSkill(dir, 'agentmail', 'agentmail');
mkdirSync(join(dir, 'agentmail', '.clawhub'), { recursive: true });
writeFileSync(
join(dir, 'agentmail', '.clawhub', 'origin.json'),
JSON.stringify({ registry: 'https://clawhub.ai', slug: 'agentmail' })
);
writeFileSync(
join(dir, 'agentmail', 'SKILL.md'),
`---\nname: agentmail\ndescription: test\ntriggers:\n - "send email"\n---\n\n# agentmail\n`
);
const r = loadOrDeriveManifest(dir);
expect(r.derived).toBe(true);
expect(r.skills.map(s => s.name)).toEqual(['agentmail']);
});
it('keeps ClawHub-origin skills listed in an explicit manifest.json (#1767)', () => {
// Explicit manifest.json is a deliberate declaration — strict checking stays.
const dir = scratch();
writeSkill(dir, 'agentmail', 'agentmail');
mkdirSync(join(dir, 'agentmail', '.clawhub'), { recursive: true });
writeFileSync(
join(dir, 'agentmail', '.clawhub', 'origin.json'),
JSON.stringify({ registry: 'https://clawhub.ai', slug: 'agentmail' })
);
writeManifest(dir, { skills: [{ name: 'agentmail', path: 'agentmail/SKILL.md' }] });
const r = loadOrDeriveManifest(dir);
expect(r.derived).toBe(false);
expect(r.skills.map(s => s.name)).toEqual(['agentmail']);
});
it('treats dirs without SKILL.md as not-a-skill', () => {
const dir = scratch();
writeSkill(dir, 'query', 'query');
-29
View File
@@ -94,35 +94,6 @@ describe('whoami op contract', () => {
expect(result.expires_at).toBeNull();
});
// #1061: stdio MCP is remote/untrusted by design but has no per-token auth
// (local pipe). The stdio dispatch marks ctx.transport='stdio'; whoami
// reports it instead of throwing unknown_transport.
test('stdio transport (remote=true, no auth, transport marker) reports stdio', async () => {
const result = (await whoami.handler(
ctxWith({ remote: true, auth: undefined, transport: 'stdio' }),
{},
)) as any;
expect(result.transport).toBe('stdio');
expect(result.scopes).toEqual([]);
});
test('stdio marker does not mask real auth (auth still wins)', async () => {
const result = (await whoami.handler(
ctxWith({
remote: true,
transport: 'stdio',
auth: {
token: 'gbrain_at_xxx',
clientId: 'gbrain_cl_abc',
scopes: ['read'],
expiresAt: 1,
} as AuthInfo,
}),
{},
)) as any;
expect(result.transport).toBe('oauth');
});
// Q3: ambiguous transport — fail-closed. The footgun this guards against
// is a future transport that lands without threading auth, where a buggy
// caller might trust whoami's output to gate sensitive ops.