docs(agentic-teams): document the new resources, and make the webhook reachable

Five resources shipped with no documentation and one documented lie:
docs/inference-tasks.md still told people `mcpctl get tasks` lists the
inference queue. Work items own that name now.

Adds docs/epics.md (the Epic -> EpicTask -> Task hierarchy, the state machine,
risk-scaled review gates, the accept-merges gate, and reconciliation),
docs/gitproviders.md (forge registration, discovery/import, merge-on-accept,
post-merge reopen) and docs/agent-authority.md (the three run modes, and
plainly which two are not implemented).

Two things found while writing them, both fixed rather than filed:

- `webhookSecretKey` was read by the webhook route but writable from NOWHERE
  -- absent from the create schema, update schema, service, CLI and apply
  spec. Since the endpoint fails closed on a null key, post-merge CI failures
  could never reopen their task; the board would keep claiming `merged` for
  work that broke the build. Adds --webhook-secret-key, the schema fields and
  the apply key, with tests covering persist / omit / explicit-null /
  reject-empty. Omission deliberately does not clear the column, so an
  unrelated edit cannot silently disarm the webhook.

- src/cli/src/commands/create.ts contained a literal NUL byte inside a string
  literal (a binary-file check written with the raw character rather than an escape). Identical behaviour, but it made the whole file read as
  binary, so `grep -I` skipped it entirely and 1120 lines of CLI were
  invisible to every text search. Replaced with the escape.

The Epic write path (criteria/status/tradeoffs) is API-only -- documented as
such rather than papered over, and filed as task 180.

Full suite 2612 green; lint at baseline 794/77.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
This commit is contained in:
Michal
2026-08-16 00:14:58 +01:00
parent 2a85447fac
commit 6d38953a96
14 changed files with 587 additions and 6 deletions

View File

@@ -232,6 +232,10 @@ const GitProviderSpecSchema = z.object({
type: z.string().optional(),
description: z.string().optional(),
credRef: z.object({ name: z.string(), key: z.string() }).nullable().optional(),
// Key in the credential Secret holding the webhook signing secret. The
// webhook endpoint fails closed without it, so a provider that omits this
// silently never reopens tasks whose merge broke CI.
webhookSecretKey: z.string().nullable().optional(),
skipProbe: z.boolean().optional(),
});

View File

@@ -131,11 +131,13 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
.option('--description <text>', 'Description')
.option('--secret <name>', 'Secret holding the API token')
.option('--key <key>', 'Key within that secret', 'TOKEN')
.option('--webhook-secret-key <key>', 'Key in that secret holding the webhook signing secret; without it incoming webhooks are refused')
.option('--skip-probe', 'Skip the credential check at registration time')
.action(async (name: string, opts) => {
const body: Record<string, unknown> = { name, url: opts.url, type: opts.type };
if (opts.description !== undefined) body.description = opts.description;
if (opts.secret !== undefined) body.credRef = { name: opts.secret, key: opts.key };
if (opts.webhookSecretKey !== undefined) body.webhookSecretKey = opts.webhookSecretKey;
if (opts.skipProbe === true) body.skipProbe = true;
const created = await client.post<{ name: string; type: string; url: string }>('/api/v1/gitproviders', body);
log(`Created gitprovider '${created.name}' (${created.type} @ ${created.url})`);
@@ -1019,7 +1021,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
const buf = await fs.readFile(full);
// Reject non-UTF8 — v1 is text-only.
const text = buf.toString('utf-8');
if (text.includes('')) {
if (text.includes('\u0000')) {
throw new Error(`File ${rel} contains a null byte; binaries aren't supported in v1`);
}
files[rel] = text;

View File

@@ -86,6 +86,7 @@ export class GitProviderService {
ownerId,
type,
...(data.description !== undefined ? { description: data.description } : {}),
...(data.webhookSecretKey !== undefined ? { webhookSecretKey: data.webhookSecretKey } : {}),
...cred,
});
}
@@ -114,6 +115,7 @@ export class GitProviderService {
...(data.url !== undefined ? { url: data.url } : {}),
...(data.type !== undefined ? { type: data.type } : {}),
...(data.description !== undefined ? { description: data.description } : {}),
...(data.webhookSecretKey !== undefined ? { webhookSecretKey: data.webhookSecretKey } : {}),
...cred,
});
}

View File

@@ -11,12 +11,21 @@ const InstanceUrlSchema = z.string().url().refine(
{ message: 'url must be http:// or https://' },
);
/**
* Key within the provider's credential Secret holding the webhook signing
* secret. Without it the webhook endpoint refuses every delivery (it fails
* closed), so post-merge CI failures never reopen their task — leaving the
* board claiming `merged` for work that broke the build.
*/
const WebhookSecretKeySchema = z.string().min(1).max(200);
export const CreateGitProviderSchema = z.object({
name: z.string().min(1).max(100).regex(NAME_RE, 'Name must be lowercase alphanumeric with hyphens'),
url: InstanceUrlSchema,
type: z.enum(GIT_PROVIDER_TYPES).optional(),
description: z.string().max(500).optional(),
credRef: CredRefSchema.optional(),
webhookSecretKey: WebhookSecretKeySchema.optional(),
/** Skip the registration-time credential probe (wiring config before infra exists). */
skipProbe: z.boolean().optional(),
});
@@ -26,6 +35,9 @@ export const UpdateGitProviderSchema = z.object({
type: z.enum(GIT_PROVIDER_TYPES).optional(),
description: z.string().max(500).optional(),
credRef: CredRefSchema.nullable().optional(),
// Nullable so webhook verification can be turned back off without deleting
// the provider.
webhookSecretKey: WebhookSecretKeySchema.nullable().optional(),
skipProbe: z.boolean().optional(),
});

View File

@@ -441,3 +441,64 @@ describe('GiteaDriver — pull requests', () => {
await expect(new GiteaDriver().getPullRequest(CTX, REF)).rejects.toThrow(GitProviderAuthError);
});
});
describe('GitProviderService — webhook signing key', () => {
beforeEach(() => { vi.clearAllMocks(); });
/**
* `webhookSecretKey` existed on the schema and was read by the webhook route,
* but nothing could ever write it: it was absent from the create schema, the
* update schema, the service, the CLI and the apply spec. Since the endpoint
* fails closed on a null key, post-merge CI failures could never reopen their
* task — the board kept claiming `merged` for work that broke the build.
*
* These pin the write path that makes the feature reachable at all.
*/
it('persists the key given at registration', async () => {
const { service, providerRepo } = svc();
await service.create(
{
name: 'mysources',
url: 'https://mysources.co.uk',
credRef: { name: 'gitea-token', key: 'TOKEN' },
webhookSecretKey: 'WEBHOOK',
skipProbe: true,
},
'user-1',
);
expect(providerRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ webhookSecretKey: 'WEBHOOK' }),
);
});
it('leaves the column untouched when the flag is omitted', async () => {
// Omission must not be read as "clear it" — that would silently disarm the
// webhook on any unrelated edit.
const { service, providerRepo } = svc();
await service.update('gp-1', { description: 'renamed', skipProbe: true });
const patch = (providerRepo.update as ReturnType<typeof vi.fn>).mock.calls[0]?.[1] as Record<string, unknown>;
expect(patch).not.toHaveProperty('webhookSecretKey');
});
it('turns verification back off when explicitly nulled', async () => {
const { service, providerRepo } = svc();
await service.update('gp-1', { webhookSecretKey: null, skipProbe: true });
expect(providerRepo.update).toHaveBeenCalledWith(
'gp-1',
expect.objectContaining({ webhookSecretKey: null }),
);
});
it('rejects an empty key rather than storing a falsy signing secret', async () => {
const { service } = svc();
await expect(
service.create(
{ name: 'mysources', url: 'https://mysources.co.uk', webhookSecretKey: '', skipProbe: true },
'user-1',
),
).rejects.toThrow();
});
});