CHANGELOG.md
Rama main · réplica de lectura del repositorio original.
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [v2.1.1] - 2026-09-30
### Security
- PyJWT raised to `>=2.14.0` (the lock files pin 2.15.x) for 10 advisories that pip-audit reports against 2.13.0: CVE-2026-101917, CVE-2026-102265 through 102269, and CVE-2026-102271 through 102274 (#3470).
### Fixed
- Release artifacts now report one product version (Issue #3469, #3468). v2.1.0 shipped Python package `2.1.0`, frontend bundle `0.2.0` and a static Docker `LABEL version="1.0.0"`. `pyproject.toml` is now the single source: the frontend package and lockfile follow it, the frontend bundle stamps the version and commit SHA, and the image carries `org.opencontainers.image.version` / `org.opencontainers.image.revision` instead of the static `version` label. `scripts/check_release_version.py` rejects mismatches in CI and before any release is published.
- Sandboxed workspaces no longer validate `/fs/*` and project-creation paths on the host (Issue #3427, #3428). In sandboxed mode, browse and `/fs/home` make no host probes and report `sandboxed: true`; new project directories are created inside the sandbox through the instance command channel; a non-string JSON `path` returns 400 instead of 500 (Issue #3459, #3460, #3461).
- `/analytics/report`, `/analytics/efficiency` and `/analytics/export` return 400 for an invalid `days` or date instead of silently clamping it, swapping reversed dates, or failing with 500 (Issue #3253, #3458).
- Days-only analytics ranges cover exactly N calendar days; `days=30` previously covered 31 (Issue #3254, #3456).
- The forecast page reads history and forecast from the same source (`daily_messages`), so their token values agree (Issue #3243, #3316).
- Enterprise Report quick-range buttons (7/30/90 days) are highlighted from the actual date period; manually editing a date no longer leaves "30 days" highlighted (Issue #3255, #3452).
- Audit Center's fallback action and category lists match the backend `AuditAction` enum again (73 actions, 17 categories), with a test that fails on future drift (Issue #3362, #3454).
- Docker Compose: the multi-user overlay now really starts the scheduler by default (`profiles: !override`; the previous `profiles: []` never took effect) (Issue #3455, #3457), and the base-compose scheduler defaults `OPENACE_SECURITY_MODE` so it no longer crash-loops when started on its own (Issue #3133, #3453).
### Changed
- qwen-code-webui upgraded from 0.2.43 to 0.3.0; `@qwen-code/qwen-code` stays at 0.23.3 (#3466). A weekly `webui-upgrade.yml` workflow now opens qwen-stack upgrade PRs automatically (#3463).
- Release process (#3468): `scripts/release.sh` only prepares the version files (`pyproject.toml`, frontend package and lockfile, `CHANGELOG.md`) and no longer commits, tags or pushes. The release change is merged to `main` by PR and the merged commit is tagged; the release workflows verify the tag, the product versions and that the tagged commit is on `main` before publishing. See `docs/dev/REPOSITORY_SETUP.md`.
### Documentation
- Docs governance restructure (2026-09-29): all curated docs under `docs/` are now single-file bilingual (English + Chinese sections). Language directories `docs/en/` and `docs/cn/` are gone; content is organized as `guide/` (usage & ops), `dev/` (contributors), `contracts/` (versioned contracts), `security/`, plus the `dev-notes/` English-only process archive. `docs/README.md` is the index.
- `docs/dev/API.md` rewritten: all 426 endpoints covered from a generated inventory; three families of ghost endpoints that no longer existed were removed (`/api/usage/request/*` → `/api/request/*`, `/api/sessions` & `/api/prompts` → `/api/workspace` prefix).
- Reverted while merging `main`: the deprecation of `POST /api/content/filter/patterns` in favor of `/api/filter-rules` has been withdrawn — the endpoint is a real implementation again (it adds the pattern to the content filter and audits the change) and coexists with `GET/POST /api/filter-rules` — so the `docs/contracts/FILTER_PATTERNS_V2.md` migration guide and the deprecation-notice links to it have been removed.
- `scripts/generate_permission_matrix.py` fixed: route-decorator matching now covers all blueprint variable names (previously 92 `@admin_required` and 7 `@platform_admin_required` endpoints were silently omitted from the generated matrix); output is bilingual at `docs/dev/API_PERMISSION_MATRIX.md` with a generated date.
- New docs: `guide/UPGRADING.md` (upgrade/rollback runbook with the `baseline_2026_06_23` minimum), `guide/ENV_REFERENCE.md`, `guide/CONFIG_REFERENCE.md` (absorbs `config/CONFIG_GUIDE.md`), `guide/SSO_CONFIG.md` (SAML + OIDC/OAuth2 + SLO, replaces SAML_CONFIG), `guide/OPERATIONS.md`, `guide/TROUBLESHOOTING.md`, `guide/MULTI_USER_WORKSPACE.md` (split from DEPLOYMENT), `dev/CLI_REFERENCE.md`, `dev/MODULES.md`, `remote-agent/README.md`.
- `docs/dev/DATABASE_SCHEMA.md` is now a domain map covering all 104 tables (40 previously undocumented); column-level reference authority is `schema/schema-postgres.sql`.
- Guard test `test_phase_b_acceptance.py` now also enforces the 8-field contract for the `acceptance_verification` phase.
- `docs/marketing/` removed along with its pitch-deck generator scripts.
## [v2.1.0] - 2026-09-27
### Upgrade Notes
- **Workspace isolation configuration changed (Issue #3446).** Package and Docker installs convert existing configs automatically; source installs and read-only mounted configs must run `scripts/convert_workspace_isolation.py` (or edit by hand) before starting 2.1.0. Isolation of interactive workspaces is configured in one block, `workspace.isolation {"level": ..., "backend": ...}`, with the backends `shared` (level `none`), `plain` / `bwrap` (`os_user`) and `local-gvisor` / `local-kata` / `opensandbox` (`sandboxed`), plus optional `tier`, `limits` (`memory` / `cpu_percent` / `tasks`), `egress_allow` and `container_webui`. The keys it replaces — `multi_user_mode`, `required_isolation_level`, `os_user_confinement`, `sandbox_tier`, `confinement_*` — are rejected (the server refuses to start and names the replacement), as are a level/backend pair that does not belong together and a backend the install method cannot provide (`OPENACE_INSTALL_METHOD`, set by the Docker image and the package installer's units; the Docker install cannot run `bwrap`/`local-*`). Existing configs are converted by `scripts/convert_workspace_isolation.py`, which the package installer runs on fresh install, local upgrade and remote upgrade (before it rewrites sudoers) and the Docker entrypoint runs on every start (a read-only mounted config is not converted: the server then names each key to replace); each old key is read the way the old server read it: a tier with a `webui_image` in `sandbox-backends.json` (which ran pods whatever `multi_user_mode` said) or `required_isolation_level: sandboxed` becomes `opensandbox` with its tier, a floor above the old mode is kept by raising the backend, and an `os_user_confinement` value the old server refused stops the install instead of being guessed. Semantics: the declared level is the floor (a degraded launch path refuses launches instead of serving on the shared account); the backend is explicit (pods only for `opensandbox`, never inferred from `webui_image`; a backend that is not ready is reported unsupported with its reason, never replaced by another; the single shared pod is gone). The capability snapshot reports the same backend names (`shared` / `plain` / `bwrap` / `local-gvisor` / `local-kata` / `opensandbox:<tier>`), `isolation_backend_shared` replaces `multi_user_mode_disabled`, and it adds `install_method` and `available_backends` (policy revision `2026-09-26.3`). The root policy `/etc/openace/webui-confine.json` keys its container sections by backend (`local-gvisor` / `local-kata` with `image` + `runtime`, top-level `docker`), and `openace-webui-confine --backend` takes the backend names. Docker: `WORKSPACE_ISOLATION_BACKEND=shared|plain|opensandbox` replaces `WORKSPACE_MULTI_USER_MODE` / `WORKSPACE_REQUIRED_ISOLATION_LEVEL`.
### Added
- Workspace isolation admin guide in English and Chinese (`docs/en/WORKSPACE_ISOLATION.md`, `docs/cn/WORKSPACE_ISOLATION.md`, Issue #3446): the six isolation modes side by side, **which modes each install method allows** (the Docker install cannot run the confined or local-container sandboxes, and why), a minimal configuration for each mode, how to verify what is in force, and the common mistakes. The isolation capability reference and the sandbox-backends reference are now bilingual pairs under `docs/en/` and `docs/cn/` (previously `docs/WORKSPACE_ISOLATION_CAPABILITIES.md`, Chinese only, and `docs/SANDBOX_BACKENDS.md`, English only); the doc-executing tests run against both languages.
- Local Kata Containers sandbox for interactive workspaces (Issue #3438): `workspace.isolation {"level": "sandboxed", "backend": "local-kata"}` is the local-container form of #3431 Option 2 on a Kata runtime (Kata >= 3.32.0, `/dev/kvm`), reported as the `sandboxed` level with backend `local-kata`. A Kata guest cannot reach host UNIX sockets over virtio-fs, so ingress and egress are multiplexed as framed streams with per-stream credit windows over the container's stdin/stdout: the container keeps `--network none`, with no bridge network and no host firewall rules, and any framing violation ends the launch. `launch --probe --backend local-kata` verifies Kata positively from the host (docker's `State.Pid` is a hypervisor whose parent is the Kata shim for that container id, and the guest kernel differs from the host's). The root policy file's `local-kata` section names the Docker runtime; `io.containerd.kata.v2` works without a `daemon.json` entry.
- Local gVisor container sandbox for interactive workspaces without Kubernetes (Issue #3431, Option 2): `workspace.isolation {"level": "sandboxed", "backend": "local-gvisor"}` runs each user's WebUI in a Docker container on a registered gVisor runtime (`runsc` with `--host-uds=open`) from a digest- or ID-pinned image, as the user's OS account (`--user` + `--group-add`), with `--network none`, a read-only image root, dropped capabilities, `no-new-privileges`, memory/CPU limits, an RLIMIT_NPROC task bound enforced by the gVisor kernel, and only the user's home, `<base>/shared` and the log dir bind-mounted. It reuses the Option 1 root entry point, reverse-tunnel ingress, allowlist egress proxy and root-owned audit log; the WebUI environment reaches the container on stdin. `launch --probe` (root) verifies the docker CLI, the runtime, the local image, a gVisor guest kernel and host UNIX-socket access; the capability contract then reports the `sandboxed` level with backend `local-gvisor` and every dimension enforced, while keeping the os_user entry-point matrix, the OS-account identity gate and the local launch form (the OpenSandbox pod form is now selected by backend, not by level). A root parent process stays alive for the container's lifetime (signal forwarding, force-removal if sudo is killed, leftover-container and run-dir cleanup), requires `fs.protected_symlinks=1`, and an in-container watchdog stops the WebUI if the host-side supervisor disappears. Egress log: equivalent IP spellings canonicalized, and summaries past the byte ceiling are written at most once a minute.
- Optional confinement for `os_user` interactive workspaces (Issue #3431, Option 1): with `workspace.isolation {"level": "os_user", "backend": "bwrap"}`, each per-user qwen-code-webui runs in a systemd scope (`MemoryMax`/`MemorySwapMax=0`/`CPUQuota`/`TasksMax`) entered through `setpriv --init-groups --no-new-privs` with all capabilities dropped, inside bubblewrap with a read-only host root, empty tmpfs over `/tmp`, `/var/tmp`, `/run` and the workspace base (only the user's home and `<base>/shared` are bound back — other homes are invisible), and a private network namespace whose only way out is a host-side proxy that allows allowlisted `host:port` pairs (the Open ACE API / LLM proxy endpoints plus `workspace.isolation.egress_allow`) and logs every decision. The root entry point `/usr/local/bin/openace-webui-confine launch` validates typed arguments, builds every systemd property itself, refuses executables not listed in the root-owned `/etc/openace/webui-confine.json`, and takes the WebUI environment on stdin so the proxy token never appears on a command line. The capability contract reports backend `bwrap` with `resources` and `network_egress` enforced; a host that cannot confine refuses the launch (the declared level is the floor) instead of serving unconfined. The package installer installs the wrapper, the policy file and a `launch`-only sudoers rule. Real-Linux acceptance: `scripts/webui_confine_acceptance.py`.
### Security
- Messages API is tenant-scoped (Issue #3440): all six endpoints in `app/routes/messages.py` (`/api/senders`, `/api/messages`, `/api/messages/count`, `/api/conversation-history`, `/api/conversation-timeline/<id>`, `/api/conversation-details/<id>`) were guarded by authentication alone, so any user of any tenant could read every tenant's senders, message content and conversations. A blueprint gate now gives global scope only to platform admins, pins everyone else (including `tenant_admin`) to their own tenant, and returns 403 for a non-platform-admin without a tenant; another tenant's conversation looks like a nonexistent one. The module-global senders cache, shared by every caller, is gone and the service cache key includes the tenant.
- `tenant_admin` is scoped to its own tenant on all 14 `/api/analysis/*` endpoints (Issue #3442): global scope was decided with `is_admin_role()`, which includes `tenant_admin`, so tenant admins saw usage, rankings, conversation stats and anomalies across all tenants. Global scope now requires `is_platform_admin_role()`. Note: tenant admins' analysis pages now show only their tenant, and the legacy `admin` role is no longer global under strict mode.
### Performance
- Admin dashboard no longer rescans `daily_messages` on every request (Issue #3424, measured on PostgreSQL 14 with 484k messages / 604k stats rows): `GET /api/trend` 91 s → 23 ms, `/api/tools` 10.8 s → 2 ms, `/api/senders` 19 s → 7 ms, `/api/hosts` 3–22 ms; the slowest parameter-less admin GET is now 25 ms (was 19 s). Fixes found on the way: `daily_stats` accumulated NULL-sender duplicate rows on every refresh (603k copies of 67 groups), which **doubled trend totals**; `needs_refresh` was permanently true; `/api/summary` returned 500 and the usage summary stopped refreshing since 2026-08-21 when a tool had rows in both sources (mixed `date`/string types on PostgreSQL); unfiltered `SELECT DISTINCT` is replaced by a loose index scan.
- Tenant-scoped `/api/tools` and `/api/hosts` walk two new partial indexes instead of scanning `daily_messages` (Issue #3424): tenant admins' `/api/hosts` 2.9 s → 12 ms, `/api/tools` up to 3.1 s → 28 ms. Migration `20260926_002` builds `idx_messages_user_tool` and `idx_messages_user_host` `CONCURRENTLY` on PostgreSQL (writes are not blocked; an INVALID leftover is rebuilt). Code deployed ahead of the migration falls back to the old query.
## [v2.0.1] - 2026-09-25
### Changed
- Python packages are published to PyPI as `open-ace-server` (`pip install open-ace-server`) via Trusted Publishing; PyPI rejects `open-ace` as too similar to the unrelated `openace` project. Import names and the `openace` CLI are unchanged. Release wheels/sdists are now named `open_ace_server-X.Y.Z*`.
- Internal: `webui_sandbox.py` split into a shared module and `webui_sandbox_opensandbox.py` (Issue #3431); no behavior change.
## [v2.0.0] - 2026-09-25
Open ACE 2.0 turns the self-hosted workspace into a multi-user, sandbox-isolated platform: interactive and autonomous agents can now run in OpenSandbox (gVisor/Kata) pods, autonomous workflows gain an independent acceptance-verification phase, and enterprise identity (SAML SSO, DingTalk/Feishu org sync) and alerting landed. It is a major version because of the upgrade-affecting changes listed below.
### Breaking Changes / Upgrade Notes
- **Python 3.10+ is required** (#1935, #1936); Python 3.9 is no longer supported. Python 3.13 is supported for source and offline installs (#2219, #2240).
- **The Docker image runs as the non-root `open-ace` user (uid 1000)** by default (#1797). Mounted `./data`, `./config` and log volumes must be writable by uid 1000; multi-user workspace mode, which needs root, must use `docker-compose.multi-user.yml` (or explicitly opt in with `user: "0"` + `OPENACE_ALLOW_ROOT_MULTI_USER=1`).
- **Set `OPENACE_ENCRYPTION_KEY` before the first restart after upgrading.** Stored secrets are now encrypted with a dedicated key; existing deployments must set it to their previous `SECRET_KEY` value, or previously encrypted secrets cannot be decrypted (#2275, #2362).
- **Kubernetes reference manifests run the web pod non-root with 3 replicas + HPA**; deployments relying on root must opt in explicitly.
- **Autonomous development is enabled by default** in Docker-generated config, and the scheduler's global concurrency default rose from 3 to 10 with a new per-user cap (#2295, #2297).
- Upgrading still requires starting from at least the `baseline_2026_06_23` revision (enforced by `scripts/check_min_revision.py`); run `alembic upgrade head` as part of the upgrade.
### Added
- **Sandboxed agent execution**: a `SandboxProvider` contract (#2022) with Legacy POSIX, Remote Machine and OpenSandbox providers; the OpenSandbox backend runs autonomous agents in gVisor or Kata pods with attestation-derived capabilities, fail-closed policy translation, CNI-enforced egress, orphan reconciliation and a TTL reaper (#2023, #3205). Per-task HOME/TMP/XDG isolation and cgroup-v2 resource policy (`scripts/setup-cgroup-v2.sh`) harden the local provider (#2020).
- **Agent state carry across ephemeral sandboxes**: the CLI session transcript is exported/imported per session line so `--resume` works for Claude Code, Codex, Qwen Code and ZCode across milestones (#3237, #3263, #3325, #3329, #3330).
- **Independent acceptance verification** for autonomous workflows: a verifier on a dedicated merged-main checkout, five mechanical gates (negative-test, legacy, call-chain, deployment, regression), scope gate, verdict aggregation, owner/admin override, in-page acceptance report viewer and resume-with-feedback (#2335, #2344, #2346, #2658, #2701, #3005).
- Evidence-based autonomous pipeline: structured command/test evidence replaces agent-prose heuristics, verify-before-act merge readiness, per-domain test-evidence requirement (shadow-first), and CI auto-repair raised to 5 attempts (#2045, #2046, #2194, #2391, #2460).
- Autonomous operations: GLM usage-window 429s pause the workflow and auto-resume at the window reset (#2709, #2714), loop-detection aborts auto-resume (#3149), lazy dependency preparation for agent worktrees (#2700), per-workflow `max_changed_files_override` with a scope-exceeded retry banner (#2309), real-time activity summary + heartbeat (#1734), final diff stats (#1717) and dedicated timeline icons for all milestone types (#3044).
- **SAML SSO provider** alongside OIDC, with frontend management (#3191, #3196).
- **DingTalk and Feishu integrations**: org sync, identity resolution for imported sessions, alert bots and a unified notification & integration settings page (#1768, #1787, #2384, #2632).
- **Alerting**: signed, asynchronous alert webhooks with duplicate-delivery cooldown, a global unread alert badge, a test-alert button and scheduler execution-health alerts (#1771, #1807, #3039, #3093, #3115, #3152).
- Personal files browser with upload/download/delete and recursive search, multi-user sudo compatible (#1779, #1878, #1928).
- ROI analysis with explicit, tenant-configurable assumptions, estimation labels and a per-user dimension; response-time metrics in trend analysis (#1757, #1854, #3092, #3111).
- Shared-project permission-change audit log and tenant-scoped OS isolation for shared projects (#2888, #3402).
- External proxy-token governance on the shared LLM-proxy path, and a default-off external identity capability probe for workspaces (#3423, #3415).
- Model selection for terminal sessions (#1976), remote projects in the WebUI project picker (#2478), a create-project button on project management (#3373), and a one-command Remote Agent launcher script (#2254).
- Kubernetes database backup CronJob with recovery documentation (#1853); migration authoring rules MIG001/MIG002 enforced in CI (#1706).
- Pre-built images are published to GitHub Container Registry on every release (`ghcr.io/open-ace/open-ace`, tags `vX.Y.Z`, `X.Y.Z`, `X.Y`, `X`, `latest`; linux/amd64). `docker-compose.yml`, the Kubernetes manifests and the docker-method installer now default to this image; previously they referenced `openace/open-ace` / `open-ace:latest`, which were never published.
- Sandboxed isolation level for interactive workspaces (Issue #3378): each user's qwen-code-webui runs in its own OpenSandbox pod instead of a local OS-account process — digest-pinned, image-allowlisted per-tier `webui_image`; identity is a per-instance token secret (no OS account, sudo chain, or host credentials — the control-plane credential denylist filters the pod env); the browser reaches the pod through a per-instance local port proxy that strips client credential headers, injects the server-resolved endpoint headers, and refuses bare CR/LF/NUL heads, non-token header names, and non-digit Content-Length values (request-side 400 / upstream-side 502); session history survives pod recycling via bounded per-user tar snapshots in an independent state root with a control-plane restore-confirmation record (an in-pod marker is never trusted as an export gate), atomic 0600 O_EXCL+fsync writes, tar validation before replace, and an unreadable-snapshot degrade path that suspends exports rather than treating the file as absent. The level is declared by a zero-pod fail-closed readiness probe (backend configured, tier exists, image pinned+allowlisted, tier API key present, no other live web process — per its heartbeat; an unreadable heartbeat root also refuses — callback URL egress-reachable); `workspace.required_isolation_level` is a floor, not a target — the launch form is the strongest verified form satisfying max(pin, request), so a configured webui_image flips default launches to pods even when the entrypoint pins `os_user`. Pod lifetime is clamped to its baked-in LLM proxy token (create and every renew, UTC), tokens mint per requester on the shared single-user instance, validation requires a live pod, and the boot-probe memo (per-tier, revocable, 1h TTL) upgrades the kernel dimension only on a positive gVisor identification and network_egress only on sidecar attestation — negative-only verdicts stay unsupported with explicit reason codes. Multi-replica safety: per-process heartbeat files (keyed by a per-process generation, not (boot_id, pid), which same-node containers share) are refreshed by a dedicated fixed-cadence 300s timer and removed at process shutdown (gunicorn worker_exit + atexit); the orphan reconcile refuses to sweep while a peer heartbeat is fresh or unreadable, and each orphan is isolated so one unreachable execd cannot abort the round. Server-returned sandbox ids are validated before joining control-plane record paths; the restore-confirmation record is written only for loaded/absent restore sources (never a degraded unreadable-snapshot start) and cleared only after a confirmed delete. The capability contract (policy revision 2026-09-12.2) reports the sandboxed level with per-level entry-point statuses (`sandboxed_entry_not_wired` for terminal/vscode/filesystem_api) and provisional markers on cold workers. See `docs/en/WORKSPACE_ISOLATION_CAPABILITIES.md` §6 for deployment requirements, the TTL chain, and the honest-limitation list (per-pod kernel/egress verification resets on control-plane restart, a failed probe on the tier, or the memo's 1h TTL; entry-point wiring and real-cluster end-to-end validation remain follow-ups #3379).
- Versioned capability contract for local interactive workspace multi-user isolation (Issue #3374): `GET /api/workspace/isolation-capabilities` reports what this deployment actually enforces (`local_workspace_multi_user`, `backend`, `isolation_level`, enforced/unsupported dimensions, per-entry-point coverage on supported snapshots, `policy_revision`), derived from live runtime state — including the WebUI launch-path readiness probe, so the contract and the launch gate can never disagree about the same host. The endpoint is read-only by construction (it never creates the WebUI manager, mints a token secret, or spawns cleanup greenlets). Interactive workspaces report at most `os_user` (per-user OS accounts on a shared kernel — never advertised as strong runtime isolation); probes never build (no npm runs) and privileged/reserved target accounts (uid 0 / uid < 1000, e.g. mapping `system_account` to `root`) are refused. See `docs/en/WORKSPACE_ISOLATION_CAPABILITIES.md` for semantics, reason-code reference, deployment requirements, and the audited list of remaining entry-point gaps split into follow-ups.
- Server-side isolation floor for workspace launches (Issue #3374): `GET /api/workspace/user-url` enforces `workspace.required_isolation_level` from config.json; when unset (or invalid — warned), the floor derives from what the deployment's launch-path probe actually verifies (`os_user` on healthy multi-user Docker AND package-method hosts, `none` on single-user or launch-degraded hosts). The `required_isolation` query parameter can only raise, never lower, that floor, and empty values count as absent. On probe-healthy multi-user deployments the default (parameterless) path fails closed: users without an explicit `system_account` mapping get a structured 400 (`identity_mapping_missing`) instead of a silently convention-derived launch — login no longer auto-backfills `system_account = username` in multi-user mode, no longer provisions username-convention OS accounts/homes for mapping-less users, cached instances whose launch account no longer matches the mapping are restarted under the current mapping, and the login-time background pre-start goes through the same gate (it never launches what the gate would reject). Multi-user installs now write an explicit `required_isolation_level: os_user` into config.json (docker entrypoint first-boot generation and the package installer), so an ops-induced launch-path degradation (removed wrapper, repointed webui_path) cannot silently drop per-user isolation; hosts without an explicit value derive the floor from the verified snapshot, which follows degradation downward while logging a WARNING (the derived floor is a mirror, not a hard floor — degraded hosts keep the previous default behavior rather than erroring). Rejections carry both reason namespaces (deployment-level + request-level). Single-user deployments are unaffected.
### Fixed
- Cross-user symlink escape in the file API (Issue #3410): `POST /api/fs/upload` derived its final target with `realpath(join(validated_dir, filename))`. realpath follows a symlink in the last component, and the follow-up check only compared the workspace-base prefix and dropped the home-subtree lock, so a user could place a same-named symlink in their own home pointing at another user's file and the root branch (Docker multi-user) would overwrite the victim's file (reproduced: 200 response that also echoed the victim's absolute path). Upload now descends from the home root one component at a time with `O_NOFOLLOW|O_DIRECTORY` directory fds, creates the temp file inside that fd with `O_EXCL|O_NOFOLLOW`, `fchown`s it and replaces the entry with `renameat`, which replaces the directory entry rather than a symlink target and removes the check-to-write TOCTOU window; the root branch also asserts the trust anchor with `fstat` (the home root belongs to the target account). A target name that is itself a symlink or an existing directory is an explicit 400 on both branches. **`download`, `delete-file` and `search` no longer operate by path either:** they validated the realpath and then opened, deleted or walked by path (download even opened the file in the response generator after the view returned), so under a root process swapping a component for a symlink between the check and the operation could read or delete any file on the host. They now work relative to the same component-by-component directory fd: `open(O_NOFOLLOW|O_NONBLOCK)` plus an `fstat` regular-file check (a FIFO cannot hang the request), `unlinkat`, and `os.fwalk` (symlinks themselves are skipped under root). In the non-root package install, `openace-write-as` and `openace-rm` perform every filesystem probe, write and delete after the user/prefix check through `runuser` as the target account (`openace-rm` used to run the final `rm` as root; `openace-write-as` ran its `-L`/`-d` probes as root and before the prefix check, leaking the type of arbitrary paths). `openace-write-as` also rejects symlinks (exit 5) and directories (exit 6), writes atomically with `mktemp` + `mv -fT`, and exits right after its INT/TERM trap cleans up. Both wrappers carry a capability marker, and the routes **fail closed on upload/delete** when the installed version lacks it. When a wrapper rejects early, an upload no longer turns into a 500 from manually closing an already-broken stdin. Under a root process, a user with no mapped `system_account` whose username equals another user's `system_account` no longer gets any home root, and an account named `shared` (including same-named users from SSO or org sync) never gets the shared-project namespace root `<base>/shared` as its home root in any deployment. uid resolution is a single `getpwnam` (account names starting with `-` are no longer parsed by `id` as options). Fixed in the same batch: `POST /api/fs/create-directory` gets the home-subtree lock it always lacked (same creatable set as check-path); single-file `/api/fs` paths, `/api/fs/home` and the default `search` root use per-base home roots, since on multi-base deployments (`WORKSPACE_BASE_DIR=/a,/b`) the old single-base helper produced the literal `"/a,/b/<account>"` and these endpoints all returned 400; `<base>/<account>` is tightened from 0755 to 0700 (matching `/home/<account>`; old volumes converge at startup and login provisioning, only for directories owned by that account, with the shared namespace root explicitly skipped); login provisioning under root sets owner and mode of `<base>/<account>` and its `.qwen` through directory descriptors.
- Dropped the "others" permission bits from the multi-user shared-namespace root `<base>/shared` (3775 → 3770; sticky and setgid kept): the root is group openace-shared, which every active account joins for namespace creation, but the old others r-x additionally let ANY non-member process on the host (other containers' agents, host services) enumerate the root and read shared project NAMES — metadata only (content access always required the per-tenant group), yet project names can be sensitive. Existing deployments are re-normalized on the next container boot (the provisioning is idempotent, and the unconditional chgrp+chmod also converges mode drift). Declared residual, documented in the acceptance handbooks: because namespace creation is a global right, cross-tenant members can still list the root via the group r-x — per-tenant name secrecy is not achievable in this design, while content access stays fenced by the per-tenant `openace-shared-<tenant_id>` groups (2770/660).
- Hardened the boot-time tenant shared-group convergence against two edge shapes in its `getent`-driven member filter: (1) the numeric-suffix guard for `openace-shared-<t>` groups used bare `str.isdigit()`, which ACCEPTS non-ASCII digits — a root-created `openace-shared-<unicode-digits>` lookalike passed the guard and the convergence emptied it member by member (no such tenant exists in the DB); the suffix must now be ASCII digits. (2) A transient NSS failure (`getent passwd` exit code neither 0 nor 2 — socket timeout, sssd restart, nscd hiccup) used to drop a PRESENT member from this boot's `gpasswd -M` list, silently removing their tenant content access until the next boot; the lookup is now retried once, and a member whose presence stays ambiguous after the retry skips that group's convergence entirely for this boot with a loud warning — never a list built on an unreliable answer (that either poisons `gpasswd -M` with a missing account, the shadow-utils rejection, or strips a member who is actually present). Both shapes self-heal on the next boot.
- Fresh multi-user production installs can now boot without manual migration steps (Issue #3397 — the multi-user acceptance run's declared deviation): a genuinely fresh database (no application schema AND no `alembic_version` table) used to be refused by the entrypoint, because `scripts/check_min_revision.py` exits 1 in production mode on exactly that state ("Fresh database detected in production"), so the documented DEPLOYMENT.md compose path could not start an empty database at all — the acceptance had to pre-initialize with a one-shot `alembic upgrade head && python3 scripts/init_db.py` container. The entrypoint now probes for the `alembic_version` table as well (quoted-heredoc python, so the #3399 raw-quote class cannot recur) and, when the database is provably fresh, self-initializes with the same two commands the deviation used before continuing the normal boot flow ("Fresh database detected — self-initializing schema and seed"). The branch is deliberately narrow: a database with existing schema or a recorded revision is never touched by it (the minimum-revision refusal and the regular upgrade path keep applying, and `init_db.py` still seeds only when no application schema existed at boot), and a failed probe reads as "unknown" — NOT fresh — so detection fails strict instead of bootstrapping blind over a database that might have data. **User-visible change**: a first `docker compose up` against an empty volume now starts instead of exiting; existing deployments are unaffected.- On-demand provisioning of the multi-user shared-namespace root from the API (Issue #3393, follow-up to #3379's entrypoint-only provisioning): the `openace-shared`-group sticky root `<base>/shared` was created only by the Docker entrypoint at boot, so on a deployment where the entrypoint did not run — package-method multi-user installs, or any shape where the root went missing — the FIRST shared-project creation (`POST /api/projects` with `create_dir: true` at `<base>/shared/<name>`) ran the user-side `mkdir -p` against a root-owned 0755 parent and failed 403, with no way to bootstrap the #3376 namespace from the API. `POST /api/projects` now provisions the one namespace root the project path lives under BEFORE the user-side mkdir (`app.utils.workspace.ensure_shared_namespace_root`): root-owned, `openace-shared` group (`groupadd -f` if missing, mirroring the entrypoint), mode 3770 (sticky + setgid, no others bits — the entrypoint's cross-tenant rename/inheritance semantics). The provisioning is idempotent and non-intrusive (an EXISTING root is never re-chgrp/chmod'd), a no-op outside Docker multi-user mode, fail-closed on the account-named-`shared` home collision (defense in depth under the registration-time path_guard), and any failing step degrades to a clean 5xx JSON error instead of a crash. **User-visible change**: shared-project creation bootstraps its namespace root on any deployment shape, not only Docker boots (the package installer's install-time equivalent lands separately).- Fixed (P0) the multi-user boot user-sync silently dying on every container start since 2026-09-08 (`5a20fd7bc`, auto-dev round 1): the SSH-sync hardening embedded python strings with RAW double quotes (e.g. `_log_sync_failure(username, "script_missing", ...)`) inside the bash DOUBLE-QUOTED `python3 -u -c` payload — bash stripped the quotes, split the argument at the first unquoted whitespace (~7.5KB in), and handed python a truncated program (SyntaxError, exit 1), while the mis-parsed pipeline tail kept the traceback out of `/app/logs/open-ace-user-sync.log` — zero-output silent death, the exact #3399 signature discovered by the #3385 acceptance runs (Issue #3399). Every boot's sync since that commit created NO OS accounts; deployments only appeared healthy because logins/WebUI launches provision accounts via the app-side `ensure_system_user` — the gap surfaced whenever a container was recreated (`--force-recreate`, image upgrade) and only the boot sync could restore `/etc/passwd`. The 49 payload lines are now properly `\"`-escaped, and a permanent guard test runs the invocation through REAL bash with a PATH-shimmed python3, asserting bash's expansion is byte-identical to the source extraction and parses — `bash -n` cannot catch this class (quotes stay balanced), and the functional harnesses' own unescape extraction masked it.- Install-time provisioning of the shared-namespace root in the package-method installer (Issue #3393, the installer half of the app-side on-demand fix): the package multi-user setup path (sudoers/wrappers/systemd configuration in `scripts/install-central/package-method/install.sh`) never created `<base>/shared` — the #3379 entrypoint provisioning is Docker-only, leaving package deployments dependent on the newly-added API-side fallback. The installer's multi-user section now runs `provision_shared_namespace` on fresh installs AND upgrades: per comma-separated `WORKSPACE_BASE_DIR` entry (Package default `/home`, whitespace- and trailing-slash-trimmed — no literal `a,b` directory), `as_root groupadd -f openace-shared` + `mkdir -p` + `chgrp` + `chmod 3770`, mirroring the entrypoint's semantics (sticky+setgid, no others bits). It skips loudly when the path collides with a real account named `shared` or an existing non-root-owned root, idempotently re-converges an existing root-owned root (mkdir -p no-ops, chgrp/chmod fix drift), and degrades to a WARNING on failure — the app's request-time provisioning retries. The remote deploy path (`install_deploy`) has no multi-user setup section (remote sudoers remain manual) and is unchanged; `scripts/start-multi-user.sh` only drives Docker Compose, where the entrypoint provisions inside the container — the wiring belongs in install.sh alone. **User-visible change**: a package-method multi-user install can create shared projects immediately, without waiting for the API-side on-demand fallback.- The single-user WebUI instance now honors the configured port range instead of the hardcoded 3100 (registered leftover "single-user 3100 launch limit", #3379 acceptance tail / handbook §5.8): `_start_single_user_instance` pinned `port = 3100` and `get_user_webui_url` advertised `:3100` regardless of `workspace.port_range_start`, so (1) a deployment that offsets the published range — the #3379 acceptance runs the single-user stack beside the multi-user stack with `WORKSPACE_PORT_RANGE` 13100-13200 because host 3100-3200 is held — got an instance bound on an UNPUBLISHED container port advertising an unreachable `:3100` URL (handbook §5.8's declared "not reachable at its advertised host URL"), and (2) a busy 3100 was never detected: no availability pre-check ran and the connect-based readiness probe succeeds against ANY listener, so a foreign service on 3100 would be wired in as "our webui" with a false-positive ready. The port is now the FIRST FREE port of the configured range (the same `_is_port_available` probe the multi-user `allocate_port` uses), the URL and the embedded token port follow the instance's actual port, and an exhausted range raises the same loud `ValueError` shape as the multi-user allocation. Default deployments are unchanged (the default range starts at 3100). The "many launches" reading was investigated and is not a defect: single-user mode has exactly ONE shared instance, reused idempotently by every user and never reaped — there is no per-launch allocation to exhaust. The OTHER §5.8(8) sub-item (the default admin has no OS account in the single-user container, so the sudo launch path cannot start the instance for it) remains the declared capability-separation exemption — the acceptance script's real-capability assertion (a user with `system_account=open-ace` takes the sudo-free direct branch) still guards the launch path, and the exemption is unchanged. Existing single-user tests were made port-deterministic (the availability probe is stubbed) so they no longer depend on the runner's port table. **User-visible change**: co-located stacks and offset-range single-user deployments get a reachable webui URL; a busy 3100 falls through to the next free port instead of silently wiring a foreign listener. the package multi-user setup path (sudoers/wrappers/systemd configuration in `scripts/install-central/package-method/install.sh`) never created `<base>/shared` — the #3379 entrypoint provisioning is Docker-only, leaving package deployments dependent on the newly-added API-side fallback. The installer's multi-user section now runs `provision_shared_namespace` on fresh installs AND upgrades: per comma-separated `WORKSPACE_BASE_DIR` entry (Package default `/home`, whitespace- and trailing-slash-trimmed — no literal `a,b` directory), `as_root groupadd -f openace-shared` + `mkdir -p` + `chgrp` + `chmod 3770`, mirroring the entrypoint's semantics (sticky+setgid, no others bits). It skips loudly when the path collides with a real account named `shared` or an existing non-root-owned root, idempotently re-converges an existing root-owned root (mkdir -p no-ops, chgrp/chmod fix drift), and degrades to a WARNING on failure — the app's request-time provisioning retries. The remote deploy path (`install_deploy`) has no multi-user setup section (remote sudoers remain manual) and is unchanged; `scripts/start-multi-user.sh` only drives Docker Compose, where the entrypoint provisions inside the container — the wiring belongs in install.sh alone. **User-visible change**: a package-method multi-user install can create shared projects immediately, without waiting for the API-side on-demand fallback.- Session tokens now honor `users.tokens_valid_after` — the same floor URL tokens have had since R-5 (registered leftover "session-side tokens_valid_after reuse", #3379 review follow-up): deactivation stamps the column so pre-stamp WebUI URL tokens stay dead across a later reactivation (the stamp is never cleared), but the SESSION side only had the `is_active` JOIN gate in `get_session_by_token` — which the same reactivation flips back to passing. A pre-deactivation session row therefore VALIDATED AGAIN after reactivation in two real shapes: the admin deactivation route's session revocation is fail-soft (a failed `delete_all_sessions_for_user` is logged, not fatal), and org-sync deactivations (feishu/dingtalk) write `is_active` directly and delete no session rows at all — in both, the old cookie resurrected on an account the operator had disabled, while the equivalent URL token stayed dead. `get_session_by_token` (the single funnel every session-token path goes through — before_request hooks, `/api/auth/me`, `validate_session`) now also refuses sessions whose `created_at` predates the stamp: reactivated users simply log in again and mint a fresh post-stamp session (the declared R-5 residual, aligned across both token families), and never-deactivated users are unaffected (the floor only bites when a stamp exists). Timestamp note: the comparison is a plain timestamp comparison on both dialects; SQLite stores `created_at` at second resolution while the stamp carries microseconds, so a session minted in the same second as the stamp can be refused — bounded by one second, the mirror of the URL-token side's declared same-second tolerance. **User-visible change**: deactivating an account kills its cookies for good — reactivation requires a fresh login instead of resurrecting the pre-deactivation browser session.
- The single-user WebUI instance now honors the configured port range instead of the hardcoded 3100 (registered leftover "single-user 3100 launch limit", #3379 acceptance tail / handbook §5.8): `_start_single_user_instance` pinned `port = 3100` and `get_user_webui_url` advertised `:3100` regardless of `workspace.port_range_start`, so (1) a deployment that offsets the published range — the #3379 acceptance runs the single-user stack beside the multi-user stack with `WORKSPACE_PORT_RANGE` 13100-13200 because host 3100-3200 is held — got an instance bound on an UNPUBLISHED container port advertising an unreachable `:3100` URL (handbook §5.8's declared "not reachable at its advertised host URL"), and (2) a busy 3100 was never detected: no availability pre-check ran and the connect-based readiness probe succeeds against ANY listener, so a foreign service on 3100 would be wired in as "our webui" with a false-positive ready. The port is now the FIRST FREE port of the configured range (the same `_is_port_available` probe the multi-user `allocate_port` uses), the URL and the embedded token port follow the instance's actual port, and an exhausted range raises the same loud `ValueError` shape as the multi-user allocation. Default deployments are unchanged (the default range starts at 3100). The "many launches" reading was investigated and is not a defect: single-user mode has exactly ONE shared instance, reused idempotently by every user and never reaped — there is no per-launch allocation to exhaust. The OTHER §5.8(8) sub-item (the default admin has no OS account in the single-user container, so the sudo launch path cannot start the instance for it) remains the declared capability-separation exemption — the acceptance script's real-capability assertion (a user with `system_account=open-ace` takes the sudo-free direct branch) still guards the launch path, and the exemption is unchanged. Existing single-user tests were made port-deterministic (the availability probe is stubbed) so they no longer depend on the runner's port table. **User-visible change**: co-located stacks and offset-range single-user deployments get a reachable webui URL; a busy 3100 falls through to the next free port instead of silently wiring a foreign listener. the package multi-user setup path (sudoers/wrappers/systemd configuration in `scripts/install-central/package-method/install.sh`) never created `<base>/shared` — the #3379 entrypoint provisioning is Docker-only, leaving package deployments dependent on the newly-added API-side fallback. The installer's multi-user section now runs `provision_shared_namespace` on fresh installs AND upgrades: per comma-separated `WORKSPACE_BASE_DIR` entry (Package default `/home`, whitespace- and trailing-slash-trimmed — no literal `a,b` directory), `as_root groupadd -f openace-shared` + `mkdir -p` + `chgrp` + `chmod 3770`, mirroring the entrypoint's semantics (sticky+setgid, no others bits). It skips loudly when the path collides with a real account named `shared` or an existing non-root-owned root, idempotently re-converges an existing root-owned root (mkdir -p no-ops, chgrp/chmod fix drift), and degrades to a WARNING on failure — the app's request-time provisioning retries. The remote deploy path (`install_deploy`) has no multi-user setup section (remote sudoers remain manual) and is unchanged; `scripts/start-multi-user.sh` only drives Docker Compose, where the entrypoint provisions inside the container — the wiring belongs in install.sh alone. **User-visible change**: a package-method multi-user install can create shared projects immediately, without waiting for the API-side on-demand fallback.
- Install-time provisioning of the shared-namespace root in the package-method installer (Issue #3393, the installer half of the app-side on-demand fix): the package multi-user setup path (sudoers/wrappers/systemd configuration in `scripts/install-central/package-method/install.sh`) never created `<base>/shared` — the #3379 entrypoint provisioning is Docker-only, leaving package deployments dependent on the newly-added API-side fallback. The installer's multi-user section now runs `provision_shared_namespace` on fresh installs AND upgrades: per comma-separated `WORKSPACE_BASE_DIR` entry (Package default `/home`, whitespace- and trailing-slash-trimmed — no literal `a,b` directory), `as_root groupadd -f openace-shared` + `mkdir -p` + `chgrp` + `chmod 3770`, mirroring the entrypoint's semantics (sticky+setgid, no others bits). It skips loudly when the path collides with a real account named `shared` or an existing non-root-owned root, idempotently re-converges an existing root-owned root (mkdir -p no-ops, chgrp/chmod fix drift), and degrades to a WARNING on failure — the app's request-time provisioning retries. The remote deploy path (`install_deploy`) has no multi-user setup section (remote sudoers remain manual) and is unchanged; `scripts/start-multi-user.sh` only drives Docker Compose, where the entrypoint provisions inside the container — the wiring belongs in install.sh alone. **User-visible change**: a package-method multi-user install can create shared projects immediately, without waiting for the API-side on-demand fallback.
- On-demand provisioning of the multi-user shared-namespace root from the API (Issue #3393, follow-up to #3379's entrypoint-only provisioning): the `openace-shared`-group sticky root `<base>/shared` was created only by the Docker entrypoint at boot, so on a deployment where the entrypoint did not run — package-method multi-user installs, or any shape where the root went missing — the FIRST shared-project creation (`POST /api/projects` with `create_dir: true` at `<base>/shared/<name>`) ran the user-side `mkdir -p` against a root-owned 0755 parent and failed 403, with no way to bootstrap the #3376 namespace from the API. `POST /api/projects` now provisions the one namespace root the project path lives under BEFORE the user-side mkdir (`app.utils.workspace.ensure_shared_namespace_root`): root-owned, `openace-shared` group (`groupadd -f` if missing, mirroring the entrypoint), mode 3770 (sticky + setgid, no others bits — the entrypoint's cross-tenant rename/inheritance semantics). The provisioning is idempotent and non-intrusive (an EXISTING root is never re-chgrp/chmod'd), a no-op outside Docker multi-user mode, fail-closed on the account-named-`shared` home collision (defense in depth under the registration-time path_guard), and any failing step degrades to a clean 5xx JSON error instead of a crash. **User-visible change**: shared-project creation bootstraps its namespace root on any deployment shape, not only Docker boots (the package installer's install-time equivalent lands separately).
- Fixed (P0) the multi-user boot user-sync silently dying on every container start since 2026-09-08 (`5a20fd7bc`, auto-dev round 1): the SSH-sync hardening embedded python strings with RAW double quotes (e.g. `_log_sync_failure(username, "script_missing", ...)`) inside the bash DOUBLE-QUOTED `python3 -u -c` payload — bash stripped the quotes, split the argument at the first unquoted whitespace (~7.5KB in), and handed python a truncated program (SyntaxError, exit 1), while the mis-parsed pipeline tail kept the traceback out of `/app/logs/open-ace-user-sync.log` — zero-output silent death, the exact #3399 signature discovered by the #3385 acceptance runs (Issue #3399). Every boot's sync since that commit created NO OS accounts; deployments only appeared healthy because logins/WebUI launches provision accounts via the app-side `ensure_system_user` — the gap surfaced whenever a container was recreated (`--force-recreate`, image upgrade) and only the boot sync could restore `/etc/passwd`. The 49 payload lines are now properly `\"`-escaped, and a permanent guard test runs the invocation through REAL bash with a PATH-shimmed python3, asserting bash's expansion is byte-identical to the source extraction and parses — `bash -n` cannot catch this class (quotes stay balanced), and the functional harnesses' own unescape extraction masked it.
- Tenant-scoped OS isolation for shared projects: cross-tenant shell/agent access blocked and revocation now reclaims filesystem access (Issue #3396, found by the #3385 acceptance review). Previously every account — every tenant's — joined the single global `openace-shared` group and shared project directories were group-owned by it with 2775/664, so (1) any tenant's shell could `ls`/write any other tenant's shared project at the OS layer (the API layer's browse 400s never reach the terminal/agent channel), and (2) `PUT /api/projects/<id> {is_shared: false}` flipped only the database flag — ex-members kept full OS read/write. Shared project content is now group-owned by per-tenant groups `openace-shared-<tenant_id>` with dirs 2770 / files 660 (setgid kept for group inheritance; "others" bits dropped, so accounts of other tenants — which legitimately keep the global membership for namespace creation — get EACCES), while the global `openace-shared` group survives solely as the sticky `<base>/shared` namespace-root creation group. Revocation reclaims the directory to the creator (`chown -R creator` via the audited `openace-chown` wrapper + dirs 0700 / files 0600), fail-soft with a `permission_warning` in the response. Enrollment is tenant-aware everywhere accounts are provisioned (admin create/update/restore, login, WebUI launch, scheduler sync) and the Docker entrypoint's `/home`-glob group passes were replaced by a DB-driven sync that enrolls every active, non-deleted user into the global + own-tenant groups and reconciles pre-#3396 shared directories (left on the global group by existing deployments) onto their tenant group at startup (skipped per project when already normalized — one `stat` per project on steady-state boots). Review round 2 added two boot-time guarantees to that sync: a RECLAIM pass that fixes the fail-soft hole in revocation (a revoke that timed out after the database flip, or a soft-deleted shared project, left the directory group-owned and readable by the whole tenant forever) — a not-active-and-shared project directory is chowned back to its creator and stripped to 0700/0600 on restart, but (PR #3402 review, takeover hardening) ONLY when the directory provably was that row's own shared leftover: it must sit strictly inside a workspace base dir (never a base dir or the `<base>/shared` namespace root), must not overlap (equal to / contain / lie inside) any live shared project of any tenant, must carry the row's OWN tenant group, and a legacy pre-#3396 leftover on the global group is only reclaimed at the exact first-level `<base>/shared/<name>` shape — private registration had no path validation and the tenant-scoped uniqueness check never 409s cross-tenant, so a hostile private row pointing at foreign shared state must not be auto-chowned at boot (the API now refuses such registrations too: private paths must not be a base dir, lie in the shared namespace, or overlap another tenant's project) — and #3399-class de-silencing: the sync runs under `pipefail` with `python3 -u` and a nonzero Python exit surfaces as a loud WARNING line instead of dying silently behind `tee`, while the enrollment query itself excludes soft-deleted users (`deleted_at IS NULL`, the same classification as the user sync) so a restart can no longer re-grant a deleted user the tenant group the delete path removed. Deletion, deactivation and admin tenant-moves also drop the old tenant's group so those OS rights do not outlive the DB state. PR #3402 review hardening across the same surfaces: the boot sync is now AUTHORITATIVE for tenant-group membership — every existing `openace-shared-<t>` group is converged onto the DB's exact member list (`gpasswd -M`, per-member `gpasswd -d` when the desired list is empty, since shadow-utils treats `-M ""` as a no-op), so stale memberships from tenant moves/deactivations/deletes no longer survive a restart of the scheduler container (whose autonomous agents resolve supplementary groups against its own `/etc/group`); the global `openace-shared` group is deliberately left alone (deactivated accounts keep namespace-root creation — no content access). Enrollment, removal and reclaim now trust `system_account` only — the old `or username` fallbacks could cross tenant boundaries when an unmapped username collided with another user's OS account. The boot reconcile/reclaim chmod passes are batched (`-exec chmod ... {} +` — one fork per batch instead of per entry) so a node_modules-scale first-boot normalize no longer stalls service start for tens of minutes. The admin update route enrolls only users who end up ACTIVE (a PUT without `is_active` on a deactivated row used to re-grant the tenant group the deactivation dropped); the WebUI launch path fails CLOSED on tenant-lookup errors (a new `TENANT_UNRESOLVED` sentinel enrolls the global group only, instead of fail-opening into `openace-shared-0`). The `openace-chown` wrapper no longer sources `/etc/openace/openace-chown.conf` as shell code — the entrypoint writes it as data (one prefix per line, character-filtered `[A-Za-z0-9/._-]`, `/`-entries dropped) with the write itself inside the guarded `if` so an unwritable `/etc/openace` degrades to a warning instead of crash-looping under bash 5 `set -e`, and the root-run wrapper stat-validates the conf (root-owned, no group/world write bits) before use, falling back to the built-in prefixes otherwise. **User-visible change**: cross-tenant reads/writes of shared project directories via terminals/agents are denied at the OS layer, and unsharing a project immediately strips other members' filesystem access while the creator keeps hers. Residual (documented): directories of shared projects created before this change are normalized at the next container restart (or on demand via the fix-permissions endpoint / a re-share) — until then their content stays on the old global group, while new projects and every revocation are isolated immediately.
- Pinned multi-user OS account uids across container recreation (Issue #3390, found by the #3385 acceptance review): on an image upgrade or `compose up -d --force-recreate` the container starts from a fresh `/etc/passwd` while volume directories keep their numeric owners, and the entrypoint's DB sync re-useradded only the ACTIVE users with sequentially assigned uids — so a DEACTIVATED user's old uid necessarily landed on some active account, numerically handing that account the deactivated user's 0700 `/home/<user>` and `/workspace/<user>` (#3374's "A cannot enter B's private area", triggered by every image upgrade). The new `users.system_uid` column records each account's OS uid (written back by both the app-side `ensure_system_user` and the entrypoint sync at creation/re-sync), and every (re)creation passes it to `useradd -u` so uids are stable across recreations; the entrypoint additionally creates nologin placeholder accounts for deactivated/soft-deleted users that reserve their recorded uid so it can never be reassigned (a reactivated user's placeholder is upgraded back to `/bin/bash`), and processes pinned accounts before any unpinned `useradd` so a legacy account can never take a reserved uid. A pinned uid already owned by a different account name skips with a loud ERROR instead of silently renumbering — renumbering moves the file-ownership boundary between two users and is an administrator action, never automation; the scheduler container's account sync reads the same pins, so multi-container deployments stay consistent. **Upgrade note (declared residual)**: existing rows have no recorded uid and are deliberately not backfilled (the value was never persisted; guessing would be worse) — after upgrading, the first recreation records every ACTIVE user's assigned uid, and from the second recreation on all active uids are pinned. The residual is NOT time-bounded: a legacy DEACTIVATED/soft-deleted user never gets a pin (they are not re-synced), so their directories stay on an orphan uid (owner "UNKNOWN"), and any future account creation whose uid is still unpinned at `useradd` time can land on that number and inherit those directories numerically — the recording then pins that (already inherited) uid to the new user. Known closure, now implemented: a first-boot volume scan after the user-sync (`find /home <workspace base dirs> -maxdepth 3` collecting `stat -c %u`, dirs and files) adopts each observed uid >= 1000 that no account owns as an `openace-orphan-<uid>` nologin placeholder account (`useradd -M -s /usr/sbin/nologin -u <uid>`), so an unrecorded orphan uid is never handed to a future account; the adoption is idempotent (an adopted uid resolves via getent and is skipped) and degrades to a WARNING, never aborts the boot. Also hardened in review: the sync pipeline no longer masks a Python-side death (`pipefail` + `python3 -u`, so #3399-style silent kills surface as the WARNING line), and any Python exception inside the sync now exits nonzero instead of printing one line and ending with exit 0; active users left without an OS account by a collision skip or `useradd` failure abort the sync loudly instead of quietly, but only AFTER the project-directory pass — which still runs, so one failed account no longer blocks every other user's project-dir sync (the overall exit stays nonzero). The app-side auto uid assignment now excludes the DB-recorded active pins in addition to the /etc/passwd uids (fail-soft to the old plain `useradd` if the pin query fails), so a boot-sync failure can no longer let an unpinned account steal a recorded pin; the admin user-update route provisions the OS account only after the row write succeeds — so a NEW mapping's uid write-back actually lands — and only for users who end up active, so editing a deactivated user never re-shells their nologin placeholder; and the scheduler container's account sync now skips deactivated/soft-deleted users so it no longer strips the nologin shell off their placeholder accounts.
- Provisioned the multi-user shared-project namespace root at container startup (Issue #3379, gap found by the #3374 acceptance dry run): on a fresh volume nothing created `<workspace-base>/shared`, so the first `POST /api/projects` with `create_dir: true` ran `sudo -u <user> mkdir -p` against a root:root 0755 parent and failed with 403 for every user — the #3376 first-class `<base>/shared/<name>` namespace could never bootstrap. The Docker entrypoint's multi-user block now creates `<base>/shared` per workspace base dir (comma-separated `WORKSPACE_BASE_DIR` lists are iterated for this provisioning instead of mkdir-ing a literal `a,b` directory), owned by the `openace-shared` group with mode 3775 (setgid, so shared files inherit the group; sticky, so members cannot rename or delete each other's project directories). The provisioning is idempotent, only ever touches the root itself (never user data inside it), skips with a warning when the path collides with an account named `shared` **that already exists at container start**, degrades to a warning instead of crash-looping when the filesystem refuses it (e.g. root_squash NFS), and a post-DB-sync group-enrollment pass re-adds recreated users to `openace-shared` (sessions survive container recreation, and the sync's `useradd` adds no supplementary groups — without the pass those users kept getting 403 until re-login). The app side additionally refuses to provision workspace dirs for an account named `shared` in Docker multi-user mode (that path IS the namespace root; taking it over would break shared projects for the whole tenant). **User-visible change**: fresh multi-user deployments can create shared projects immediately; existing deployments get the root on their next container restart. Remaining gap (follow-up): package-method multi-user deployments (`install.sh`, base dir `/home`) still lack the equivalent provisioning — Docker deployments are the scope of this change.
- Fixed (P0) production images crash-looping on boot since 2026-09-05 (`1a67c04a7`, #3277 regression): the frontend integrity check globbed for a hardcoded `main.*.js`, but the vite build's real entry chunk is `index.<hash>.js` (named after its `index.html` input), so every `FLASK_ENV=production` boot failed the check and the gunicorn worker exited with code 3 — compose deployments and documented setups all hit it, discovered by the #3385 acceptance workflow's first real boot (Issue #3394). The check now derives the expected entry filename from the already-validated `.vite/manifest.json` (`index.html` → `file`, with traversal containment and non-empty validation) — the manifest is the single source of truth, with no legacy `main.*.js` fallback — and still fails closed in production when no valid entry exists. The check is surfaced as `entry.js` (was the misleading `main.*.js` in startup errors; `main.js` in the `/readyz` `frontend_build` payload). The `OPENACE_SKIP_FRONTEND_CHECK=1` escape hatch now also gates `/readyz` (previously the worker booted but readiness kept returning 503 under production security mode), and the frontend CI lane runs the check against the real `npm run build` output so checker/vite drift fails a PR lane instead of production boots.
- Fixed app-side configuration path resolution to honor `OPENACE_CONFIG_DIR` before falling back to `~/.open-ace` (Issue #3110). Upgrade note for custom SQLite deployments: the module-level default `ace.db` path now follows `OPENACE_CONFIG_DIR`; move an existing `~/.open-ace/ace.db` into the configured directory before upgrading, or set `DATABASE_URL` explicitly to retain the old database path. Docker Compose deployments use PostgreSQL and are unaffected.
- Hardened workspace entry-point boundaries for multi-user deployments (Issue #3376): terminal `status`/`attach`/`stop` and VSCode `status`/`stop`/`attach` now require session ownership instead of machine-level access alone — terminal endpoints allow platform admins, same-tenant tenant admins, and the session owner; VSCode endpoints follow the code-server proxy's existing role set (platform admins, same-tenant tenant admins, same-tenant machine-level admins, and the owner, who is now the requesting user rather than the machine creator). Terminal `status` no longer returns the agent-side bridge credentials to browsers; terminal `work_dir` and VSCode `project_path` are validated server-side (absolute, no traversal, outside system directories — note this rejects remote paths under `/opt`, `/var`, `/root` etc.); `/fs/browse` and `/fs/check-path` enforce the same home-subtree lock as write operations while keeping explicitly shared project paths reachable. Review round 2 closes two follow-ups found in runtime forensics: a shared project can no longer be registered on a path inside another user's home subtree (any depth, e.g. `<workspace>/<account>/.ssh`) or outside the creator's own roots (own home subtree or an already-open shared root), and the read-side filter drops any shared row inside a user home subtree regardless of stored creator; `/fs/check-path` admissibility is narrowed from the whole workspace base dirs to the user's own home subtree, the base dir itself, and first-level non-home children of a base dir, since its `exists`/`canCreate` probes amount to one-at-a-time existence enumeration under other users' homes (multi-level mkdir -p validation remains available inside the user's own home subtree). Review round 3 makes the shared directory a first-class citizen: round 2's creation-side ownership rule and the read-side home-subtree filter accepted disjoint sets (a shared path inside the creator's own home was created but never surfaced to other tenant members, and a fresh deployment could not bootstrap its first shared root since anchoring requires one to already exist), so new shared registrations now go through a dedicated non-home namespace `<base>/shared/<name>` that both sides accept — the namespace root itself is not registrable (container, not project), a namespace colliding with a real user home (an account literally named `shared`) is rejected fail-closed with an explicit message, and pre-existing clean first-level shared rows keep anchoring nested registrations.
- Fixed WebUI workspace tokens silently invalidating when `workspace.token_secret` was absent from config.json (Issue #3377): `WebUIManager` used to generate an ephemeral random secret per process, so tokens broke on every restart — and per-request token validation (which constructs a fresh manager) could not validate tokens at all in that state. The secret is now resolved as config.json value → `<config dir>/webui_token_secret` (0600, atomically created on first use) → generated once and persisted; concurrent first-boot races converge on one winner, malformed secret files are rebuilt, and unwritable config dirs degrade to the old in-memory behavior with a warning instead of silent breakage. Deployments that set the secret via the Docker entrypoint are unaffected.
- Fixed an environment-sensitive flaky in `tests/unit/test_webui_manager_42.py::test_webui_instance`: it asserted `WebUIInstance.is_alive() is False` for the hardcoded pid 12345, which fails on busy CI runners where that pid genuinely belongs to a live process (the `os.kill(pid, 0)` probe succeeds and the failed HTTP health check stays under the 10-failure death budget). The test now stubs the signal so every `is_alive()` branch — dead pid, alive pid via the fresh health cache, and no pid — is exercised deterministically regardless of the ambient pid table (Issue #3305)
- Extended per-test hang protection beyond the unit lanes: the `postgres` (single-process against the CI DB service; a healthy run measured 306.5s of its 480s budget) and `compatibility-smoke` pytest commands now run with the same `--timeout 300 --timeout-method thread --durations 20` flags as `python-core`/`python-min`, so a hung test dumps its stacks and fails loudly instead of silently burning the suite budget; the `performance` lane deliberately keeps no per-test timeout since its benchmarks are slow by design. Also restored the gradual-slowdown signal that #3281's budget raise traded away: `scripts/ci.py` now prints a non-fatal `WARNING` (and records a `suite_budget_warning` metrics event on nightly runs) whenever a suite completes above 75% of its budget, making budget-margin erosion visible in every CI log before it turns into an intermittent timeout (Issue #3282)
- Fixed the required `test (3.11)` CI lane intermittently dying with "Command exceeded 599s" on healthy runs: the `python-core` suite budget is raised 600s→1200s (matching `python-min`, #3240) because GitHub-hosted runner variance swings the same suite between ~289s and ~535s, so the old budget sat inside the variance band; both unit lanes now also run pytest with `--timeout 300 --timeout-method thread --durations 20` so a hung test dumps its stacks and fails loudly in bounded time instead of burning the whole suite budget undiagnosably (Issue #3280)
- Fixed the remote LLM-proxy failover loop deadlocking when a key resolver ignores `exclude_key_ids` and keeps returning an already-excluded key (e.g. a non-quota upstream 429): the handler now treats that as failover-pool exhaustion and returns 502 instead of retrying the same key forever, which could hang the process (Issue #2466)
- Fixed configuration persistence in docker-compose.multi-user.yml by adding `OPENACE_CONFIG_DIR` environment variable to ensure config persists to mounted volume when running as root (Issue #2235)
- Fixed install.sh script to automatically add required multi-user mode configurations (`user: "0"`, `OPENACE_ALLOW_ROOT_MULTI_USER=1`, `OPENACE_CONFIG_DIR`) when `WORKSPACE_MULTI_USER_MODE=true`
- Improved error messages in docker-entrypoint.sh when multi-user mode is enabled without root privileges, now recommending docker-compose.multi-user.yml as the easiest fix
### Changed
- Workspace isolation capability contract `policy_revision` bumped to `2026-09-16.1` (Issue #3410): in `entry_points`, `filesystem_api` goes from `partial` to `enforced` (the fix above plus a per-operation review), and `terminal`/`vscode` move from `partial` to the new status `remote_machine_scope`. Their old partial reasons (terminal tokens not bound to the session owner, VSCode owner taken from the machine creator) were already fixed by #3376 / PR #3380 without updating the matrix, but `enforced` would be wrong too: both run on the remote machine as that machine's agent account, outside the local `os_user` level, governed by machine ACLs, session ownership and tenancy. New `entry_point_details` give each entry point its `scope`, `covered_by_isolation_level`, per-operation `roots` and `symlink_policy`, `access_control`, plus `limitations` (real gaps that count toward admission) and `residuals` (known, accepted properties that do not), so integrators can tell "meets the chosen level", "remote scope" and "real gap" apart; an unknown `status` or an unreviewed `policy_revision` must fail closed. `filesystem_api` upload/download/delete-file/search declare `symlink_policy: never_followed_with_elevated_privilege` (below the home root no symlink is followed with more privilege than the target account); other operations are `resolved_then_rejected_if_outside`; `residuals` lists the shared project root, the package-install wrapper prerequisite and unmapped users. Sandboxed snapshots keep each entry point's `limitations` and append `sandboxed_entry_not_wired`, and `filesystem_api` no longer inherits the host-tree boundary/residuals. Docs §4 (entry-point matrix) is rewritten against the current implementation with the gaps closed by #3376/#3380 removed, and §7 adds an executable admission example that a unit test runs verbatim.
- Multi-user deployment documentation now recommends using docker-compose.multi-user.yml as the primary method
- The Docker image now defaults to the non-root `open-ace` user (uid 1000) via a `USER 1000` directive in the production stage, so `docker run`, docker-compose, and Kubernetes all run non-root without relying solely on the manifest `securityContext`. Multi-user workspace mode (which needs root for `useradd`/`chown`/`sudo -u`) is now an explicit, fail-fast opt-in: run as uid 0 (`docker run --user 0` / manifest `runAsUser: 0`) and set `OPENACE_ALLOW_ROOT_MULTI_USER=1`, otherwise the entrypoint exits with a clear error instead of silently swallowing permission failures.
- Kubernetes reference manifests now run the web pod as non-root with 3 replicas, HPA, and Prometheus scrape annotations restored; multi-user workspace deployments that require root must opt in via an explicit overlay.
- Docker-generated default config now enables AI autonomous development by default and only enables multi-user workspace mode when `WORKSPACE_MULTI_USER_MODE=true`.
- Documented the encrypted-secret upgrade path: existing deployments should set `OPENACE_ENCRYPTION_KEY` to the previous `SECRET_KEY` value before first restart after the security-hardening upgrade, then rotate in a planned maintenance window if desired.
## [v1.2.0] - 2026-07-14
### Fixed
- Dark-mode contrast on Manage → Settings → Model Gateway: the status banners (the "gateway routing not enabled" warning and the "enabled" success state) had too little text/background contrast. New `--color-{success,warning,danger,info}-{bg,text}` CSS variables use low-opacity semantic backgrounds with bright text in dark mode, meeting WCAG AA (≥ 4.5:1).
### Changed
- The code-review reminder issue (auto-created every 3 weeks) now includes a health report: the workflow runs a Bandit security scan, pip-audit dependency check, single-migration-head check, frontend bundle size, and SQL lists of dead DB indexes, large tables and slow queries, and the template gains a "🛡️ Security" dimension. Every check step is `continue-on-error`, so one broken tool never blocks the issue.
- `MessageRepository.get_user_conversation_samples` replaces its per-session N+1 queries with one batched query (`IN` list + grouping by session in the application), keeping the per-session `(agent_session_id = S OR conversation_id = S)` semantics and equivalence for value collisions and rows matching both fields; if the batch fails it falls back to the old per-session loop.
- Removed the one-off `baseline_2026_06_23` cutover call from the install/upgrade paths and added `scripts/check_min_revision.py` as a minimum-revision guard; the minimum supported upgrade starting point is `baseline_2026_06_23`, and a database below that revision must be restored from a known-good backup before upgrading (Issue #1215).
## [v1.1.0] - 2026-07-02
### Added
- AI autonomous development workflow with retry, fork/cancel milestones, and PR-issue linkage (#716, #740, #886)
- Real-time agent activity streaming for autonomous workflows (Issue #771)
- Persisted remote-agent run provenance timeline with milestone TL;DR and code-change views (#988, #993)
- Batched GitHub issues and `auto_merge` for autonomous workflows
- AI GitHub account configuration for the autonomous agent (Issue #786)
- ZCode CLI support — single-shot + app-server persistent mode, settings sync, session list (#1074, #1134, #1142)
- Tenant user management and admin account creation (Issue #870) (#1238)
- User quota gate on autonomous workflows (#1265) and quota reference panel (#1230)
- Unified `session_messages.source` and transcript contract (#1125/#1128)
- Password reset with temporary password flow (Issue #865) (#1246)
- Frontend token management UI with rotate/revoke (Issue #885)
- Remote agent identity hardening with token-based auth (Issue #754)
- WebSocket relay for terminals on private networks
- Upgrade mode for existing deployments (#882)
- Date range filter on Dashboard summary
- Global custom context menu (Issue #643)
### Changed
- Schema single source of truth — authoritative `schema.sql` loaded directly (#1273, #1276)
- Baseline migration `baseline_2026_06_23` absorbs #1125/#1128 transcript columns
- Dashboard UI layout optimization and Card actions (#1085)
- Autonomous workflow timeline UI redesign and milestone compact cards (#1025, #1037)
- Shared workspace base-dir helper + macOS symlink blacklist fix (#1138)
- Removed unused management components and dead diagnostic scripts (Issue #877, #1242)
### Fixed
- Project management total count now includes smart-categorized projects (Issue #1384)
- Removed default project-category initialization in favor of smart categorization (Issue #1382)
- "View latest milestone" button in the fork view did nothing when clicked (Issue #1375) (#1380)
- Added the `autonomous_workflows.retry_count` column (Issue #1376)
- Added retries to the Sync to Gitee workflow (Issue #1378) (#1379)
- WebUI session token validation based on instance alive status (Issue #1169) (#1374)
- Project categorization derives the project name from the workspace path (Issue #1371) (#1373)
- Single-user mode uses the fixed port 3100 instead of parsing it from config.json (Issue #1357) (#1370)
- Single-user mode adds the token parameter to the iframe URL (#1369)
- Fetch scripts no longer overwrite an existing `DATABASE_URL` environment variable (Issue #1362) (#1363)
- WebUI processes connect to the main service using the IP from the request (Issue #1357) (#1358)
- Editing a user now sends `tenant_id` to the backend API (Issue #1359)
- Fixed boolean type compatibility when creating tenants on PostgreSQL (#1353)
- Replaced hard-coded skeleton-loader and terminal colors with CSS variables (Issue #1334) (#1352)
- Fixed a spurious error message caused by a race condition on page refresh (Issue #1347) (#1349)
- Unified the refresh-button style across management pages (Issue #1337) (#1348)
- Fixed the `refreshKey` configuration so refresh buttons work (Issue #1335) (#1336)
- Fixed a cursor type error when creating tenants on PostgreSQL (Issue #1341) (#1345)
- Required form fields are marked with a red asterisk (Issue #1340) (#1342)
- Insights reports use the model configured in the API key's `cli_settings` (Issue #1171) (#1216)
- Empty dropdown menus are hidden instead of opening with no items (#1332)
- Fixed English text showing in the Chinese UI of the project management page (#1326)
- auto-detect SERVER_IP to fix iframe loading failure (Issue #1306) (#1307)
- Recognize post-baseline revisions so head DBs upgrade cleanly; widen legacy `alembic_version.version_num` to VARCHAR(64) (#1281, #1282)
- Work-page quota reads `agent_sessions` only; gray-style CLI sessions (#1272)
- Auto-provision users PostgreSQL type fix (Issue #1261) (#1266)
- Package multi-user mode `useradd` permission and `WORKSPACE_BASE_DIR=/home` (Issue #1217, #1262)
- `config.json` 600 permissions to protect sensitive info (Issue #1252) (#1258)
- Schema detection for existing databases (Issue #1095) (#1254)
- SSO provider registration passes `tenant_id` (Issue #1247) (#1251)
- Upstream quota-exceeded alert in workspace (Issue #1060) (#1229)
- Deterministic score for regenerated insights reports (Issue #685) (#1227)
- Hide 2FA toggle as feature not implemented (Issue #862) (#1236)
- Workspace fetch-source fallback + `remote_sync` message_count (#1221)
## [1.0.0] - 2026-06-02
### Added
- **Self-hosted AI workspace**: Work Mode for AI sessions, prompt reuse, conversation history, and project context.
- **Remote Workspace and Remote Agent**: Run AI CLIs on development, staging, or GPU machines through a browser-managed remote agent.
- **Multi-CLI support**: Claude Code, Qwen Code, Codex, and OpenClaw adapters with session recovery, permission modes, and history sync.
- **API Key proxy**: Encrypt LLM API keys on the server and issue short-lived proxy tokens to local and remote sessions.
- **Browser development loop**: Remote terminal, directory browsing, and VSCode/code-server proxy support.
- **AI governance dashboards**: Token usage, cost analysis, quotas, alerts, anomaly detection, audit trails, and ROI visibility.
- **Compliance reporting**: Governance reports with JSON/CSV export paths and practical risk recommendations.
- **Enterprise administration**: Multi-tenant access control, roles, SSO-ready permission model, and Feishu/DingTalk integration paths.
- **Deployment options**: Docker Compose quick start, production deployment docs, Kubernetes reference, and reverse proxy guidance.
- **Open source readiness**: Apache 2.0 license, contributing guide, code of conduct, security policy, public roadmap, issue templates, CODEOWNERS, Dependabot, and beginner-friendly issue labels.
- **Bilingual documentation**: Chinese and English docs for architecture, deployment, development, API usage, integrations, Remote Workspace, Remote Agent, Kubernetes, and repository setup.
### Architecture
- Flask backend with SQLAlchemy repositories and Alembic migrations.
- React 18, TypeScript, Vite, Bootstrap 5, and Chart.js frontend.
- PostgreSQL and SQLite support for development and deployment workflows.
- Remote agent package with CLI adapters, WebSocket terminal support, and session synchronization.
### Deployment
- Docker Compose quick start with PostgreSQL.
- Source installation path for local development.
- Kubernetes deployment reference.
- Linux systemd and macOS launchd support for remote agents.
---
## Version History
| Version | Date | Description |
|---------|------|-------------|
| 2.1.0 | 2026-09-27 | One workspace.isolation config; bwrap, local gVisor and Kata sandboxes; tenant-scoping security fixes; dashboard performance |
| 2.0.1 | 2026-09-25 | First PyPI release as open-ace-server (Trusted Publishing) |
| 2.0.0 | 2026-09-25 | Sandbox-isolated multi-user platform: OpenSandbox agents, acceptance verification, SAML SSO, DingTalk/Feishu, alerting |
| 1.2.0 | 2026-07-14 | Operational hardening: minimum-revision upgrade guard, review health checks |
| 1.1.0 | 2026-07-02 | Autonomous development workflows, run provenance, Remote Agent hardening |
| 1.0.0 | 2026-06-02 | Initial public release of the self-hosted AI workspace and governance platform |
---
[v1.1.0]: https://github.com/open-ace/open-ace/releases/tag/v1.1.0
[v1.2.0]: https://github.com/open-ace/open-ace/releases/tag/v1.2.0
[v2.0.0]: https://github.com/open-ace/open-ace/releases/tag/v2.0.0
[v2.0.1]: https://github.com/open-ace/open-ace/releases/tag/v2.0.1
[v2.1.0]: https://github.com/open-ace/open-ace/releases/tag/v2.1.0
[v2.1.1]: https://github.com/open-ace/open-ace/releases/tag/v2.1.1
[Unreleased]: https://github.com/open-ace/open-ace/compare/v2.1.1...HEAD
[1.0.0]: https://github.com/open-ace/open-ace/releases/tag/v1.0.0