Merge origin/main into feat/agentic-teams

124 commits of main against 33 of agentic-teams. 22 files overlapped, 7
conflicted. Three needed real reconciliation rather than a side-pick:

**The `volumes` collision.** Both branches independently added a `volumes`
field to ContainerSpec meaning different things: main's is persistent storage
keyed by a server-derived `claimName` that deliberately outlives an instance;
mine is emptyDir scratch shared between an init container and the main
container. Worse, each emitted the pod-level `volumes` list from its own
helper, so spreading both would have silently kept only the last — a server
with a PVC would have lost it the moment a session scratch volume appeared.

Resolved by renaming the unreleased side: mine is now `scratchVolumes`, main's
keeps `volumes` since it is already shipped and in use. `buildPodVolumes` now
owns the whole pod-level list and emits both kinds; `buildPodExtras` is init
containers only. fsGroup/securityContext stays tied to the PVC case, because
an emptyDir is created writable by the container's own user and chowning it
is work with no purpose. Five tests pin the coexistence so a later change
cannot drop one kind again.

**The extracted permission mapper.** I moved `mapUrlToPermission` out of
main.ts into middleware/url-permission.ts so it could be tested; main kept it
inline and kept editing it. Kept the extraction and ported main's one change
across — the `/api/v1/audit/events/purge` mapping onto the existing
`audit-purge` operation. Verified rule-by-rule that the extracted version is a
superset: all 15 of main's rules plus the 5 agentic-teams ones.

**The NUL byte in create.ts.** Both branches fixed the same defect
independently, mine as `''` and main's as `'\0'`. Took main's.

The rest were one-liners: .gitignore comment wording, and an add/add on
proxy-long-running.test.ts where main's copy carries a progressive-delivery
test mine lacked — took main's in full.

Not changed, but worth flagging: main's generateDeploymentSpec hardcodes
`automountServiceAccountToken: false` while generatePodSpec honours
`spec.automountServiceAccountToken`. That asymmetry is main's own and predates
this merge, so it is left exactly as-is rather than altered inside a merge
commit.

Verified: 3138 tests pass (237 files), build clean, prisma schema valid.
Lint 809/80 vs 794/77 pre-merge — the delta is main's own debt; every file
touched during conflict resolution was checked and introduces no new errors.

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-09-06 23:40:04 +01:00
223 changed files with 20890 additions and 640 deletions

View File

@@ -19,6 +19,11 @@ source "$SCRIPT_DIR/arch-helper.sh"
resolve_arch "${MCPCTL_TARGET_ARCH:-}"
# Sets: NFPM_ARCH, BUN_TARGET, ARCH_SUFFIX
# Same guard as build-rpm.sh: this script is also run on its own, so it cannot
# rely on that one having checked.
source "$SCRIPT_DIR/check-main-sync.sh"
check_main_sync
# Check and install missing build dependencies
source "$SCRIPT_DIR/ensure-deps.sh"
ensure_build_deps

36
scripts/build-gitea-mcp.sh Executable file
View File

@@ -0,0 +1,36 @@
#!/bin/bash
# Build gitea-mcp Docker image and push to Gitea container registry
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
cd "$PROJECT_ROOT"
# Load .env for GITEA_TOKEN
if [ -f .env ]; then
set -a; source .env; set +a
fi
# Push directly to internal address (external proxy has body size limit)
REGISTRY="10.0.0.194:3012"
IMAGE="gitea-mcp"
TAG="${1:-latest}"
echo "==> Building gitea-mcp image..."
podman build -t "$IMAGE:$TAG" -f deploy/Dockerfile.gitea-mcp .
echo "==> Tagging as $REGISTRY/michal/$IMAGE:$TAG..."
podman tag "$IMAGE:$TAG" "$REGISTRY/michal/$IMAGE:$TAG"
echo "==> Logging in to $REGISTRY..."
podman login --tls-verify=false -u michal -p "$GITEA_TOKEN" "$REGISTRY"
echo "==> Pushing to $REGISTRY/michal/$IMAGE:$TAG..."
podman push --tls-verify=false "$REGISTRY/michal/$IMAGE:$TAG"
# Ensure package is linked to the repository
source "$SCRIPT_DIR/link-package.sh"
link_package "container" "$IMAGE"
echo "==> Done!"
echo " Image: $REGISTRY/michal/$IMAGE:$TAG"

View File

@@ -16,6 +16,11 @@ if [ -f .env ]; then
set -a; source .env; set +a
fi
# This pushes an image to the registry, so the same staleness gate as the package
# builds applies. Run standalone as well as from deploy-k8s.sh, hence its own copy.
source "$SCRIPT_DIR/check-main-sync.sh"
check_main_sync
# Push directly to internal address (external proxy has body size limit)
REGISTRY="10.0.0.194:3012"
IMAGE="mcpd"

View File

@@ -19,6 +19,11 @@ source "$SCRIPT_DIR/arch-helper.sh"
resolve_arch "${MCPCTL_TARGET_ARCH:-}"
# Sets: NFPM_ARCH, BUN_TARGET, ARCH_SUFFIX
# Before anything expensive: a branch behind main packages a binary missing
# whatever landed there, and installing it silently downgrades the machine.
source "$SCRIPT_DIR/check-main-sync.sh"
check_main_sync
# Check and install missing build dependencies
source "$SCRIPT_DIR/ensure-deps.sh"
ensure_build_deps

101
scripts/check-main-sync.sh Executable file
View File

@@ -0,0 +1,101 @@
#!/bin/bash
# Refuse to build a package from a branch that main has already moved past.
#
# WHY
#
# Everyone branches off main and builds from their own branch. A branch that is
# behind main still builds and installs perfectly — it just quietly ships a
# binary missing whatever landed on main in the meantime, and `rpm -U --force`
# overwrites the good one with it.
#
# That is not hypothetical: on 2026-08-10 a build from a stale checkout replaced
# /usr/bin/mcpctl with one that had no `statusline` command at all, months after
# the status line landed on main. Nothing reported an error — the release
# succeeded, the feature just vanished from the installed CLI.
#
# So this is a hard failure rather than a warning. A warning scrolls past in a
# build log; the whole point is to stop before the artifact exists.
#
# ESCAPE HATCH
#
# MCPCTL_ALLOW_BEHIND_MAIN=1 build anyway (deliberate build of an old tree)
# MCPCTL_BASE_BRANCH=<name> compare against something other than main
#
# Skips itself entirely outside a git checkout, so tarball builds still work.
# Resolve the ref to compare against, echoing it on stdout. Prefers a fresh
# fetch; falls back to whatever is already on disk so an offline build is
# degraded rather than blocked. Returns 1 when there is nothing to compare to.
_main_sync_ref() {
local base="$1" remote="$2"
if [ -n "$remote" ] && git fetch --quiet "$remote" "$base" 2>/dev/null; then
# FETCH_HEAD rather than refs/remotes/<remote>/<base>: it is what this fetch
# just wrote, so it cannot be a stale opportunistic update.
echo "FETCH_HEAD"
return 0
fi
if [ -n "$remote" ] && git rev-parse --verify --quiet "refs/remotes/$remote/$base" >/dev/null; then
echo " (could not reach $remote — comparing against the last fetched $remote/$base)" >&2
echo "refs/remotes/$remote/$base"
return 0
fi
if git rev-parse --verify --quiet "refs/heads/$base" >/dev/null; then
echo " (no reachable remote — comparing against local $base)" >&2
echo "refs/heads/$base"
return 0
fi
return 1
}
check_main_sync() {
local base="${MCPCTL_BASE_BRANCH:-main}"
if ! git rev-parse --git-dir >/dev/null 2>&1; then
return 0 # not a checkout; nothing to be behind
fi
if [ "${MCPCTL_ALLOW_BEHIND_MAIN:-}" = "1" ]; then
echo "==> Skipping the '$base' sync check (MCPCTL_ALLOW_BEHIND_MAIN=1)"
return 0
fi
echo "==> Checking this branch is not behind '$base'..."
local remote ref
remote="$(git remote | head -1)"
if ! ref="$(_main_sync_ref "$base" "$remote")"; then
echo " (no '$base' branch found anywhere — skipping)"
return 0
fi
local behind
behind="$(git rev-list --count "HEAD..$ref" 2>/dev/null || echo 0)"
if [ "$behind" -eq 0 ]; then
echo " up to date with $base"
return 0
fi
local branch
branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD)"
echo "" >&2
echo "ERROR: '$branch' is $behind commit(s) behind $base — refusing to build." >&2
echo "" >&2
# Deliberately artifact-agnostic: the same helper gates RPM/DEB packages, the
# mcpd image, and the k8s deploy.
echo " Building now would produce an artifact without these, and shipping it" >&2
echo " would replace a good one with a version missing them:" >&2
echo "" >&2
git log --oneline --no-decorate "HEAD..$ref" | head -15 | sed 's/^/ /' >&2
if [ "$behind" -gt 15 ]; then
echo " … and $((behind - 15)) more" >&2
fi
echo "" >&2
echo " Fix it: git merge $base # or: git rebase $base" >&2
echo " Anyway: MCPCTL_ALLOW_BEHIND_MAIN=1 $0" >&2
echo "" >&2
return 1
}

View File

@@ -0,0 +1,101 @@
#!/usr/bin/env node
/**
* Sweep per-run smoke-test resources off the shared mcpd.
*
* The suites clean up in `afterAll`, which covers assertion failures but not a
* killed process (CI timeout, Ctrl-C, OOM). Over time that leaked hundreds of
* `smoke-*` projects and never-expiring `smoke-*` mcptokens onto the shared
* server, to the point where the project picker was unusable.
*
* Run after a smoke run, or any time the server looks cluttered:
* pnpm smoke:clean # show what would go
* pnpm smoke:clean --yes # actually delete
*
* Only names matching SMOKE_PREFIX are ever considered, and the shared fixture
* (`smoke-data` / `smoke-aws-docs`, provisioned from
* tests/smoke/fixtures/smoke-data.yaml and depended on by five suites) is
* protected — deleting it mid-run would break a concurrent suite.
*/
import { execFileSync } from 'node:child_process';
const SMOKE_PREFIX = /^smoke-/;
/** Shared, intentionally long-lived fixture — never swept. */
const PROTECTED = new Set(['smoke-data', 'smoke-aws-docs']);
const APPLY = process.argv.includes('--yes') || process.argv.includes('-y');
interface Named { id?: string; name?: string; projectName?: string; status?: string }
function mcpctl(args: string[]): string {
return execFileSync('mcpctl', args, { encoding: 'utf-8', maxBuffer: 32 * 1024 * 1024 });
}
function list(resource: string, extra: string[] = []): Named[] {
try {
const out = mcpctl(['get', resource, '-o', 'json', ...extra]);
const parsed: unknown = JSON.parse(out || '[]');
return Array.isArray(parsed) ? (parsed as Named[]) : [];
} catch {
return [];
}
}
function sweepable(rows: Named[]): Named[] {
return rows.filter((r) => typeof r.name === 'string' && SMOKE_PREFIX.test(r.name) && !PROTECTED.has(r.name));
}
function remove(resource: string, row: Named, extra: string[] = []): boolean {
const name = row.name ?? row.id ?? '';
if (!SMOKE_PREFIX.test(name) || PROTECTED.has(name)) return false; // belt and braces
try {
mcpctl(['delete', resource, name, ...extra]);
return true;
} catch (err) {
process.stderr.write(` ! ${resource} ${name}: ${err instanceof Error ? err.message.split('\n')[0] : String(err)}\n`);
return false;
}
}
const projects = sweepable(list('projects'));
// Tokens live on real projects too (the suites mint smoke-* tokens against
// mcpctl-development and sre), so they need sweeping independently of projects.
const tokens = sweepable(list('mcptoken'));
const servers = sweepable(list('servers'));
const llms = sweepable(list('llms'));
const agents = sweepable(list('agents'));
const groups: Array<[string, Named[], string[]]> = [
['project', projects, []],
['mcptoken', tokens, []],
['server', servers, []],
['llm', llms, []],
['agent', agents, []],
];
let total = 0;
for (const [, rows] of groups) total += rows.length;
if (total === 0) {
process.stdout.write('smoke:clean — nothing to sweep\n');
process.exit(0);
}
for (const [kind, rows] of groups) {
if (rows.length === 0) continue;
process.stdout.write(`${kind}: ${String(rows.length)}\n`);
for (const r of rows.slice(0, 5)) process.stdout.write(` ${r.name ?? ''}${r.projectName ? ` (in ${r.projectName})` : ''}\n`);
if (rows.length > 5) process.stdout.write(` … and ${String(rows.length - 5)} more\n`);
}
if (!APPLY) {
process.stdout.write(`\n${String(total)} resource(s) would be deleted. Re-run with --yes to apply.\n`);
process.exit(0);
}
let deleted = 0;
for (const [kind, rows, extra] of groups) {
for (const r of rows) if (remove(kind, r, extra)) deleted++;
}
process.stdout.write(`\nsmoke:clean — deleted ${String(deleted)}/${String(total)}\n`);
process.exit(deleted === total ? 0 : 1);

View File

@@ -80,6 +80,20 @@ cat <<EOF
EOF
[ -f "$PULUMI_YAML" ] || die "Pulumi config not found: $PULUMI_YAML"
# ── 0. Staleness gate ──
# Same hazard as the RPM build, with the cluster on the receiving end: a branch
# behind main deploys images missing whatever landed there, and the sha pinned in
# Pulumi makes that the new source of truth. Skipped for --dry-run, which builds
# and cuts over nothing — blocking a read-only inspection only teaches people to
# export MCPCTL_ALLOW_BEHIND_MAIN=1 permanently, which would disable the gate for
# the real deploys too.
if [ "$DRY_RUN" = true ]; then
warn "dry-run: skip the main-sync check"
else
source "$SCRIPT_DIR/check-main-sync.sh"
check_main_sync || die "branch is behind main — merge it before deploying"
fi
# ── 1. Test gate ──
if [ "$SKIP_TESTS" = true ]; then warn "skipping unit tests (--skip-tests)"; else
say "1/7 Unit tests (pnpm test:run)"
@@ -173,14 +187,16 @@ trap - ERR
# ── 7. RPM + smoke ──
say "7/7 Build/install CLI RPM + smoke tests"
bash scripts/release.sh
systemctl --user restart mcplocal && sleep 2
if pnpm test:smoke > /tmp/deploy-smoke.log 2>&1; then
grep -E "Tests |passed" /tmp/deploy-smoke.log | tail -2
# release.sh already restarts mcplocal and runs the smoke suite against the
# binary it just installed. This step used to restart and re-run it a second
# time, which put two full suites inside a minute and tripped mcpd's rate
# limiter: the second run failed with 429s and printed a false
# "SMOKE TESTS FAILED — consider rollback" over a perfectly healthy deploy.
# One run, one verdict.
if bash scripts/release.sh; then
say "Deploy complete — $TAG live. Rollback tag: $ROLLBACK_TAG"
else
tail -40 /tmp/deploy-smoke.log
warn "SMOKE TESTS FAILED — system may be unhealthy. Consider rollback:"
warn "RELEASE OR SMOKE TESTS FAILED — system may be unhealthy. Consider rollback:"
rollback_recipe
exit 1
fi

View File

@@ -76,7 +76,7 @@ const PROJECT_ONLY_COMMANDS = new Set([
/** Commands that appear in BOTH project and non-project context. */
const PROJECT_SCOPED_COMMANDS = new Set([
'get', 'describe', 'delete', 'logs', 'create', 'edit', 'help',
'get', 'describe', 'delete', 'logs', 'create', 'edit', 'help', 'trace',
]);
/** Completely hidden commands (never shown in completions). */

View File

@@ -0,0 +1,62 @@
#!/usr/bin/env node
/**
* Generates `src/cli/src/config/opencode-extension.ts`, which embeds the two
* opencode plugin sources as string constants — mirroring how the pi extension
* (`pi-extension.ts`) and prime-agent's `/mcpctl` switcher
* (`prime-agent-extension.ts`) are embedded.
*
* Embedding matters: `mcpctl config opencode` must work from an installed
* binary that has no access to the source tree. The installed plugin files are
* this exact embedded source, so what the CLI ships is always what opencode
* runs.
*
* Regenerate after editing the plugin sources:
* npx tsx scripts/generate-opencode-extension.ts
*/
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
const scriptsDir = import.meta.dirname;
const root = join(scriptsDir, '..');
const extDir = join(root, 'src', 'opencode-ext');
const SERVER = readFileSync(join(extDir, 'mcpctl-opencode.ts'), 'utf-8');
const TUI = readFileSync(join(extDir, 'mcpctl-opencode-tui.tsx'), 'utf-8');
function embed(src) {
// JSON.stringify yields a quoted string literal we can inline directly.
return JSON.stringify(src);
}
const out = `/**
* Embedded source of the mcpctl opencode plugins — DO NOT EDIT BY HAND.
* Generated by \`npx tsx scripts/generate-opencode-extension.ts\` from
* \`src/opencode-ext/mcpctl-opencode.ts\` and
* \`src/opencode-ext/mcpctl-opencode-tui.tsx\`.
*
* \`mcpctl config opencode\` writes these verbatim into ~/.config/opencode/
* (and registers them), so an installed binary with no source tree can still
* provision a working opencode integration.
*
* The install file names differ from the source file names on purpose:
* - the server plugin lands in opencode's auto-discovered \`plugin/\` dir,
* where its basename is what shows up in \`opencode plugin list\`;
* - the TUI plugin lands beside it in \`mcpctl/\`, referenced by path from
* \`tui.json\`, and must keep a \`.tsx\` extension for opencode to transpile
* its JSX.
*/
/** Install name of the server plugin, relative to ~/.config/opencode/plugin/. */
export const OPENCODE_SERVER_PLUGIN_FILENAME = 'mcpctl.ts';
/** Install name of the TUI plugin, relative to ~/.config/opencode/mcpctl/. */
export const OPENCODE_TUI_PLUGIN_FILENAME = 'mcpctl-tui.tsx';
export const OPENCODE_SERVER_PLUGIN_SOURCE: string = ${embed(SERVER)};
export const OPENCODE_TUI_PLUGIN_SOURCE: string = ${embed(TUI)};
`;
mkdirSync(dirname(join(root, 'src', 'cli', 'src', 'config')), { recursive: true });
writeFileSync(join(root, 'src', 'cli', 'src', 'config', 'opencode-extension.ts'), out);
console.log('wrote src/cli/src/config/opencode-extension.ts');

View File

@@ -0,0 +1,48 @@
#!/usr/bin/env node
/**
* Generates `src/cli/src/config/pi-extension.ts` which embeds the pi extension
* sources (`mcpctl-pi.ts` + `mcp-http.ts`) as string constants, mirroring how
* prime-agent's `/mcpctl` switcher is embedded (`prime-agent-extension.ts`).
*
* Embedding matters: `mcpctl config pi` must work from an installed binary
* that has no access to the source tree. The installed extension files are this
* exact embedded source, so what the CLI ships is always what pi runs.
*
* Regenerate after editing the extension sources:
* npx tsx scripts/generate-pi-extension.ts
*/
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
const scriptsDir = import.meta.dirname;
const root = join(scriptsDir, '..');
const piExtDir = join(root, 'src', 'pi-ext');
const MAIN = readFileSync(join(piExtDir, 'mcpctl-pi.ts'), 'utf-8');
const HTTP = readFileSync(join(piExtDir, 'mcp-http.ts'), 'utf-8');
function embed(src) {
// JSON.stringify yields a quoted string literal we can inline directly.
return JSON.stringify(src);
}
const out = `/**
* Embedded source of the mcpctl pi extension — DO NOT EDIT BY HAND.
* Generated by \`npx tsx scripts/generate-pi-extension.ts\` from
* \`src/pi-ext/mcpctl-pi.ts\` and \`src/pi-ext/mcp-http.ts\`.
*
* \`mcpctl config pi\` writes these verbatim into ~/.pi/agent/extensions/mcpctl/
* (and registers them), so an installed binary with no source tree can still
* provision a working pi integration.
*/
export const PI_EXTENSION_FILENAMES = ['mcpctl-pi.ts', 'mcp-http.ts'] as const;
export const PI_EXTENSION_FILES: Record<string, string> = {
'mcpctl-pi.ts': ${embed(MAIN)},
'mcp-http.ts': ${embed(HTTP)},
};
`;
mkdirSync(dirname(join(root, 'src', 'cli', 'src', 'config')), { recursive: true });
writeFileSync(join(root, 'src', 'cli', 'src', 'config', 'pi-extension.ts'), out);
console.log('wrote src/cli/src/config/pi-extension.ts');

View File

@@ -0,0 +1,41 @@
#!/usr/bin/env node
/**
* Generates `src/cli/src/config/prime-agent-extension.ts`, which embeds the
* prime-agent `/mcpctl` switcher as a string constant — the same arrangement
* the pi and opencode extensions use.
*
* The switcher used to live *only* as that string literal, with no source file
* behind it, so nothing typechecked or linted it. Now `src/prime-agent-ext/`
* holds the real source and this script produces the embed, so the file the CLI
* writes into ~/.prime/agent/extensions/ is always exactly what was checked.
*
* Regenerate after editing the extension source:
* npx tsx scripts/generate-prime-agent-extension.ts
*/
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
const scriptsDir = import.meta.dirname;
const root = join(scriptsDir, '..');
const extDir = join(root, 'src', 'prime-agent-ext');
const SOURCE = readFileSync(join(extDir, 'mcpctl-switch.ts'), 'utf-8');
const out = `/**
* The source of the \`/mcpctl\` project-switcher extension, exported as a string
* so \`mcpctl config prime-agent\` can install it into prime-agent's auto-
* discovered extensions directory (\`~/.prime/agent/extensions/\`).
*
* DO NOT EDIT BY HAND. Generated by
* \`npx tsx scripts/generate-prime-agent-extension.ts\` from
* \`src/prime-agent-ext/mcpctl-switch.ts\` — edit that instead, so the change is
* typechecked before it ships. The installed file is this exact source
* (verbatim), so the extension the CLI writes is always the one that ran.
*/
export const MCPCTL_SWITCH_EXTENSION_FILENAME = 'mcpctl-switch.ts';
export const MCPCTL_SWITCH_EXTENSION = ${JSON.stringify(SOURCE)};
`;
mkdirSync(dirname(join(root, 'src', 'cli', 'src', 'config')), { recursive: true });
writeFileSync(join(root, 'src', 'cli', 'src', 'config', 'prime-agent-extension.ts'), out);
console.log('wrote src/cli/src/config/prime-agent-extension.ts');

View File

@@ -75,9 +75,28 @@ echo "==> Running smoke tests..."
export PATH="$HOME/.npm-global/bin:$PATH"
if pnpm test:smoke; then
echo "==> Smoke tests passed!"
elif [ "${MCPCTL_ALLOW_SMOKE_FAILURE:-}" = "1" ]; then
echo "==> WARNING: Smoke tests failed, continuing (MCPCTL_ALLOW_SMOKE_FAILURE=1)."
else
echo "==> WARNING: Smoke tests failed! Check mcplocal/mcpd are running."
echo " Continuing anyway — deployment is complete, but verify manually."
# This used to print a warning and exit 0. That is how four broken readiness
# probes shipped unnoticed on 2026-08-10: the warning scrolled past in the
# build log and the release reported success. A failing smoke run means
# something in the live fleet is genuinely broken — say so in the exit code.
#
# Note what this does and does not do: smoke runs LAST, against the installed
# binary, so the package is already published and installed by now. Failing
# here reports the breakage, it does not prevent it — investigate, do not
# assume the artifact is bad.
echo "" >&2
echo "ERROR: smoke tests failed — the release is published and installed, but" >&2
echo " something in the live fleet is broken. Investigate before relying" >&2
echo " on this build; do not just re-run." >&2
echo "" >&2
echo " Common causes: mcplocal/mcpd not running, a readiness probe pointing at" >&2
echo " a tool the upstream renamed, or an expired credential." >&2
echo " Override: MCPCTL_ALLOW_SMOKE_FAILURE=1 $0" >&2
echo "" >&2
exit 1
fi
echo ""