release 6.97.2: repair packaged lifecycle portability
Some checks failed
CI / quick-test (push) Has been cancelled
CI / full-test (macos-latest) (push) Has been cancelled
CI / full-test (ubuntu-latest) (push) Has been cancelled
CI / full-test (windows-latest) (push) Has been cancelled
CI / integration-test (push) Has been cancelled
CI / skill-smoke (macos-latest) (push) Has been cancelled
CI / skill-smoke (ubuntu-latest) (push) Has been cancelled
CI / skill-smoke (windows-latest) (push) Has been cancelled
CI / marker-guards (push) Has been cancelled
CI / ui-smoke (push) Has been cancelled
CI / docker-ui-smoke (push) Has been cancelled
CI / docker-portable-test (push) Has been cancelled
Scorecard analysis workflow / Scorecard analysis (push) Has been cancelled
Sync Joi Lab Fork Mirror / sync (push) Has been cancelled
Claudexor platform gate (API keys — subscription auth NOT covered) / fixture · macos-latest · exact managed runtime, fake harness, no model (push) Has been cancelled
Claudexor platform gate (API keys — subscription auth NOT covered) / fixture · ubuntu-latest · exact managed runtime, fake harness, no model (push) Has been cancelled
Claudexor platform gate (API keys — subscription auth NOT covered) / fixture · windows-latest · exact managed runtime, fake harness, no model (push) Has been cancelled
Claudexor platform gate (API keys — subscription auth NOT covered) / live · macos-latest · claude · API key only, subscription NOT covered (push) Has been cancelled
Claudexor platform gate (API keys — subscription auth NOT covered) / live · ubuntu-latest · claude · API key only, subscription NOT covered (push) Has been cancelled
Claudexor platform gate (API keys — subscription auth NOT covered) / live · windows-latest · claude · API key only, subscription NOT covered (push) Has been cancelled
Claudexor platform gate (API keys — subscription auth NOT covered) / live · macos-latest · codex · API key only, subscription NOT covered (push) Has been cancelled
CI / release-preflight (push) Has been cancelled
CI / build (dmg, macos-latest, macos-arm64, syft_1.50.0_darwin_arm64.tar.gz, syft, e32fdb9d47823fa633748a1efca2528fd77c37469ea93c9e40ab835da44e4cce) (push) Has been cancelled
CI / build (tar.gz, ubuntu-latest, linux-x86_64, syft_1.50.0_linux_amd64.tar.gz, syft, bf7b29ff57f06da30918266a0e1c2885a8f99784798d1bdb1628886aa015d788) (push) Has been cancelled
CI / vendor-package-smoke (push) Has been cancelled
CI / release (push) Has been cancelled
CI / build (zip, windows-latest, windows-x64, syft_1.50.0_windows_amd64.zip, syft.exe, 815ee6973ec5dff6a671d7f41b0e78835a8c45b91d5a39f4743ea1cee833d3be) (push) Has been cancelled

Own nested AppImage extraction cleanup in the marker-gated AppRun custodian, preserve path-resolution failure semantics across supported Python versions, and advance all synchronized release carriers for the fix-forward release after v6.97.1.

Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
This commit is contained in:
Ouroboros 2026-08-11 21:14:51 +03:00
parent bd8338490f
commit dcf48aacce
18 changed files with 244 additions and 56 deletions

View file

@ -732,14 +732,20 @@ jobs:
exit 1
fi
APPIMAGE_RUNTIME_ROOT="${LAUNCHER_EXE%/usr/lib/ouroboros/Ouroboros}"
APPIMAGE_RUNTIME_PID="$(ps -o ppid= -p "$LAUNCHER_PID" | tr -d '[:space:]')"
APPIMAGE_PRIVATE_BASE="${APPIMAGE_RUNTIME_ROOT%/*}"
APPIMAGE_CUSTODIAN_PID="$(ps -o ppid= -p "$LAUNCHER_PID" | tr -d '[:space:]')"
if [ -z "$APPIMAGE_CUSTODIAN_PID" ] || ! kill -0 "$APPIMAGE_CUSTODIAN_PID" 2>/dev/null; then
echo "Could not identify the AppRun custodian for launcher $LAUNCHER_PID" >&2
exit 1
fi
APPIMAGE_RUNTIME_PID="$(ps -o ppid= -p "$APPIMAGE_CUSTODIAN_PID" | tr -d '[:space:]')"
if [ -z "$APPIMAGE_RUNTIME_PID" ] || ! kill -0 "$APPIMAGE_RUNTIME_PID" 2>/dev/null; then
echo "Could not identify the AppImage runtime that owns launcher $LAUNCHER_PID" >&2
echo "Could not identify the AppImage runtime that owns custodian $APPIMAGE_CUSTODIAN_PID" >&2
exit 1
fi
APPIMAGE_RUNTIME_EXE="$(readlink "/proc/$APPIMAGE_RUNTIME_PID/exe")"
if [ "$APPIMAGE_RUNTIME_EXE" != "$APPIMAGE" ]; then
echo "Launcher parent is not the stable AppImage runtime: $APPIMAGE_RUNTIME_EXE" >&2
echo "Custodian parent is not the stable AppImage runtime: $APPIMAGE_RUNTIME_EXE" >&2
exit 1
fi
@ -754,9 +760,10 @@ jobs:
echo "AppImage launcher did not shut down after SIGTERM" >&2
exit 1
fi
# The type-2 runtime removes its extraction after the launcher exits
# and only then terminates. Wait on that owning process instead of
# racing a fixed pathname cleanup interval.
# The marker-gated AppRun custodian waits for the launcher, removes
# the private extraction, and exits. The type-2 runtime reaps that
# custodian before it terminates, so runtime death orders the cleanup
# proof without racing a fixed pathname interval.
for _ in $(seq 1 240); do
! kill -0 "$APPIMAGE_RUNTIME_PID" 2>/dev/null && break
sleep 0.5
@ -765,8 +772,18 @@ jobs:
echo "AppImage runtime did not finish cleanup after launcher shutdown" >&2
exit 1
fi
if kill -0 "$APPIMAGE_CUSTODIAN_PID" 2>/dev/null; then
echo "AppRun custodian remained alive after its AppImage runtime exited" >&2
exit 1
fi
if [ -e "$APPIMAGE_RUNTIME_ROOT" ]; then
echo "AppImage runtime left its extraction behind: $APPIMAGE_RUNTIME_ROOT" >&2
echo "AppRun custodian left its extraction behind: $APPIMAGE_RUNTIME_ROOT" >&2
cat "$CLI_LOG" >&2
exit 1
fi
if [ -e "$APPIMAGE_PRIVATE_BASE" ]; then
echo "AppRun custodian left its private runtime root behind: $APPIMAGE_PRIVATE_BASE" >&2
cat "$CLI_LOG" >&2
exit 1
fi
if [ -e "$PID_FILE" ]; then

View file

@ -10,7 +10,7 @@
[![Linux](https://img.shields.io/badge/Linux-x86__64-orange.svg)](https://github.com/razzant/ouroboros/releases)
[![Windows](https://img.shields.io/badge/Windows-x64-blue.svg)](https://github.com/razzant/ouroboros/releases)
[![OuroborosHub](https://img.shields.io/badge/OuroborosHub-skills%20marketplace-8A2BE2.svg)](https://github.com/razzant/OuroborosHub)
[![Version 6.97.1](https://img.shields.io/badge/version-6.97.1-green.svg)](VERSION)
[![Version 6.97.2](https://img.shields.io/badge/version-6.97.2-green.svg)](VERSION)
Ouroboros is an open-source, general-purpose AI agent whose identity, durable memory, and history continue across tasks and restarts. It works on external projects, coordinates a live swarm of specialist agents, and can rewrite the implementation it runs on, including its code, architecture, prompts, tools, and dependencies. Reflection can also change how it understands itself without severing that continuity.
@ -420,6 +420,7 @@ and integration work.
| Version | Date | Description |
|---------|------|-------------|
| 6.97.2 | 2026-08-11 | **fix: nested AppImage cleanup has a real lifecycle owner, and path guards keep the same fail-closed meaning on every supported Python.** The marker-gated `AppRun` now remains between the type-2 runtime and desktop launcher, waits for the launcher recorded by the PID file, removes only its verified extraction, and removes the empty private runtime base before returning the payload status. Linux release smoke proves that process chain and both cleanup boundaries. A shared allow-missing resolver first validates existing path ancestry, so Python 3.13's changed symlink-loop behavior can no longer turn an unresolvable delegated write root, read target, or harness home into a partially resolved path; ordinary missing targets remain supported. Packaging and delegated-containment correctness only; workspace authority and frozen Tool API contracts are unchanged. |
| 6.97.1 | 2026-08-11 | **fix: Linux AppImage relaunches use independent extraction roots, and Windows release builds avoid a false dirty-tree failure.** Nested extract-and-run starts no longer share the outer CLI runtime's temporary payload; Linux release smoke waits for the owning runtime to finish cleanup instead of racing a fixed pathname timeout. The generated runtime lock is pinned to LF so `uv export` preserves the strict clean-tree bundle gate on Windows, and that gate now reports offending paths. Packaging and release-CI only; workspace authority and frozen Tool API contracts are unchanged. |
| 6.97.0 | 2026-08-11 | **feat: workspaces now choose the default focus without narrowing Ouroboros's top-level authority, while target-sensitive tool calls bind each selected physical target once.** Immutable resource bindings now carry project, system-repository, task, data, and exact skill-payload roots through file, edit, process, service, VCS, verification, and skill-lifecycle consumers, with batch calls retaining one binding per physical target. Omitted root or cwd stays in the active project; explicit typed roots can operate on authorized system and non-native-skill resources without changing workspace, and ambiguous skill-topology collisions fail before lifecycle mutation. Canonical skill lifecycle state remains shared while task evidence stays task-custodied. This release also completes the uv-managed dependency migration, adds the Linux AppImage release artifact with its hardened launcher lifecycle, and publishes the technical report page for arXiv:2608.08311 with citation metadata. Frozen Tool API contracts remain unchanged; no Tool API v3 or skill-service layer is introduced. |
| 6.96.2 | 2026-08-11 | **fix: the hermetic preflight capture is byte-faithful on Windows too, and a POSIX-only filename test is skipped there.** The v6.96.1 release full-test matrix (first tag build since the byte-faithful capture landed) caught two windows-latest failures in the preflight runner. The hermetic worktree checked HEAD out and applied the `--binary` candidate diff under the runner's default `core.autocrlf=true`, so an LF payload landed on a CRLF-converted base and every line ending was mangled — breaking the exact byte-faithfulness the capture exists to hold; the `worktree add` and `git apply` now pin `core.autocrlf=false` + `core.eol=lf` (no `.gitattributes text` directive governs the affected paths, so the override is authoritative and inert on POSIX). The second failure was a synthetic non-UTF-8 filename test asserting an os.fsdecode round-trip that only holds under POSIX surrogateescape: Windows uses a UTF-16 filesystem where such a name cannot exist and its fs codec raises on the injected 0xff byte, and git never emits such a name there, so the production path is unaffected — the test is now skipped on Windows with that reason. Test/CI-correctness only; no runtime behavior change on any platform. |
@ -429,8 +430,7 @@ and integration work.
| 6.94.0 | 2026-08-10 | **feat: external-project tasks can finally delegate onto the subscription substrate, and the ordinary finalization nudge tells the truth about delegated runs.** The Slime Lab Escape saga (4 attempts, ~$215, the game built twice) exposed that the workspace tool envelope predates delegation: tasks in external project workspaces — and their read-only children — were resolved onto the harness route and told to be nannies while `delegate_start`/`delegate_wait`/`delegate_cancel` were filtered out of their toolset, so every "nanny" burned metered opus tokens and the subscription paid $0. The envelope now carries the delegate verbs plus `switch_model` and the `send_photo`/`send_video`/`send_file` media family (a task contract demanded send_photo while the filter hid it), with an invariant test pinning both child profiles as strict subsets so a future tool cannot silently diverge. `enable_tools` distinguishes "hidden by policy: <reason>" from "not found" through the new read-only `policy_hidden_reason` (drift-pinned to `get_schema_by_name` across six context variants); the nanny finalization nudge consults durable delegate-custody evidence — suppressed when the verbs are structurally invisible, and speaking `NANNY_DELEGATED_RUN_FAILED` with the terminal states when a child delegated and the run crashed, so a killed run can never again be misread as a judgment call ("0 delegate_start calls" accused children whose runs died in 6 seconds). The dispatch note tells a nanny to decide delegation FIRST with typed cost classes (known-zero when the route settled $0; estimated/undisclosed is never zero; metered is real money), and the swarm-router promote turn sees the built-in tool-name envelope so a contract can no longer demand a built-in the runtime does not expose. The landed amendments close the honesty loop end to end: a capability preflight before the first paid round proves a harness-dispatched child can actually see all three delegate verbs — an explicit harness pin blocks typed with zero spend (`delegate_tools_invisible`; `delegate_visibility_unverified` when toolset introspection itself failed), while `auto` falls back LOUDLY to native with a typed capability delta; execution reporting speaks the purely factual `actual_substrate` vocabulary (`harness_used`/`harness_attempted`/`native_only`, derived from custody evidence alone, raw attested counts riding beside the enum, and the claim omitted entirely when evidence is unreadable); the nanny evidence distinguishes PENDING from FAILED — `NANNY_DELEGATED_RUN_PENDING` says wait or cancel over an in-flight run instead of accusing it; the promoted-task toolset is resolved from LIVE registry availability (typed `unavailable_builtin_tools` reasons) instead of a static allowlist union; and `evidence_read_failed` renders as typed unknown ("evidence unavailable"), never as a false no-run receipt — the `wait_tasks` projection then omits the run counts and the substrate claim entirely (an unread log yields no numeric facts). Disclosed residual: the FORCED-finalization note remains trace-only — its honesty upgrade lands with the lifecycle release. This release also integrates community PR #176: native Linux `.deb`/`.rpm` packages join the release pipeline (contributed by Fgeeha). |
| 6.93.1 | 2026-08-10 | **fix: the three v6.93.0 tag-matrix failures.** chmod-based unreadability is injected instead (Windows chmod cannot revoke reads); two `read_text()` calls gain explicit utf-8 (server.py carries non-ASCII bytes that cp1252 rejects); and the overlapping dismiss/start smoke now pins the merges transition-queue semantics — the overlapped start lands after the dismissal settles instead of being dropped — asserting the preserved C7 invariant as order. Test-only changes. |
| 6.93.0 | 2026-08-09 | **feat: onboarding connects agent plans, Settings gets an Agents tab, and the account surfaces stop stating more than they know.** Two sprints integrated on one backbone. The wizard is served by the live gateway on every host and gains a skippable Connect-your-agents step with sign-in through the same login cards Settings uses; completion is one atomic request, and install-time defaults point reviewers and subagents at connected subscriptions without ever overwriting an owner choice. A new Agents tab holds the account cards per family, engine-contract removal, one service banner, and the delegation controls; docs/DESIGN.md becomes the visual authority, and the ratified BIBLE P3 amendment admits owner-declared retrieving scope reviewers as an alternate authoritative delivery mode. Underneath, `GET /api/claudexor/status` carries per-facet read provenance — reads:{catalog, accounts, quota}, each ok\|not_read\|failed — so a lazily-started daemons silence stops rendering as "no account connected": a facet is ok only when the full promised envelope arrived, refused per-harness model reads are disclosed, and POST /api/claudexor/wake is the owners start button behind Refresh. The client reads it all through ONE store (single writer, visibility-gated polling, dispose): the status line asks the facets before the aggregate, a partial refusal names what went unread, a wake refusal expires only on proven recovery, saved reviewer pins are labelled from their own facet ("not checked", never "not in discovery", until the facet was read), and a login seen online is monotone evidence. |
| 6.92.1 | 2026-08-08 | **chore: the managed Claudexor runtime pin moves to 3.3.13.** The reviewed pin (`ouroboros/claudexor_runtime_pin.json`) selects the release the delegation lanes provision and self-heal to, so a host running an older engine converges on the new one at the next handshake: 3.3.13 carries the dev-hygiene follow-up (Spotlight-shielded app bundle, dev-build chip suppression, a skew-safe opt-in for the gc data-root advisory, data-root allowlist truth, and confinement-policy shape validation ahead of the platform-availability branch). Pin fields move together — version, build sha, archive URL, sha256 and size; the Node artifact set (24.16.0) and protocol major 3 are unchanged. |
Older releases are preserved in Git tags and GitHub releases. Older 6.x rows (including 6.91.1, 6.92.0, 6.90.3, 6.91.0, 6.90.2, 6.90.0, 6.87.5, 6.87.4, 6.87.3, 6.87.2, 6.84.0, 6.87.1, 6.83.0, 6.86.1, 6.81.1, 6.76.0, 6.75.0, 6.74.5, 6.74.4, 6.74.1, 6.74.0, 6.73.2, 6.73.1, 6.73.0, 6.72.0, 6.71.2, 6.71.1, 6.71.0, 6.70.0, 6.69.0, 6.68.0, 6.67.0, 6.66.0, 6.65.4, 6.65.3, 6.65.2, 6.65.1, 6.65.0, 6.64.3, 6.64.2, 6.64.1, 6.64.0, 6.63.0, 6.62.0, 6.61.4, 6.61.3, 6.61.1, 6.61.0, 6.60.0, 6.59.0, 6.58.0, 6.57.0, 6.56.0, 6.55.0, 6.54.4, 6.54.2, 6.54.1, 6.54.0, 6.53.4, 6.53.0, 6.51.0), the 5.2.0 through 5.33.0-rc.6 rows, and former `4.0.0` rows are rolled off to respect the P9 changelog cap; their full bodies remain at their git tags.
Older releases are preserved in Git tags and GitHub releases. Older 6.x rows (including 6.92.1, 6.92.0, 6.91.1, 6.90.3, 6.91.0, 6.90.2, 6.90.0, 6.87.5, 6.87.4, 6.87.3, 6.87.2, 6.84.0, 6.87.1, 6.83.0, 6.86.1, 6.81.1, 6.76.0, 6.75.0, 6.74.5, 6.74.4, 6.74.1, 6.74.0, 6.73.2, 6.73.1, 6.73.0, 6.72.0, 6.71.2, 6.71.1, 6.71.0, 6.70.0, 6.69.0, 6.68.0, 6.67.0, 6.66.0, 6.65.4, 6.65.3, 6.65.2, 6.65.1, 6.65.0, 6.64.3, 6.64.2, 6.64.1, 6.64.0, 6.63.0, 6.62.0, 6.61.4, 6.61.3, 6.61.1, 6.61.0, 6.60.0, 6.59.0, 6.58.0, 6.57.0, 6.56.0, 6.55.0, 6.54.4, 6.54.2, 6.54.1, 6.54.0, 6.53.4, 6.53.0, 6.51.0), the 5.2.0 through 5.33.0-rc.6 rows, and former `4.0.0` rows are rolled off to respect the P9 changelog cap; their full bodies remain at their git tags.
---

View file

@ -1 +1 @@
6.97.1
6.97.2

View file

@ -1,4 +1,4 @@
# Ouroboros v6.97.1 — Architecture & Reference
# Ouroboros v6.97.2 — Architecture & Reference
This file is NOT a changelog. Version history lives in README.md, git tags, and commit log.
@ -344,7 +344,7 @@ Workspace tasks default to `memory_mode=forked`, and the gateway rejects `shared
CLI schedules and skill-manifest schedules enqueue ordinary supervisor tasks; they do not create a parallel scheduler. `resync_skill_schedules()` admits only executable skills with the supervised-task permission, removes stale source rows, and runs after lifecycle changes and on scheduler ticks. A blank timezone means the DST-aware system zone, with a fixed-offset fallback only when the zone cannot be recovered. The active schedule digest is task/consciousness context, so recurring work is visible to the same reasoning loop that executes it.
Packaged artifacts ship a tiny wrapper and installer rather than a second PyInstaller runtime. `ouroboros.packaged_cli` locates `repo.bundle`, its manifest, and `python-standalone`; bootstraps the launcher-managed checkout; then invokes the same `ouroboros.cli` module with the embedded interpreter and canonical app/repo/data environment. Packaged `server` is refused because it would bypass launcher-owned bootstrap, process identity, restart, and cleanup. Packaged `run --start` is loopback-only: it starts the outer desktop application when no ready gateway exists, follows the actual `data/state/server_port` unless the caller supplied an explicit URL, and waits for both `/api/health` and `api_state.supervisor_ready` before submitting work.
Packaged artifacts ship a tiny wrapper and installer rather than a second PyInstaller runtime. `ouroboros.packaged_cli` locates `repo.bundle`, its manifest, and `python-standalone`; bootstraps the launcher-managed checkout; then invokes the same `ouroboros.cli` module with the embedded interpreter and canonical app/repo/data environment. Packaged `server` is refused because it would bypass launcher-owned bootstrap, process identity, restart, and cleanup. Packaged `run --start` is loopback-only: it starts the outer desktop application when no ready gateway exists, follows the actual `data/state/server_port` unless the caller supplied an explicit URL, and waits for both `/api/health` and `api_state.supervisor_ready` before submitting work. A normal Linux FUSE relaunch retains direct `AppRun → launcher` execution. When `run --start` originates inside extract-and-run, the nested AppImage receives a private temporary base and its marker-gated `AppRun` remains as a custodian between the type-2 runtime and launcher; it waits for that launcher, removes only the verified extracted child, and removes the now-empty private base before returning the payload status to the runtime.
Release builds also carry Node and ripgrep. Consumers prefer `platform_layer.resolve_bundled_node()` / `resolve_bundled_ripgrep()` and fall back to `PATH` whenever no bundled candidate exists; the fallback is not mode-gated, although a proper release build normally supplies both payloads. The Node downloader verifies the official LTS archive against published SHASUMS, prunes it to the binary, and the macOS signing pass re-signs it under the hardened runtime because an external Homebrew Node can be SIGKILLed inside the signed application tree. Ripgrep is likewise archive-hash verified; `search_code` still pre-enumerates policy-approved files before invoking it, so bundling a faster binary does not widen search authority.
@ -1977,7 +1977,7 @@ dependency authority.
Platform builds precompile bundled Python with unchecked-hash bytecode. Sealing valid bytecode prevents runtime `__pycache__` writes from invalidating a macOS signature; runtime children also route caches outside the bundle. When signing is enabled, hardened runtime, notarization, xattr hygiene, and strict verification remain part of the stable-release path. Prerelease artifacts may intentionally be unsigned, and their evidence must report the actual signing state rather than imply notarization. Linux and Windows use the same precompile for startup parity without a macOS seal.
The release proof begins with the final DMG, AppImage, tarball, or ZIP rather than its staging directory. Each platform shard checks the embedded repository bundle, packaged CLI, and managed Claudexor seed plus Node by starting the owned daemon, completing a fixture task, and verifying an identity-bound stop. The Linux AppImage is extracted for metadata and SBOM inspection, then run through the FUSE-independent runtime to prove exact version output, CLI dispatch, browser-fallback readiness, payload lifetime after CLI-started relaunch, main-executable libraries, and clean shutdown; its payload remains read-only at runtime. This is browser-fallback evidence, not a claim that the packaged payload contains a native GTK/Qt backend. The macOS smoke also verifies its Finder installation surfaces. On Linux, the proven tarball payload is also wrapped into the three native packages; each receives its own digest-bound package-manager smoke receipt, including the bounded desktop-launcher start, and provenance attestation. A digest-pinned Syft build produces CycloneDX inventories from extracted payload bytes; the tarball inventory is reused for the three native wrappers instead of rescanning identical bytes. GitHub SBOM attestations bind the relevant payload inventory to each final asset. The release job accepts only the seven expected release assets, recalculates their digests, verifies both predicate types, writes the checksum/evidence capsule, and rechecks the remote tag immediately before publication. Signing credentials remain step-scoped and are absent from SBOM and attestation steps.
The release proof begins with the final DMG, AppImage, tarball, or ZIP rather than its staging directory. Each platform shard checks the embedded repository bundle, packaged CLI, and managed Claudexor seed plus Node by starting the owned daemon, completing a fixture task, and verifying an identity-bound stop. The Linux AppImage is extracted for metadata and SBOM inspection, then run through the FUSE-independent runtime to prove exact version output, CLI dispatch, browser-fallback readiness, payload lifetime after CLI-started relaunch, main-executable libraries, and clean shutdown; its payload remains read-only at runtime. The nested cleanup proof follows the live `runtime → AppRun custodian → launcher` chain, stops the launcher recorded by the PID file, waits for the owning runtime, and requires both the extraction and its private base to be absent. This is browser-fallback evidence, not a claim that the packaged payload contains a native GTK/Qt backend. The macOS smoke also verifies its Finder installation surfaces. On Linux, the proven tarball payload is also wrapped into the three native packages; each receives its own digest-bound package-manager smoke receipt, including the bounded desktop-launcher start, and provenance attestation. A digest-pinned Syft build produces CycloneDX inventories from extracted payload bytes; the tarball inventory is reused for the three native wrappers instead of rescanning identical bytes. GitHub SBOM attestations bind the relevant payload inventory to each final asset. The release job accepts only the seven expected release assets, recalculates their digests, verifies both predicate types, writes the checksum/evidence capsule, and rechecks the remote tag immediately before publication. Signing credentials remain step-scoped and are absent from SBOM and attestation steps.
### Docker

View file

@ -1686,7 +1686,13 @@ Linux additionally emits an AppImage built by a version- and digest-pinned
it for metadata and SBOM inspection, then uses real extract-and-run invocations
to verify product version, CLI dispatch, the browser-fallback launcher, gateway
readiness, payload lifetime after `run --start`, shared libraries, and graceful
shutdown. This smoke deliberately makes no native GTK/Qt claim: packaged native
shutdown. A nested extract-and-run relaunch receives a private temporary base;
its marker-gated `AppRun` waits as the payload custodian, restores the caller's
`TMPDIR` before launch, and removes the verified extraction plus the empty private
base after the launcher exits. The release smoke proves the resulting type-2
runtime → custodian → launcher process chain and waits on the runtime before it
requires both paths to be absent. Ordinary FUSE launches retain direct `exec`.
This smoke deliberately makes no native GTK/Qt claim: packaged native
webview coverage remains a separate Linux distribution contract.
`OUROBOROS_SKIP_PLAYWRIGHT_INSTALL_DEPS=1` is only a local-builder escape hatch
for hosts whose system packages are managed separately: it skips Playwright's

View file

@ -16,6 +16,7 @@ from dataclasses import dataclass
from typing import Any, Dict, Optional
from ouroboros import delegate_custody as custody
from ouroboros.utils import resolve_path_allow_missing
_TERMINAL_STATES = custody.TERMINAL_STATES
@ -35,8 +36,12 @@ _UNKNOWN_ACCESS_RANK = 99
def _resolved(path: Any) -> Optional[pathlib.Path]:
try:
return pathlib.Path(str(path)).resolve() if str(path or "").strip() else None
except (OSError, ValueError, RuntimeError):
return (
resolve_path_allow_missing(pathlib.Path(str(path)))
if str(path or "").strip()
else None
)
except (OSError, ValueError, RuntimeError, TypeError):
return None
@ -166,4 +171,3 @@ def _inside_operator_home(applied: pathlib.Path, real_home: pathlib.Path) -> boo
"""
return applied == real_home or real_home in applied.parents

View file

@ -38,6 +38,7 @@ from ouroboros.usage_accounting import (
settle_attempt,
usage_scope,
)
from ouroboros.utils import resolve_path_allow_missing
log = logging.getLogger(__name__)
@ -335,7 +336,7 @@ def _resolved(value: Any, base: Optional[pathlib.Path] = None) -> Optional[pathl
target = pathlib.Path(value)
if base is not None and not target.is_absolute():
target = base / target
return target.resolve()
return resolve_path_allow_missing(target)
except (OSError, ValueError, RuntimeError, TypeError):
return None

View file

@ -36,7 +36,7 @@ from ouroboros import delegate_progress as progress
from ouroboros.delegate_custody import RunCustody as _RunCustody
from ouroboros.tool_capabilities import tool_result_limit
from ouroboros.tools.registry import ToolContext, ToolEntry, active_repo_dir_for
from ouroboros.utils import truncate_review_artifact
from ouroboros.utils import resolve_path_allow_missing, truncate_review_artifact
# The staged-output + read-receipt cluster lives in its own module (size gate);
# re-exported here because sibling code, the tests and the convergence census all
# name it on THIS surface, and `_READ_COVERAGE` must stay the same object.
@ -600,8 +600,8 @@ def _resolved(path: Any) -> Optional[pathlib.Path]:
"""Resolve a path, or None when it cannot be resolved at all (null byte, symlink
loop, unreadable parent). One predicate, so no call site re-enumerates the set."""
try:
return pathlib.Path(str(path)).resolve()
except (OSError, ValueError, RuntimeError):
return resolve_path_allow_missing(pathlib.Path(str(path)))
except (OSError, ValueError, RuntimeError, TypeError):
return None
@ -653,9 +653,9 @@ def _mutating_run_root(ctx: ToolContext, authority: "DelegatedRunShape") -> tupl
"has none. Refusing rather than falling back to the repository root.",
)
# "Can this path be resolved at all" is ONE question, not an exception set to
# re-enumerate: an embedded null raises ValueError and a symlink loop RuntimeError,
# and either escaping here would abort delegate_start with a traceback instead of the
# typed refusal this function exists to produce.
# re-enumerate: embedded nulls and symlink loops vary in exact exception behaviour
# across supported Python versions, and either escaping here would abort
# delegate_start instead of returning the typed refusal this function exists to produce.
resolved_root, resolved_grant = _resolved(root), _resolved(granted)
same = resolved_root is not None and resolved_root == resolved_grant
if not same:

View file

@ -94,6 +94,25 @@ def read_text(path: pathlib.Path) -> str:
return path.read_text(encoding="utf-8")
def resolve_path_allow_missing(path: pathlib.Path) -> Optional[pathlib.Path]:
"""Resolve a path while allowing a missing tail but rejecting unusable ancestry.
Python 3.13 changed ``Path.resolve(strict=False)`` so a symlink loop no longer
raises and instead returns a partially resolved path. Probe strict resolution
first to keep loops, unreadable parents, and non-directory ancestors typed as
unusable; only an ordinary missing component earns the allow-missing fallback.
"""
try:
return pathlib.Path(path).resolve(strict=True)
except FileNotFoundError:
try:
return pathlib.Path(path).resolve(strict=False)
except (OSError, ValueError, RuntimeError, TypeError):
return None
except (OSError, ValueError, RuntimeError, TypeError):
return None
def write_text(path: pathlib.Path, content: str) -> None:
# Full-file overwrite -> atomic (temp-sibling + os.replace), so a crash mid-write never
# leaves a truncated file (G, v6.39). Strictly safer for every caller of this overwrite
@ -985,4 +1004,3 @@ def truncate_review_artifact(text: str | None, limit: int = 4000) -> str:
if len(text) - limit <= len(marker):
return text
return text[:limit] + marker

View file

@ -3,12 +3,17 @@ set -eu
APPDIR=${APPDIR:-$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)}
PAYLOAD="$APPDIR/usr/lib/ouroboros"
APPIMAGE_CUSTODY_BASE=
APPIMAGE_CUSTODY_APPDIR=
# A nested extract-and-run relaunch receives a private TMPDIR so it cannot share
# ownership of the outer CLI runtime's content-addressed extraction. Restore the
# caller's TMPDIR before the actual product starts; only the AppImage runtime
# itself should see the private extraction namespace.
# caller's TMPDIR before the actual product starts. AppRun keeps custody of the
# private extraction until that product exits because the pinned musl type-2
# runtime cannot reliably remove it itself.
if [ "${OUROBOROS_APPIMAGE_RESTORE_TMPDIR:-}" = "1" ]; then
APPIMAGE_CUSTODY_BASE=${TMPDIR-}
APPIMAGE_CUSTODY_APPDIR=${APPDIR-}
if [ "${OUROBOROS_APPIMAGE_ORIGINAL_TMPDIR_SET:-0}" = "1" ]; then
TMPDIR=${OUROBOROS_APPIMAGE_ORIGINAL_TMPDIR-}
export TMPDIR
@ -42,4 +47,56 @@ if [ "${1:-}" = "--cli" ]; then
exec "$PAYLOAD/bin/ouroboros" "$@"
fi
exec "$PAYLOAD/Ouroboros" "$@"
if [ -z "$APPIMAGE_CUSTODY_BASE" ]; then
exec "$PAYLOAD/Ouroboros" "$@"
fi
if [ -z "$APPIMAGE_CUSTODY_APPDIR" ]; then
echo "AppImage cleanup custody is missing its private runtime paths" >&2
exit 1
fi
APPIMAGE_CUSTODY_BASE=$(CDPATH= cd -- "$APPIMAGE_CUSTODY_BASE" && pwd -P) || {
echo "AppImage cleanup custody cannot resolve its private runtime root" >&2
exit 1
}
APPIMAGE_CUSTODY_APPDIR=$(CDPATH= cd -- "$APPIMAGE_CUSTODY_APPDIR" && pwd -P) || {
echo "AppImage cleanup custody cannot resolve its extracted payload" >&2
exit 1
}
case "${APPIMAGE_CUSTODY_BASE##*/}" in
ouroboros-appimage-runtime-?*) ;;
*)
echo "AppImage cleanup custody refused an unexpected private runtime root" >&2
exit 1
;;
esac
if [ "$(dirname -- "$APPIMAGE_CUSTODY_APPDIR")" != "$APPIMAGE_CUSTODY_BASE" ]; then
echo "AppImage cleanup custody refused a payload outside its private runtime root" >&2
exit 1
fi
case "${APPIMAGE_CUSTODY_APPDIR##*/}" in
appimage_extracted_?*) ;;
*)
echo "AppImage cleanup custody refused an unexpected extracted payload" >&2
exit 1
;;
esac
PAYLOAD_STATUS=0
"$PAYLOAD/Ouroboros" "$@" || PAYLOAD_STATUS=$?
CLEANUP_STATUS=0
rm -rf -- "$APPIMAGE_CUSTODY_APPDIR" || CLEANUP_STATUS=$?
if [ -e "$APPIMAGE_CUSTODY_APPDIR" ]; then
echo "AppImage cleanup custody left its extracted payload behind: $APPIMAGE_CUSTODY_APPDIR" >&2
CLEANUP_STATUS=1
fi
rmdir -- "$APPIMAGE_CUSTODY_BASE" || CLEANUP_STATUS=$?
if [ -e "$APPIMAGE_CUSTODY_BASE" ]; then
echo "AppImage cleanup custody left its private runtime root behind: $APPIMAGE_CUSTODY_BASE" >&2
CLEANUP_STATUS=1
fi
if [ "$PAYLOAD_STATUS" -eq 0 ] && [ "$CLEANUP_STATUS" -ne 0 ]; then
PAYLOAD_STATUS=$CLEANUP_STATUS
fi
exit "$PAYLOAD_STATUS"

View file

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "ouroboros"
version = "6.97.1"
version = "6.97.2"
description = "Self-creating AI agent with constitution, background consciousness, and persistent identity"
readme = "README.md"
license = {text = "MIT"}

View file

@ -86,21 +86,28 @@ def test_apprun_exposes_cli_without_writing_to_the_mount(tmp_path: pathlib.Path)
assert result.stdout.strip() == str(appdir / "usr/lib/ouroboros/_internal")
@pytest.mark.parametrize("original_tmpdir", [None, "/caller/tmp"])
def test_apprun_restores_tmpdir_before_payload(tmp_path: pathlib.Path, original_tmpdir: str | None):
appdir = tmp_path / "AppDir"
cli = appdir / "usr/lib/ouroboros/bin/ouroboros"
cli.parent.mkdir(parents=True)
cli.write_text(
@pytest.mark.parametrize(
("original_tmpdir", "payload_status"),
[(None, 0), ("/caller/tmp", 37)],
)
def test_apprun_custodian_restores_environment_and_removes_private_runtime(
tmp_path: pathlib.Path,
original_tmpdir: str | None,
payload_status: int,
):
private_base = tmp_path / "ouroboros-appimage-runtime-test"
appdir = private_base / "appimage_extracted_test"
observed = tmp_path / "payload-environment.txt"
launcher = appdir / "usr/lib/ouroboros/Ouroboros"
launcher.parent.mkdir(parents=True)
launcher.write_text(
"#!/bin/sh\n"
"if [ \"${TMPDIR+x}\" = x ]; then printf 'set:%s\\n' \"$TMPDIR\"; else printf 'unset\\n'; fi\n"
"if [ \"${TMPDIR+x}\" = x ]; then value=set:$TMPDIR; else value=unset; fi\n"
"if env | grep -q '^OUROBOROS_APPIMAGE_'; then exit 9; fi\n"
"exit 0\n",
"printf '%s\\n' \"$value\" > \"$OBSERVED_PATH\"\n"
"exit \"$TEST_PAYLOAD_STATUS\"\n",
encoding="utf-8",
)
cli.chmod(0o755)
launcher = appdir / "usr/lib/ouroboros/Ouroboros"
launcher.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
launcher.chmod(0o755)
apprun = appdir / "AppRun"
apprun.write_bytes((REPO / "packaging/appimage/AppRun").read_bytes())
@ -108,21 +115,88 @@ def test_apprun_restores_tmpdir_before_payload(tmp_path: pathlib.Path, original_
env = {
**os.environ,
"APPDIR": str(appdir),
"TMPDIR": "/private/extraction",
"TMPDIR": str(private_base),
"OUROBOROS_APPIMAGE_RESTORE_TMPDIR": "1",
"OUROBOROS_APPIMAGE_ORIGINAL_TMPDIR_SET": "1" if original_tmpdir is not None else "0",
"OUROBOROS_APPIMAGE_ORIGINAL_TMPDIR": original_tmpdir or "",
"OBSERVED_PATH": str(observed),
"TEST_PAYLOAD_STATUS": str(payload_status),
}
result = subprocess.run(
[str(apprun), "--cli", "--help"],
[str(apprun)],
env=env,
check=True,
check=False,
capture_output=True,
text=True,
)
assert result.stdout.strip() == (f"set:{original_tmpdir}" if original_tmpdir is not None else "unset")
assert result.returncode == payload_status
assert observed.read_text(encoding="utf-8").strip() == (
f"set:{original_tmpdir}" if original_tmpdir is not None else "unset"
)
assert not appdir.exists()
assert not private_base.exists()
def test_apprun_custodian_refuses_unrelated_private_runtime(tmp_path: pathlib.Path):
private_base = tmp_path / "ouroboros-appimage-runtime-test"
private_base.mkdir()
sentinel = private_base / "keep.txt"
sentinel.write_text("keep", encoding="utf-8")
appdir = tmp_path / "elsewhere" / "appimage_extracted_test"
launcher = appdir / "usr/lib/ouroboros/Ouroboros"
launcher.parent.mkdir(parents=True)
launcher.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
launcher.chmod(0o755)
apprun = appdir / "AppRun"
apprun.write_bytes((REPO / "packaging/appimage/AppRun").read_bytes())
apprun.chmod(0o755)
result = subprocess.run(
[str(apprun)],
env={
**os.environ,
"APPDIR": str(appdir),
"TMPDIR": str(private_base),
"OUROBOROS_APPIMAGE_RESTORE_TMPDIR": "1",
"OUROBOROS_APPIMAGE_ORIGINAL_TMPDIR_SET": "0",
"OUROBOROS_APPIMAGE_ORIGINAL_TMPDIR": "",
},
check=False,
capture_output=True,
text=True,
)
assert result.returncode != 0
assert "outside its private runtime root" in result.stderr
assert sentinel.read_text(encoding="utf-8") == "keep"
assert appdir.is_dir()
def test_apprun_unmarked_desktop_path_keeps_exec_pid_and_appdir(tmp_path: pathlib.Path):
appdir = tmp_path / "AppDir"
pid_output = tmp_path / "launcher.pid"
launcher = appdir / "usr/lib/ouroboros/Ouroboros"
launcher.parent.mkdir(parents=True)
launcher.write_text(
"#!/bin/sh\nprintf '%s\\n' \"$$\" > \"$PID_OUTPUT\"\n",
encoding="utf-8",
)
launcher.chmod(0o755)
apprun = appdir / "AppRun"
apprun.write_bytes((REPO / "packaging/appimage/AppRun").read_bytes())
apprun.chmod(0o755)
process = subprocess.Popen(
[str(apprun)],
env={**os.environ, "APPDIR": str(appdir), "PID_OUTPUT": str(pid_output)},
)
process_pid = process.pid
assert process.wait(timeout=10) == 0
assert int(pid_output.read_text(encoding="utf-8")) == process_pid
assert appdir.is_dir()
def test_appimage_builder_pins_tool_and_embedded_runtime():

View file

@ -1101,10 +1101,10 @@ class TestReadGuard:
"tool_input": {"paths": [str(tmp_path), "/etc/hosts"]}}, "t", None))
assert out, "a path inside a list escaped the read fence"
# An unresolvable path denies, whatever makes it unresolvable: `OSError` alone
# missed an embedded null (`ValueError`, legal in a JSON string) and a symlink
# loop (`RuntimeError`). A guard that raises out of the PreToolUse callback
# delivers no decision at all — the one outcome a fence must never produce.
# An unresolvable path denies, whatever makes it unresolvable: embedded nulls
# and symlink loops have changed their exact `Path.resolve()` failure behaviour
# across supported Python versions. A guard that raises out of the PreToolUse
# callback delivers no decision at all — the one outcome a fence must never produce.
import os as _os
_os.symlink(tmp_path / "loop_b", tmp_path / "loop_a")
@ -1112,6 +1112,9 @@ class TestReadGuard:
for bad in ("/etc/passwd\x00", str(tmp_path / "loop_a" / "x")):
assert self._run(guard({"tool_name": "Read",
"tool_input": {"file_path": bad}}, "t", None)), bad
missing = tmp_path / "missing" / "leaf"
assert self._run(guard({"tool_name": "Read",
"tool_input": {"file_path": str(missing)}}, "t", None)) == {}
# In-workspace values still pass, whatever they are called.
inside = tmp_path / "a.txt"

View file

@ -1933,14 +1933,15 @@ def _plain_ctx(tmp_path):
def test_an_unresolvable_write_root_is_a_typed_refusal_not_a_traceback(tmp_path):
""""Can this path be resolved at all" is ONE question, not an exception set.
`Path.resolve()` raises `ValueError` on an embedded null and `RuntimeError` on a
symlink loop, neither of which is an `OSError`. Either escaping `_mutating_run_root`
Embedded nulls and symlink loops have changed their exact `Path.resolve()` failure
behaviour across supported Python versions. Either escaping `_mutating_run_root`
aborts `delegate_start` with a traceback instead of the typed refusal the function
exists to produce and a guard that raises delivers no decision at all.
"""
import os
from ouroboros.contracts.task_constraint import TaskConstraint
from ouroboros.delegate_containment import _resolved as containment_resolved
from ouroboros.subagents import delegated_run_shape
from ouroboros.tools.delegate import _mutating_run_root, _resolved
from ouroboros.tools.registry import ToolContext
@ -1948,8 +1949,12 @@ def test_an_unresolvable_write_root_is_a_typed_refusal_not_a_traceback(tmp_path)
os.symlink(tmp_path / "b", tmp_path / "a")
os.symlink(tmp_path / "a", tmp_path / "b")
assert _resolved(tmp_path / "a" / "x") is None, "a symlink loop must resolve to None"
assert containment_resolved(tmp_path / "a" / "x") is None
assert _resolved("/etc/passwd\x00") is None, "an embedded null must resolve to None"
assert _resolved(tmp_path) == tmp_path.resolve(), "an ordinary path still resolves"
missing = tmp_path / "missing" / "leaf"
assert _resolved(missing) == missing.resolve(strict=False)
assert containment_resolved(missing) == missing.resolve(strict=False)
workspace = tmp_path.parent / f"ws-{tmp_path.name}"
workspace.mkdir()

View file

@ -347,9 +347,12 @@ def test_release_workflow_orders_smoke_sbom_attestation_and_draft_verification()
assert "--check clean_shutdown" in workflow
assert "--check shared_libraries" in workflow
assert 'APP_ROOT="$HOME_DIR/Ouroboros"' in workflow
assert 'APPIMAGE_RUNTIME_PID="$(ps -o ppid= -p "$LAUNCHER_PID"' in workflow
assert 'APPIMAGE_CUSTODIAN_PID="$(ps -o ppid= -p "$LAUNCHER_PID"' in workflow
assert 'APPIMAGE_RUNTIME_PID="$(ps -o ppid= -p "$APPIMAGE_CUSTODIAN_PID"' in workflow
assert 'kill -0 "$APPIMAGE_RUNTIME_PID"' in workflow
assert "Wait on that owning process instead" in workflow
assert 'APPIMAGE_PRIVATE_BASE="${APPIMAGE_RUNTIME_ROOT%/*}"' in workflow
assert 'if [ -e "$APPIMAGE_PRIVATE_BASE" ]; then' in workflow
assert "runtime death orders the cleanup" in workflow
assert 'OUROBOROS_APP_ROOT="$APP_ROOT"' not in workflow
assert 'test -x "$MOUNT/Install CLI.command"' in workflow
assert 'test -L "$MOUNT/Applications"' in workflow

2
uv.lock generated
View file

@ -1554,7 +1554,7 @@ wheels = [
[[package]]
name = "ouroboros"
version = "6.97.1"
version = "6.97.2"
source = { editable = "." }
dependencies = [
{ name = "claude-agent-sdk" },

View file

@ -713,4 +713,4 @@
* @property {?boolean} check_ok
*/
export const GATEWAY_CONTRACT_VERSION = '6.97.1';
export const GATEWAY_CONTRACT_VERSION = '6.97.2';

View file

@ -1,6 +1,6 @@
{
"name": "ouroboros-web",
"version": "6.97.1",
"version": "6.97.2",
"private": true,
"type": "module",
"description": "Ouroboros browser UI package boundary",