Releases: OpenSDLC-Dev/managed-agent-platform
Release list
v0.3.0
Added
-
Two documented lists and one documented limit are now enforced by tests (#413) — a doc that
goes stale silently is the failure this work exists to stop, so three of them are pinned. The
changelog assembler refuses a fragment over the 1,500-byte capchangelog.d/README.mdstates,
measured in bytes rather than runes because the entries are full of multi-byte punctuation; the
test reads that number back out of the README, so the cap cannot move in one place only, and the
gate now runs the loader over the repository's ownchangelog.d/— an over-cap or malformed
fragment fails in the pull request that writes it instead of months later in a release PR. An
offline test fails if any doc enumerates a wire ID-prefix list that disagrees with
internal/domain/id.go, and another fails if aRUN_LIVE_*consent variable the tree reads has
no row in README's tier table. Both found real drift on their first run and this release carries
the corrections:outc_was missing from both prefix lists,skillver_from CLAUDE.md's and
vcrd_from ARCHITECTURE's, and the tier table omittedRUN_LIVE_KMS_TESTS. -
The MCP path is covered by an eval trial and a live third-party server (#45) —
mcp-answerjoins the eval suite as its sixteenth trial: a passphrase that exists only in what an MCP server's tool returns, so a graded answer exercises discovery, the default confirmation gate and the call itself. A new live tier,RUN_LIVE_MCP_TESTS=1, dials a server this repository did not write —MCP_LIVE_SERVER_URL, plus an optionalMCP_LIVE_SERVER_TOKENwhose absence is an anonymous dial rather than a rotted credential. Its opt-in contract is every live tier's (README.md's tier table): the environment variable is the consent, never.env, and once opted in a missing URL fails rather than skips. With this the MCP toolset is complete — an agent'smcp_toolsetcalls a real MCP server's tools end to end. -
A sandbox reaches the MCP servers its agent declares — plan 29 slice 6a (#45) —
limitednetworking'sallow_mcp_serversnow widens the per-session egress gate as well as the platform's own dial: the gate config carries thehost:portendpoints the session's resolved agent declares MCP servers at, and a process inside the sandbox reaches them alongsideallowed_hosts. They ride beside the policy rather than folded into it, so an operator's own list is still served as written, and they are port-scoped, because an agent author naming one endpoint should not thereby open the ports beside it. A declaration that would widen the gate past the server it names is not sent — a wildcard host (anmcp_serversurl passes no URL grammar, sohttps://*.example.com/would become the suffix rule a host set reads it as), a scheme the platform would not dial, a literal address the platform's own MCP client refuses, an impossible port, or anything the operator list's own host grammar rejects (asked ofegress.ValidateHostEntryrather than restated, which also excludes IPv6 literals the gate cannot match consistently across its two handlers). Names cannot be judged that way, so the gate dials an endpoint that only these declarations admit under the same address floor, on the resolved address. This closes the MCP half of the gate's fail-closed divergence;allow_package_managersis still not honored (#50). -
An expired MCP OAuth token refreshes at the dial — plan 29 slice 5b (#45) — An
mcp_oauthcredential whoseexpires_athas passed, or is within a minute of passing, performs the RFC 6749 refresh-token grant against its stored token endpoint before the MCP server is dialled, and the rotated tokens are sealed back onto the credential — under a compare-and-set, and best-effort, so the next dial in this session or another starts from them while a lost write costs no more than another exchange. The exchange's wire shape now lives in one place,internal/oauthrefresh, shared with themcp_oauth_validateprobe that already performed it. A credential that cannot refresh sends the token it has and lets the server answer, which is the reference's documented no-refresh-token behaviour. An issuer that refuses the grant ismcp_authentication_failed_error, and so is any other answer this platform cannot turn into a token — including one whose access token cannot be sent as a header, whose replacement refresh token is stored anyway so a later dial can still buy a usable one; one that is unreachable, 5xx-ing, or answering 408, 425 or 429 is neither, so the work item retries rather than answering a failure that says nothing about the credential. The token endpoint is dialled through the same address guard and no-redirect rule as every other credential-supplied URL, and an address that guard refuses is reported against the credential rather than retried forever. -
MCP servers authenticate with the session's vault credentials (#45) — A session whose attached vaults hold an
mcp_oauthorstatic_bearercredential for a declared MCP server now dials it with that token asAuthorization: Bearer, on discovery and on tool calls. Matching is by normalizedmcp_server_url, the first vault with a match wins, and a server nothing matches is dialled with only what its URL itself carries — userinfo still sends net/http's Basic header, which a matched token replaces. Credentials re-resolve per dial with no cache, so a rotation or an archive reaches the next dial without a restart. A refused credential now reads as one: a 401 or 403, and a matched credential the platform cannot open — never dialled anonymously instead — aremcp_authentication_failed_error, notmcp_connection_failed_error. A failed lookup is neither, and retries. -
Oversized MCP answers spill instead of vanishing (#45) — An MCP tool answer too large for the model's context was truncated and the rest lost. Its text is now written whole into the session's sandbox and the result names the file —
/tmp/tool_outputs/<call id>.txt, one per call, the convention built-in tools have used since #226. Images and blobs are named, not written. The trigger is whether rendering dropped or truncated a block, never the answer's size. Spilling never creates or heals a sandbox — it uses a new read-onlysandbox.Provider.Attach— so a session with no running sandbox, and everyself_hostedsession (a deliberate divergence), truncates as before. Resource labels such as a link's URI are now capped at 2 KiB. -
An agent's MCP tools now reach the model (#45) — An
mcp_toolsetexpanded to nothing at request assembly; it now expands to the tools its server reported, applying the entry'sdefault_configandconfigs[]. Each enabled tool is offered to the model asmcp__{server}__{tool}— the session wire still carries the bare name andmcp_server_name— and a call commits asagent.mcp_tool_use. MCP calls gate like any other ask tool:always_askis the toolset default, so the turn idles withrequires_actionuntil confirmed. A prefixed name past the Messages API's 64-byte limit, a name already taken, or a definition past the 256 KiB one request carries costs that tool a log line, not the turn. A session with undiscovered servers suspends its first turn to list them. -
The
mcp_execdriver now answers MCP tool calls (#45) — Its second job beside discovery: an outstandingagent.mcp_tool_useis dialled, called, and answered with anagent.mcp_tool_result. A tool that ran and failed is a result withis_errorfor the model to self-correct from, not a work-item fault; a call that never reached its server gets a result plus asession.errortypedmcp_connection_failed_error(retry_statusretrying), its endpoint cut toscheme://hostsince anmcp_serversentry may carry a credential. Content the wire cannot carry is mapped, not dropped: an embedded resource becomes a document, a resource link and audio become text. An answer is held to the 100 KiB every tool result gets, one call to two minutes, and a whole pass toEXECUTOR_MCP_PASS_TIMEOUT(default 5m), which now covers discovery and execution alike. Nothing emits an MCP call yet. -
An admin can issue, list, disable and retire management API keys from the console (#378) — three routes under
/api/console/organizations/{org}/workspaces/{workspace}/api_keys(POST,GET, andPOST …/{key_id}) end "one credential, changeable only by restarting the control plane": issue a named key with an optionalexpires_atand receive the secret once, asraw_key; list every key with a maskedpartial_key_hintand never a secret; disable one reversibly and re-enable it; archive one for good. Omittingexpires_atmeans never, andexpiredis derived from it at read time rather than settable. Nothing is hard-deleted, andarchivedis terminal here. On the SSO lane the whole surface — the listing included — is gated at admin; a managementx-api-keyalso reaches these routes, and can mint further management keys. A key issued over SSO records the issuing human'sprincipal_-prefixed id. The key seeded fromCONTROLPLANE_API_KEYis listed but not console-mutable: rotate it by restarting with a new value. There is deliberately no/v1twin; that absence and the omitted fields are registered in docs/DIVERGENCES.md. Operator rules: docs/self-hosted-security.md §10. -
An MCP tool call can stop and ask a human, and a refused one is answered in its own shape (#45) —
agent.mcp_tool_usejoins the confirmable set: an ask-gated MCP call blocks therequires_actiongate, appears insession.status_idle'sevent_ids, and is released by the sameuser.tool_confirmationkeyed bytool_use_ida built-in uses...
v0.2.0
Added
-
The tag-triggered release pipeline lands (plan 27 slice 3).
.github/workflows/release.ymlfires on av*tag and publishes with
GITHUB_TOKENalone, invoking only root-Makefile targets so the release's
executable source stays the Makefile:release-tag-check(the version must
be the changelog's newest released section — served by the tool's new
latestsubcommand — and the commit must sit onorigin/main; nothing
publishes otherwise),release-images(one multi-arch server build pushed
asghcr.io/opensdlc-dev/managed-agent-platform/{controlplane,brain,executor}:X.Y.Z
— same digest, three names, the coordinates the Helm chart composes — plus
…/gate:X.Y.Z; deliberately nolatesttag; withoutPUSH=1it builds
linux/amd64 into the local daemon and nothing leaves the machine),
release-chart(the chart, itsversionandappVersionboth checked
against the release PR's bump before anything publishes, to
oci://ghcr.io/opensdlc-dev/charts), andrelease-binaries
(version-stamped worker tarballs for linux/darwin × amd64/arm64 with
sha256sums). The GitHub Release's notes come frommake changelog-notes CAP=120000: thenotessubcommand now clamps an over-cap section to whole
leading Keep-a-Changelog groups plus a link to the full CHANGELOG.md
section — GitHub rejects a body over 125,000 characters, and the first
cut's absorbed legacy backlog (measured 443,570) exceeds it. Deploy docs go
live accordingly: the Helm values/README image guidance now points at the
published coordinates (from v0.2.0 onward) instead of "build and push your
own", README gains thehelm install oci://…path, and RELEASING.md's
"What the tag triggers" section replaces its "Not yet built" placeholder —
including the one-time note thatGITHUB_TOKEN-created GHCR packages
start private and need a public flip for anonymous pulls. -
Binaries know their version (plan 27 slice 2). New
internal/version
package — a singleVersionvariable,"dev"unless the build injects it
via-ldflags -X— wired into the Dockerfile as anARG VERSION=devon the
shared build stage, so both the server image and the gate image stamp their
binaries at release-build time. All five binaries log it on their existing
startup line (controlplane listening/brain running/executor running/worker running/gate listeninggain aversionattribute),
and the worker — the one binary users download and run standalone — answers
--version(and-version) with the bare version string before touching
any configuration. Deliberately no version API endpoint: that would be
net-new wire surface (plan 27 decision 3). -
Release management lands — the fragment half (plan 27 slice 1; the plan
startsin-progress). Changelog entries move out of CHANGELOG.md's
[Unreleased]section and intochangelog.d/fragments — one file per
PR per Keep-a-Changelog group, the body being the final entry verbatim (this
entry is the first one) — so parallel PRs stop contending for the same
top-of-file insertion point; only a release PR touches CHANGELOG.md, via the
newmake changelog VERSION=X.Y.Z. The assembler (tools/changelog, tested
with mutation evidence) folds fragments into a dated section in KaC group
order (entries newest-first by adding commit), moves any legacy
[Unreleased]body byte-identically below them — the one-time affordance
the 5,300-line backlog needs — leaves a pointer paragraph as the new
[Unreleased]body, advances the Keep-a-Changelog link references, and
refuses cleanly (empty release, existing version, malformed fragment names
so a typo'd section cannot silently drop an entry) leaving both files and
fragments untouched;make changelog-notesextracts a released section for
the coming release workflow. docs/RELEASING.md is the new ritual:
SemVer 0.x (Added/Changed → minor, fix-only → patch; 1.0 reserved for an
explicit stability promise), plan-archive-driven timing, annotated
vX.Y.Ztags, chart version/appVersion in lockstep — with the
tag-triggered publishing pipeline explicitly marked as arriving in plan 27
slice 3. CLAUDE.md/AGENTS.md step-2 wording and the verifier's
docs-consistency rung now require the fragment instead of a direct
CHANGELOG.md edit.
Added
github_repositorysession resources — the clone lands, and #55 closes
(plan 25 slice 2; the plan archives). A repository attached at session create
is now cloned into the sandbox before the agent's first tool runs. The clone
happens platform-side, in the executor, with go-git
(github.com/go-git/go-git/v5, a new primary dependency): the executor opens
the sealed token throughsecrets.Cipher, clones over HTTPS with the token in
theAuthorizationheader (x-access-tokenbasic auth, so the on-disk
.git/configcarries only the clean URL), packs the checkout to a tar, ships
it over the existingWriteFileStreampath, and extracts it. The token never
enters the sandbox — no credential helper, no remote URL rewrite, nothing for
the agent to read — and the egress gate is never involved, because no sandbox
process talks to GitHub.checkoutresolves here rather than at create: a
branch clones single-branch, a commit clones then checks out the sha, and
nulltakes the remote's default branch. Repositories materialize before
files, so a file mount may deliberately overlay into a checkout.
Idempotence is probe-only —<mount>/.gitpresent means materialized —
which is what lets a workspace restored from a plan-24 checkpoint keep the
agent's work instead of being re-cloned over; a marker file would have been
stripped at capture. Extraction stages into a sibling directory and renames,
so a<mount>/.gitthe probe trusts can only ever name a complete tree.
Failure is surfaced, not fatal: a repository that will not clone records a
session.errorof the new typegithub_repository_clone_error(reasons
auth,not_found,network,checkout,too_large,timeout,
internal, classified over go-git's typed sentinels rather than message
text — with a status go-git has no sentinel for, a 5xx or a 429, read off the
transport error and reported asnetwork, because a git-host outage told as
internalsends the operator to the wrong logs; a remote that answers 200
with an outage page and a checkout descriptor go-git will not build a refspec
from arenetworkandcheckoutfor the same reason, and a clone this
platform cancels — a lost lease, a shutdown — isinternal, because it is),
deduped per
(resource, reason) so a polling session cannot flood its own log, and carrying
retry_status: retryinglike every othersession.errorthe platform writes —
the next work item re-probes and clones again, so no clone failure is ever the
last attempt. The session runs on with its other repositories mounted, and a
repository that is already materialized is never reported as failed, even on an
executor whose cipher configuration has drifted away from the control plane's. Self-review found and
fixed one real leak on that path: go-git copies a failing response's body
into the errors it builds for 401/403/404, so a host that named the
credential it rejected — and a host that rejects one has already decoded it —
put the token into a line we log, on the likeliest clone failure there is.
Every error the clone returns is now scrubbed of the token verbatim and
of the base64 basic-auth blob it was sent as, through a wrapper that keeps
the error chain intact so the reason classification still reads the sentinel
through it. Two new
budgets bound the work per repository —EXECUTOR_REPO_CLONE_MAX_BYTES
(default 1 GiB, metered as the bytes land, symlink targets included, and
shared across the.gitchroot go-git takes, so an oversized repository is
abandoned mid-clone rather than after) andEXECUTOR_REPO_CLONE_TIMEOUT
(default 5m, covering the checkout and the packing as well as the fetch, which
is all go-git's own context reaches) — both exposed in the compose file and
the Helm chart. The executor judges a mount path again before building the
rm -rfthat lands a checkout there, refusing the sandbox workdir, the skills
tree materialized into it moments earlier, and another repository's staging
path — three things the control plane cannot know it is looking at. The brain
appends a "Mounted repositories" block to the system prompt naming each
path, url, and checkout, gated tocloudenvironments: BYOC workers
materialize nothing (deliberate, #322), so asserting a checkout there would be
a false statement to the model. Unit M of the plan's verification matrix
lands as executor and brain integration tests — a real git repository served
over real smart-HTTP by an in-package fixture, ten rows against real Docker
sandboxes, and a token sweep of the materialized.git— with red-run
mutation evidence for every new guard, the review rounds' included, and the
post-fetch context bound pinned as the bound it is rather than as three
separate guards (its re-checks are redundant by design — each downstream one
catches what the one before it would have — so no single one's removal is
observable)
(docs/HISTORY.md carries the running record). The end-to-endrepo-answereval
joins the opt-in suite (RUN_EVALS=1plusGITHUB_EVAL_REPO_URL/
GITHUB_EVAL_REPO_TOKENin.env): a passphrase reachable only through a
real cloned GitHub repository, asked for without naming the mount, so the
brain's block is the only way to find it. Divergences: the slice-2 clone
semantics registered INFERRED and the no-BYOC-materialization stance
CONFIRMED in docs/DIVERGENCES.md.github_repositorysession resources — the wire half lands (plan 25
slice 1; the git half of #55...