diff --git a/.gitea/workflows/governance.yml b/.gitea/workflows/governance.yml new file mode 100644 index 0000000..57ecdd0 --- /dev/null +++ b/.gitea/workflows/governance.yml @@ -0,0 +1,150 @@ +name: governance +on: + pull_request: + push: + branches: [main] + +jobs: + governance: + runs-on: ubuntu-latest + env: + GOPROXY: https://goproxy.cn,direct + steps: + - name: Checkout + env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} + SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }} + run: | + set -euo pipefail + # 只物化两个治理 gitlink(Guidelines 与 deps/development-manager),非递归; + # 其它 gitlink 不物化、不取回。凭据只以 URL 限定的 extraheader 存在于本进程树内。 + python3 - <<'PY_CHECKOUT' + import os + from pathlib import Path + import re + import subprocess + import sys + from urllib.parse import urlsplit + + # 服务端基址取运行器注入的 GITHUB_SERVER_URL(平台控制、候选 PR 不可影响)。 + SERVER = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/") + parsed_server = urlsplit(SERVER) + if parsed_server.scheme not in {"http", "https"} or not parsed_server.netloc: + sys.exit("Checkout server must be the platform-injected http(s) URL") + SSH = "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/" + # 审查过的白名单,独立于候选 .gitmodules:仅列出两个治理 gitlink。 + REPOSITORIES = { + "deps/development-guidelines": "Development-Guidelines", + "deps/development-manager": "Development-Manager", + } + TARGET = "weichen/Agent-System-Backend" + root_token = os.environ.pop("TOKEN", "") + component_token = os.environ.pop("SUBMODULE_TOKEN", "") + if not root_token or not component_token: + sys.exit("Checkout requires TOKEN and SUBMODULE_TOKEN") + if os.environ.get("GITHUB_REPOSITORY") != TARGET: + sys.exit("Checkout repository is outside the reviewed allowlist") + sha = os.environ.get("GITHUB_SHA", "") + if not re.fullmatch(r"[0-9a-fA-F]{40}", sha): + sys.exit("Checkout requires a fixed commit SHA") + + # 丢弃继承的 Git 追踪/配置/SSH 钩子;凭据只存在于单个 Git 进程树的精确 URL 头中。 + clean_env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")} + clean_env.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_SYSTEM="/dev/null", + GIT_CONFIG_GLOBAL="/dev/null", GIT_TERMINAL_PROMPT="0") + config = { + "core.hooksPath": "/dev/null", "core.fsmonitor": "false", + "init.templateDir": "", "credential.helper": "", + "fetch.recurseSubmodules": "false", "submodule.recurse": "false", + "http.followRedirects": "false", "protocol.allow": "never", + "protocol." + urlsplit(SERVER).scheme + ".allow": "always", + } + + + def git(repo, *args, extra=None): + values = config | (extra or {}) + env = clean_env | {"GIT_CONFIG_COUNT": str(len(values))} + for index, (key, value) in enumerate(values.items()): + env[f"GIT_CONFIG_KEY_{index}"] = key + env[f"GIT_CONFIG_VALUE_{index}"] = value + return subprocess.run(["git", "-C", str(repo), *args], env=env, + check=True, stdout=subprocess.PIPE).stdout + + + def materialize_governance(root): + # -r 列出任意深度的 gitlink,但不进入 submodule 内部;其它 gitlink 只记录、从不取回。 + links = {} + for entry in git(root, "ls-tree", "-rz", "HEAD").split(b"\0"): + if not entry: + continue + metadata, path = entry.split(b"\t", 1) + mode, kind, commit = metadata.decode().split() + if mode == "160000": + links[path.decode()] = commit + for name in REPOSITORIES: + if name not in links: + raise ValueError("Governance gitlink is missing from the fixed commit") + raw = git(root, "config", "--no-includes", "--blob", "HEAD:.gitmodules", "--null", "--list") + entries = {} + for item in raw.split(b"\0"): + if not item: + continue + key, value = item.decode().split("\n", 1) + match = re.fullmatch(r"submodule\.(.+)\.(path|url)", key) + if not match: + continue + name, field = match.groups() + if name not in REPOSITORIES: + continue + if field in entries.setdefault(name, {}): + raise ValueError("Duplicate candidate .gitmodules setting") + entries[name][field] = value + # 先整体校验两个治理条目,再进行任何带凭据的取回。 + for name, repository in REPOSITORIES.items(): + fields = entries.get(name, {}) + if fields.get("path") != name: + raise ValueError("Governance submodule path is outside the reviewed allowlist") + trusted_url = f"{SERVER}/weichen/{repository}.git" + if fields.get("url") not in {SSH + repository + ".git", trusted_url}: + raise ValueError("Governance submodule URL is outside the reviewed allowlist") + for name, repository in REPOSITORIES.items(): + url = f"{SERVER}/weichen/{repository}.git" + # 仅持久化审查过的公开 URL,使 Git 在进程级配置消失后仍识别该 submodule。 + git(root, "config", f"submodule.{name}.url", url) + git(root, "submodule", "update", "--init", "--checkout", "--", name, + extra={f"submodule.{name}.url": url, + f"submodule.{name}.update": "checkout", + f"http.{url}.extraheader": "AUTHORIZATION: token " + component_token}) + child = root / name + if git(child, "rev-parse", "HEAD").decode().strip() != links[name]: + raise ValueError("Materialized governance gitlink does not match the fixed SHA") + + + try: + root = Path.cwd() + # 必须是干净的临时工作区;不复用既有 Git 状态。 + if (root / ".git").exists(): + raise ValueError("Checkout requires a fresh workspace") + git(root, "init", "-q") + url = f"{SERVER}/{TARGET}.git" + git(root, "remote", "add", "origin", url) + git(root, "fetch", "--depth=1", "origin", sha, + extra={f"http.{url}.extraheader": "AUTHORIZATION: token " + root_token}) + git(root, "checkout", "-q", "--detach", "FETCH_HEAD") + materialize_governance(root) + except (ValueError, OSError, subprocess.CalledProcessError) as error: + sys.exit(f"Checkout refused: {error}") + print("Checkout complete; credentials were not persisted in Git configuration") + PY_CHECKOUT + unset TOKEN SUBMODULE_TOKEN + - name: Install Go 1.27.1 + run: | + set -euo pipefail + curl -fsSL -o "$RUNNER_TEMP/go.tgz" https://mirrors.aliyun.com/golang/go1.27.1.linux-amd64.tar.gz + tar -C /usr/local -xzf "$RUNNER_TEMP/go.tgz" + ln -sfn /usr/local/go/bin/go /usr/local/bin/go + ln -sfn /usr/local/go/bin/gofmt /usr/local/bin/gofmt + - name: Validate agent execution configuration + run: scripts/agent-exec validate --repo . + - name: Governance check + run: scripts/dm governance check --repo . diff --git a/.gitmodules b/.gitmodules index de8d163..c5f38bf 100644 --- a/.gitmodules +++ b/.gitmodules @@ -19,3 +19,12 @@ [submodule "deps/agent-command"] path = deps/agent-command url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Command.git +[submodule "deps/agent-document-renderer"] + path = deps/agent-document-renderer + url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Document-Renderer.git +[submodule "deps/development-guidelines"] + path = deps/development-guidelines + url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Development-Guidelines.git +[submodule "deps/development-manager"] + path = deps/development-manager + url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Development-Manager.git diff --git a/.task/archive/feature-governance-adoption.md b/.task/archive/feature-governance-adoption.md new file mode 100644 index 0000000..10ddffb --- /dev/null +++ b/.task/archive/feature-governance-adoption.md @@ -0,0 +1,47 @@ +# Agent-System-Backend 采纳 development-governance-v2 治理套件 任务卡 + +任务主记录:本文件随 PR 描述维护(PR 链接见下方"结果与验证")。 +状态:进行中;owner:weichen。 + +## 基线与授权 + +- 权威仓:weichen/Agent-System-Backend;root_target:main;immediate_target:main +- branch_kind:initiative;initiative_id:governance-adoption-Agent-System-Backend +- R0(initial_main_sha):7aa624370af347c3dd3740d7453b13af54ae3ed6;工作区:/home/weichen/projects/Agent-System-Backend-wt-feature-governance-adoption(worktree);分支:feature/governance-adoption +- 父任务信息(仅子任务填写):无 +- accepting_requirements:false(单仓采纳 initiative,范围固定为下列十步,不接收新需求) +- 需求分流:verdict=NEW;selected_initiative=无;候选=无;理由=development-governance-v2 Phase 6 每个正式仓库独立 initiative;triage 收据=无(本仓采纳前尚无 triage 入口) +- 授权与交付终点:owner 已授权 development-governance-v2 全部实施(Agent-System-Dev 主记录 docs/superpowers/plans/2026-10-16-development-governance-v2-master.md);终点为本 PR 合入 main 且采纳状态 active。分支保护与 tag 由用户执行。 +- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md;外部 Agent 一单元一新进程/会话/工作区) + +## 目标与非目标 + +目标(Phase 6 十步):固定 Guidelines SHA(deps/development-guidelines @ 2688fd2be6a52c0bf110e7e6366b6f291ab5e4eb);增加 AGENT-EXECUTION.md;增加共享执行器薄入口(deps/development-manager @ bd3a7de57a3a9e1a4495b94c5fa5c08d71e7be8c + scripts/dm、scripts/agent-exec、scripts/task);四条路由(implementation/repair/test/review);任务卡模板与分支元数据;CI 中启用 schema 校验(.gitea/workflows/governance.yml:agent-exec validate + governance check);一次正例运行;负例(缺配置、坏收据、越界路径、未知副作用);独立 Review + CI;采纳状态 active。 + +非目标:不改业务代码与既有测试;不改分支保护(由用户执行);不发布 tag。 + +引导说明:AGENT-EXECUTION.md 由 host 以固定版本治理套件(dm-server governance init @ bd3a7de57a3a9e1a4495b94c5fa5c08d71e7be8c)生成并作为引导配置提交(执行器运行前必须存在有效配置;沿用 DM 采纳先例);其余文件由实施单元生成。 + +## Agent 实施收据 + +| 单元 ID | 预期范围 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 | +|---|---|---|---|---|---|---|---|---|---| +| governance-adoption-Agent-System-Backend-r1 | 13 个允许路径(治理文件 + 两个 gitlink) | 39c17561 | implementation | cursor→antigravity→pi | run a1d7d74c(dm-server pinned,attempts=1) | cursor / not-observable | success | 472e5ab31331(apply 快进) | 否 | /tmp/gov-receipt-Agent-System-Backend-r1.json | + +## 同步记录 + +| 节点 | sync_base_sha | tested_head_sha | checked_at | 结果 | +|---|---|---|---|---| +| 启动 | 7aa624370af347c3dd3740d7453b13af54ae3ed6 | 7aa624370af347c3dd3740d7453b13af54ae3ed6 | 2026-10-17T06:53:54Z | 一致 | + +## 结果与验证(完成后填写) + +- 结果:NOT_RUN +- 验证:见下表(命令输出由 /tmp/gov-neg/ev.sh 写入 .task/evidence/governance-adoption/,SELF 除非另注)。 +- 影响与剩余项:NOT_RUN + +| 时间(UTC) | 命令 | 被测 HEAD | exit | 来源 | 摘要/位置 | +|---|---|---|---|---|---| +| 20261017T071539Z | `scripts/dm governance check --repo .` | 472e5ab31331 | 0 | SELF | ok fixtures;[输出](evidence/governance-adoption/00-20261017T071539Z.log) sha256:f7016c2e2af62a17 | +| 20261017T071539Z | `scripts/agent-exec validate --repo .` | 472e5ab31331 | 0 | SELF | validate: ok;[输出](evidence/governance-adoption/01-20261017T071539Z.log) sha256:6ded2d91ffdfe365 | +| 20261017T071539Z | `python3 /tmp/gov-neg/adopt_cases.py .` | 472e5ab31331 | 0 | SELF | [verdict] ALL PASS cases=5;[输出](evidence/governance-adoption/02-20261017T071539Z.log) sha256:2834e3639ade85b8 | diff --git a/.task/archive/feature-rdm-two-trees-project-audit.md b/.task/archive/feature-rdm-two-trees-project-audit.md new file mode 100644 index 0000000..f26b848 --- /dev/null +++ b/.task/archive/feature-rdm-two-trees-project-audit.md @@ -0,0 +1,40 @@ +# W9 Backend project audit candidate + +- 状态:已完成(合入前闭合,2026-10-18)。本 W9 增量随统一渲染器 Backend 分支经 PR #209 合入 main;合入前已同步 Backend main `56a7f9f`(合并提交 `84a6f6f`,当前 HEAD CI vet-and-test/governance 成功)。同步时 main 的迁移 36 `instance_guard` 保持不变,本分支迁移整体顺延为 37–48(文中 v33/v34 等编号为同步前历史编号),跑过旧编号未合入构建的数据库须重建。用户 2026-10-18 授权按计划完成并收尾、业务验收在部署上自行进行;受信 tag `v0.6.0` 由用户亲手发布。 +- Scope: TT-A01/A02/A04/U04, approved stage 5 W9. Stage 6 local implementation; human review, formal acceptance, PR, CI, tag, and deployment are pending. +- Branch/worktree: `feature/rdm-two-trees-project-audit` at `/home/weichen/projects/Agent-System-Backend-wt-feature-rdm-two-trees-project-audit`. +- R0: Backend authoritative `origin/main` `5d5fc9891c0eb8994545ccb8b740535d9b99fb0e`; main was clean and equal to the fetched remote before this worktree was created. Core gitlink in this worktree is `5773e74086791731059730bd3726dc8c9cd1381f`. +- Startup receipt: `/tmp/backend-w9-precheck-20261014-01.json` (SELF, exit 2, `SYNC_REQUIRED` for the separate Dev checkout `feature/rdm-increment-acceptance-note`, six commits behind Dev `origin/main`). This Backend task uses the independent-development path in Backend `AGENTS.md`; it does not write that Dev checkout or its task card. The receipt is preserved as a failure, not relabeled READY. +- Approved affected design: `rdm-two-trees-stage6-audit-link-design-adjustment-r1.md`, SHA256 `385d9d7570217ddcce9fe60a60c99dc58c9a4875ad790584a724d09a210a6e52`; user approval in the task session covers this local Backend increment, not tag publication or Stage 7 acceptance. +- Approved PostgreSQL cutoff adjustment: `rdm-two-trees-stage6-w9-pg-audit-cutoff-adjustment-r1.md`, SHA256 `2357b715a7c8f3451b34cc25a578d655e8a48f3f26d9cf7561ea7a13998ca857`; scope is isolated Backend implementation/testing, with no real database migration or release authorization. +- Approved all-writers affected design: `rdm-two-trees-stage6-w9-all-writers-cutoff-adjustment-r1.md`, SHA256 `44f6f7cd34bf95a9c1eb844bfdd70a5c2233e1b5ffc89b3091359dcb5fc3b5d8`; the direct user approval allows the local v34 correction and isolated tests only. + +## Implemented local slice + +- Optional `ProjectAuditHost` and domain `audit.read` registration. A different plugin, absent registration, or unbound actor cannot read. +- Store write snapshots the project from the owner-matched conversation; query requires the authenticated actor's owner and that immutable project. This deliberately excludes other members' private conversations and unbound historical rows, and does not move old receipts when a conversation changes projects. +- Command audit stores the current verified account display name at write time; old rows expose `not-recorded`. Public receipts omit raw payload and parameter digest. Cursor uses project-and-owner scoped source cutoff. +- Initial commit `c8dd169a1fdc1451fab3b475c14f9636a7f5bf6c` exposed only the actor-owned project slice. The next local increment fixes button actor propagation and adds a trusted domain-commit link publisher, verifier callback, exact linked metadata read, current `audit.read` and per-object metadata checks, and durable idempotency. Unlinked personal conversations remain private. +- Trusted command dispatch now creates an operation ID before calling the compiled handler and passes that ID through `extension.Request`. The handler can commit the same ID with its domain event/outbox; the final Backend audit/receipt uses it. Client `params.operationId` is ignored. An outbox link request with `HostAuditOffset=0` resolves a unique committed host row by owner, fixed project, and operation ID; a missing row returns retryable `ErrProjectAuditPending`, while duplicate rows fail closed. The separate idempotency key still controls retry equivalence. +- Independent review of `a8fa2fe` found two P2 security defects: clock-based operation IDs could collide; and `NewScopedHost(owner,"rdm")` let another compiled plugin self-report the publisher's plugin ID. The new local increment uses 192 bits from `crypto/rand` for each host-generated operation ID. Both zero-offset and explicit-offset publication require a unique committed row for owner/project/operation; a historical duplicate rejects publication, including attempts to link another private command. Verifier registration returns a private `ProjectAuditPublisherFactory` for the registering plugin's background outbox; a generic scoped Host has no publisher method. Fixed-clock concurrent IDs, forged params, ambiguous private offset, forged scoped Host, and legitimate registered publisher each have focused tests. +- The minted publisher exposes only `ProjectAuditPublisher`, not the full owner-scoped `Host`; tests reject attempts to use `NewScopedHost` with a self-reported `rdm` string as publisher authority. A publisher is a registration-time capability, while `IdempotencyKey` remains the separate retry identity. +- Exact-source linked reads do not enumerate hidden links or reveal raw payload, private conversation ID, or content. Body disclosure and a broad project link list are not implemented; the public coverage reports the narrower capability. +- A PostgreSQL-only v33 default function takes a table-specific transaction advisory lock before assigning `command_audit.id`, making supported implicit-ID writes commit in source-offset order. Migration and each PG append/project page verify CACHE 1, ascending non-cyclic positive IDs, the exact guarded default expression, and the next ID above the committed maximum; unsafe drift fails closed. SQLite remains single-writer. The owner-only project page reports `committed-prefix` only after runtime checks. v34 below closes the explicit-ID bypass; online sequence maintenance remains prohibited. +- Isolated PG RED receipt `/tmp/backend-w9-explicit-low-red-20261014-01.json` proved v33 still misreported `committed-prefix`: explicit ID 3 committed after normal ID 4 and disappeared across pages. Local v34 adds `ENABLE ALWAYS` insert and append-only mutation triggers. Every online INSERT checks READ COMMITTED, obtains the same transaction lock, and rejects a nonpositive, nonmonotone, or beyond-reserved ID. UPDATE/DELETE reject. PG append and every strong project page verify both trigger identities, exact event shape, and enablement; the existing default allocator also checks this guard. This is a local candidate, not yet independently reviewed or deployed. +- `storage-migrate` now copies `command_audit` within one ACCESS EXCLUSIVE transaction: it disables only the named insert guard, copies explicit IDs, verifies count and checksum, advances the owned sequence only forward, restores and verifies the guard, and records done before commit. Failure rolls back table data, trigger state, and done; nontransactional `setval` may leave a safe forward gap. Idempotent done checks guard health before reporting success. Other tables retain their existing path. + +## Verification and remaining work + +- The session saw RED for the missing store and host interfaces, unscoped cutoff, cross-member private disclosure, and conversation-project reassignment before the respective fixes. These RED outputs were not captured by `make evidence`, so this card does not use them as formal evidence. +- SELF receipt `/tmp/backend-w9-go-test-20261014-01.json`: `go test ./...`, exit 0, run on the pre-commit worktree based on R0. +- SELF receipt `/tmp/backend-w9-build-20261014-01.json`: `CGO_ENABLED=0 go build ./cmd/agent-system`, exit 0, run on that pre-commit tree. Generated local binary removed after the build. +- Historical SELF PostgreSQL receipt `/tmp/backend-w9-pg-cutoff-20261014-01.json`: isolated PostgreSQL 18.6 instance on port 55442 reproduced a low-ID transaction committing after a higher visible ID. `/tmp/backend-w9-pg-link-20261014-02.json` verifies the link table, retry/conflict behavior, and the pre-v33 cutoff counterexample. These receipts describe the old `offset-upper-bound-only` candidate, not the new v33 guarantee. +- SELF PostgreSQL receipt `/tmp/backend-w9-pg-migrate-20261014-01.json`: isolated SQLite→PostgreSQL migration preserved one linked receipt and advanced its target sequence; the same migration ID replayed idempotently. The temporary PG instance was stopped and removed after verification. +- SELF PostgreSQL receipt `/tmp/backend-w9-pg-operation-20261014-01.json`: a fresh isolated PG instance reran the link lookup, idempotency/conflict, and low-ID late-commit checks after trusted operation-offset resolution was added. The instance was stopped and removed. +- New PG cross-page RED receipt `/tmp/backend-w9-pg-crosspage-red-20261014-01.json`: before v33, page 1 returned offsets 4 and 2, page 2 returned 1 after offset 3 committed, and the paged view omitted 3. The same test now passes with v33. Isolated PG tests also cover CACHE 10 counterexample/rejection, CACHE 1, sequence rewind into a vacant low ID, down/up with unchanged link IDs, advisory-lock timeout and rollback gaps, and SQLite→PG explicit-ID migration. +- Historical pre-commit SELF load receipt `/tmp/backend-w9-pg-load-precommit-20261014-01.json` captured an initial v33 variant whose unlocked status function later produced false `cutoff unsafe` errors under concurrency; it is **not** valid performance acceptance evidence. The corrected status check reads committed MAX before non-MVCC sequence state and does not lock readers; the default ID allocator alone holds the transaction advisory lock and rechecks after `nextval`. `TestProjectAuditConcurrentReadWriteNeverReportsFalseDrift` and repeated PG load/cross-page runs protect that ordering. Three-condition pre-commit SELF raw receipts: `/tmp/backend-w9-pg-load-readlock-precommit-20261014-01.json` uses a test-only locked status wrapper, while `/tmp/backend-w9-pg-load-unlocked2-precommit-20261014-01.json` uses the final MAX-first unlocked status; each run also logs its v32 direct-INSERT baseline, PostgreSQL 18.6 version, 192 writes per mode/8 workers, elapsed time, p95, and throughput. The wrapper adds overhead and all measurements vary with hardware/load; no performance threshold has been approved, so they are Stage 6 cost evidence only. +- Pre-commit SELF `/tmp/backend-w9-default-drift-red-20261014-01.json` showed that a replacement default containing the guarded function name but executing unguarded `nextval` was accepted; `/tmp/backend-w9-default-drift-green-20261014-01.json` verifies exact-expression rejection. Pre-commit SELF `/tmp/backend-w9-precommit-all-20261014-02.json` and `/tmp/backend-w9-precommit-pg-20261014-02.json` passed before this last guard tightening; fixed-HEAD runs are required below. +- After the guard tightening, pre-commit SELF receipts `/tmp/backend-w9-precommit-all-final-20261014-01.json` (`go test ./... -count=1`), `/tmp/backend-w9-precommit-pg-final-20261014-01.json` (isolated PG store/storage-migrate), and `/tmp/backend-w9-precommit-pg-race-20261014-01.json` (cross-page/concurrent read-write with race detector) all exited 0. These still identify the dirty pre-commit tree, so they do not replace fixed-HEAD verification. +- v34 pre-commit SELF `/tmp/backend-w9-allwriters-load-precommit-20261014-01.json` records isolated PostgreSQL 18.6, three runs of 192 writes per mode with 8 workers, comparing v32 direct INSERT with v34 guarded `Store.AppendAudit`; it also logs three mixed runs of 180 writes with 6 writers and 160 200-row pages with 4 readers. Protected write throughput ranged about 2.7–3.0k/s, write p95 6.4–6.6ms, and mixed large-page p95 1.2–1.9ms on this one host. These are SELF cost signals with no approved threshold and no Stage 7 acceptance meaning; fixed-HEAD tests still follow. +- Integration constraint: this branch uses schema v31 (audit attribution), v32 (links), v33 (PG implicit cutoff), and v34 (all-writer guard), while the independent W6 Backend branch also has a local v31. Neither branch has consumed the other's unmerged work. After the first branch lands, the later branch owner must sync the new main, renumber its unpublished migrations without rewriting an already released version, and rerun fresh/old SQLite and PG migration plus combined regression on the actual merged source. These isolated tests do not prove W6+W9 compatibility. +- Required next W9 work: real RDM committed-event verifier/outbox consumer, Backend project-event source, run ID propagation, model/tool/background receipts, body/detail disclosure, broad authorized linked-operation pagination, history/export, and combined product verification remain unfinished. A domain commit followed by a lost Backend audit append leaves the link pending; recovery must retain and reconcile that unknown state without claiming a host receipt. Exact-link and owner-only paging do not satisfy Stage 7 audit acceptance. PostgreSQL v33 adds a global command-audit append lock; its measured wait/throughput cost has no approved performance threshold and needs human Stage 6 review. diff --git a/.task/evidence/governance-adoption/00-20261017T071539Z.log b/.task/evidence/governance-adoption/00-20261017T071539Z.log new file mode 100644 index 0000000..9b80328 --- /dev/null +++ b/.task/evidence/governance-adoption/00-20261017T071539Z.log @@ -0,0 +1,19 @@ +command: scripts/dm governance check --repo . +cwd: /home/weichen/projects/Agent-System-Backend-wt-feature-governance-adoption +head: 472e5ab313318181f1ecad1a351a0c612c453ac0 +exit: 0 +utc: 20261017T071539Z +--- +ok gitlink deps/development-guidelines +ok gitlink deps/development-manager +ok agent-execution +ok file AGENTS.md +ok file PROJECT.md +ok file policy/guidelines-adoption.md +ok file .task/templates/task-card.md +ok executable scripts/agent-exec +ok executable scripts/hooks/pre-commit +ok executable scripts/dm +ok executable scripts/task +ok adoption status +ok fixtures diff --git a/.task/evidence/governance-adoption/01-20261017T071539Z.log b/.task/evidence/governance-adoption/01-20261017T071539Z.log new file mode 100644 index 0000000..be8369e --- /dev/null +++ b/.task/evidence/governance-adoption/01-20261017T071539Z.log @@ -0,0 +1,7 @@ +command: scripts/agent-exec validate --repo . +cwd: /home/weichen/projects/Agent-System-Backend-wt-feature-governance-adoption +head: 472e5ab313318181f1ecad1a351a0c612c453ac0 +exit: 0 +utc: 20261017T071539Z +--- +validate: ok diff --git a/.task/evidence/governance-adoption/02-20261017T071539Z.log b/.task/evidence/governance-adoption/02-20261017T071539Z.log new file mode 100644 index 0000000..b9c8f7d --- /dev/null +++ b/.task/evidence/governance-adoption/02-20261017T071539Z.log @@ -0,0 +1,13 @@ +command: python3 /tmp/gov-neg/adopt_cases.py . +cwd: /home/weichen/projects/Agent-System-Backend-wt-feature-governance-adoption +head: 472e5ab313318181f1ecad1a351a0c612c453ac0 +exit: 0 +utc: 20261017T071539Z +--- +[object] Agent-System-Backend-wt-feature-governance-adoption HEAD=472e5ab313318181f1ecad1a351a0c612c453ac0 +PASS positive-run-apply: run exit=0 terminal=success apply exit=0 head=cb5172f341c9 +PASS missing-config: run exit=2 terminal=invalid-config validate exit=2 +PASS bad-receipt: apply exit=4 head_unchanged=True msg=apply: no local integrity record: changed paths do not match the transport diff +PASS out-of-scope-path: run exit=5 terminal=scope-expansion transport=False +PASS unknown-side-effect: run exit=4 terminal=failed-unknown transport=False +[verdict] ALL PASS cases=5 diff --git a/.task/feature-regulations-kb-ext20.md b/.task/feature-regulations-kb-ext20.md new file mode 100644 index 0000000..7f2f349 --- /dev/null +++ b/.task/feature-regulations-kb-ext20.md @@ -0,0 +1,30 @@ +# feature/regulations-kb-ext20 任务卡 + +任务主记录:本文件随 PR 描述维护。 +状态:已完成(合入前闭合);owner:weichen。PR:https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Backend/pulls/212 + +## 基线与授权 + +- 权威仓:ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-Backend.git;root_target:main;immediate_target:main +- branch_kind:standalone;跨仓总任务:Agent-System-Dev `feature/regulations-kb`([Dev PR #614](https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Dev/pulls/614)) +- R0(initial_main_sha):cac60c0;工作区:/home/weichen/projects/Agent-System-Backend-wt-feature-regulations-kb-ext20;分支:feature/regulations-kb-ext20 +- 授权与交付终点:用户 2026-10-18 /goal 指示按法规知识库计划实现全部内容;本任务交付 U9 的基座公开契约 EXT-20(提案:Documents `docs/proposals/2026-10-18-ext-20-regulations-provider.md`,分支 `feature/regulations-kb-u9-contract`),经 PR 合入 main。不含法规提供方实现(属 KB 仓)与 RDM 消费(U6)。 +- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md) + +## 目标与非目标 + +- 目标:`pkg/extension` 新增 `RegulationsProvider`/`RegulationsRegistrar`/`RegulationsHost` 与 DTO;进程内注册槽仅接受插件 `kbreg` 且只注册一次;分发 Host 实现四个方法并强制调用方身份、查询最小化(机密词拒绝、长度限制)、状态透传与无明文审计;快照、边界与契约/兼容测试。 +- 非目标:不实现搜索或语料检索;不改 `KnowledgeSearch` 语义。 + +## Agent 实施收据 + +| 单元 ID | 预期范围 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 | +|---|---|---|---|---|---|---|---|---|---| +| backend-ext20-regulations-provider | pkg/extension、internal/app | implementation | cursor, antigravity, pi | 1 | cursor-agent-stable / auto(session not-observable,新进程) | success | refs/agent-exec/cfc2e055f212789112cf11716f5649c5/c04b36e76696 → aaed108(apply 快进) | 否 | /tmp/regkb-u9-receipt.json | + +## 结果与验证(完成后填写) + +- 结果:`pkg/extension/regulations.go`(EXT-20 接口、DTO、状态常量、`ErrRegulationsUnavailable`)、`surface.txt` 与 `SurfaceVersion` 2.15.0-experimental;`internal/app/regulations.go` 注册槽(仅 kbreg、一次、非 nil;声明式拒绝)与 inprocHost 四方法(有效账户检查、调用方取自分发上下文、schema 校验、查询去空白/1–256 字符/排除词拒绝且不调用提供方、状态透传、审计仅含修订/计数/状态/查询 sha256);单元与兼容测试。 +- 验证(SELF,host 于 HEAD aaed108):`go vet ./pkg/extension/ ./internal/app/` 与 `go test ./pkg/extension/... ./internal/app/...` PASS;全量 `GOWORK=off go vet ./... && go test -count=1 ./...` 40 包 ok,仅 `pkg/identity TestDSNFileMode` 失败——在未改动基线 cac60c0 上同样失败(本机环境问题,本变更未触及 pkg/identity),以 CI(GATE)为准。 +- Review:host 主模型审阅实际 diff(base 3187a00 / head aaed108),与提案第 2、4、5 节一致,无阻塞项。 +- 影响与剩余项:新增可选能力,未注册提供方时返回不可用,既有行为不变。 diff --git a/.task/fix-ci-pg-flaky-tests.md b/.task/fix-ci-pg-flaky-tests.md new file mode 100644 index 0000000..9a5df6b --- /dev/null +++ b/.task/fix-ci-pg-flaky-tests.md @@ -0,0 +1,85 @@ +# fix/ci-pg-flaky-tests 任务卡 + +任务主记录:本文件随 Backend PR 描述维护;阻塞来源为 Dev PR #651。 +状态:已完成(合入前闭合);owner:weichen + +## 基线与授权 + +- 权威仓:ssh://git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-Backend.git;root_target:main;immediate_target:main +- branch_kind:standalone;initiative_id: +- R0(initial_main_sha):b5eb89c557e69d532c8d0a6e2e2cbe0258a7f086;工作区:/home/weichen/projects/Agent-System-Backend-wt-fix-ci-pg-flaky-tests(worktree);分支:fix/ci-pg-flaky-tests +- 父任务信息(仅子任务填写):无 +- accepting_requirements:false +- 需求分流:verdict=NEW;selected_initiative=无;候选=无;理由=no accepting initiative;triage 收据=/tmp/backend-ci-flakes-triage-20261020.json;receipt_sha256=21ec3e6337883b983cc00d6b798c30fff859a94b77e0be197744832acd9f35e1;requirement_sha256=1a3a7a2160a5c543f0ab9f7f766034de5b90a8984d99989454b340966758d50e +- 授权与交付终点:用户授权排查和修复阻塞 Dev PR #651 合入的 Backend 测试失败,按项目门禁交付 Backend 修复;不部署、不改 CI、路由或其他业务。 +- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md;外部 Agent 一单元一新进程/会话/工作区) + +## 目标与非目标 + +修复 CI 已观察到的门面卡锚点、后台完成等待、项目事件私有历史分页测试失败。保留断言与隐私边界,以真实异步完成条件消除竞态;不修改与这三个失败无关的功能、依赖或执行器。 + +## Agent 实施收据 + +按根目录 AGENT-EXECUTION.md 路由;外部 Agent 一单元一新进程/会话/隔离工作区。实施、修复与测试必须通过登记的候选执行;未配置或候选安全耗尽停在 unavailable/blocked,禁止 host 隐式代写。 + +| 单元 ID | 预期范围 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 | +|---|---|---|---|---|---|---|---|---|---| +| stabilize-ci-pg-server-tests | 原工作包文件清单与误用的 ** 模式 | repair | cursor, antigravity, pi | 1 | cursor / auto | scope-expansion | 无 | false | /tmp/backend-ci-flakes-implementation-20261020.json | + +| ci-pg-async-completion-r2 | e2e_test.go / project_events_test.go / background_test.go | repair | cursor, antigravity, pi | 1 | cursor / auto (effective not-observable) | completed (controller success) | 2ee469011e66b6bedcf6e9d87c8b1c026f7a8cef | false | /tmp/pr651-repair-r2.json | + +## 复用材料(task-start 自动检索结果摘录) + +- 相关近期提交: 命中;无则写无>。 +- 相关经验条目:<检索命中;无则写无>。 + +## 同步记录 + +| 节点 | sync_base_sha | tested_head_sha | checked_at | 结果 | +|---|---|---|---|---| +| 启动 | | | <日期时间> | <一致/已同步> | +| 验收前 | <填写> | <填写> | <填写> | <填写> | + +字段没有发生的写"无",不省略。本卡与 PR 描述同源:开 PR 时将"目标与非目标、结果与验证、影响与剩余项"复制进 PR 描述,不另写第二份。 + +## 交付调度(有多个就绪任务时) + +- 调度记录 / entry_id / 当前 attempt_id:<平台或调度服务链接及标识;未入队写无>。 +- 入队源 HEAD / owner / 依赖:<完整 SHA、身份、依赖或无>。 +- 固定候选引用:<调度尝试中的 base/head、组件与配置;未固定写无>。 +- 窗口、最终 CI、Review 与真实合入结果:<引用调度记录及原始证据,不复制全队列>。 + +定义见 [合入调度与 CI 分配](../standards/merge-queue.md)。本段在候选固定前整理;固定后先使用源码之外的记录保存运行结果,再按项目闭合流程回填。 + +## 结果与验证(完成后填写) + +- 结果:仅修复三个测试文件的异步完成等待;门面卡等待 run.completed 持久事件,项目分页读到共享帧即结束,后台 idle 使用独立有界等待。生产代码、CI 与断言未修改。 +- 验证:worker red/green 对照与 focused count=10 通过;host 独立相关用例 count=3、go vet ./...、go test -count=1 ./... 通过(SELF)。完整原始收据由 Dev make evidence 捕获:/tmp/pr651-backend-host-full-20261020.json;源码对象 2ee469011e66b6bedcf6e9d87c8b1c026f7a8cef。当前最终 HEAD GATE 待运行,不以 SELF 放行。 +- 影响与剩余项:只影响测试等待;30s/20s 是基于磁盘实测及停顿注入的卡死上限,实际 CI 停顿时长仍不确定。AuthOwner 每帧持久写入的性能观察不在本次范围。旧 fail-stop 隔离现场保留。 + +| 时间(UTC) | 命令 | 被测 HEAD | exit | 来源 | 摘要/位置 | +|---|---|---|---|---|---| +| <时间> | <命令;由项目 evidence 工具写入,无工具时附命令+对象 SHA+输出 digest 并标 SELF> | | | GATE/REPLAY/SELF | <摘要/位置> | + +## 回收(task-finish) + +- 合入核对:<分支已包含于目标分支的核对输出>。 +- 未跟踪/ignored 文件处置:<归档/可再生/存疑 逐项;无则写无>。 +- 回收动作:。 + +## 恢复与重新定义单元(2026-10-20) + +- 用户在交接后明确“继续”;旧 run 290c8b13f4a4a9e5d67df066754ab3fd 已停止,不自动重试或接纳其诊断文件。 +- 现场已对账:唯一源文件差异为原隔离目录的 zz_diag_tmp_test.go;实际事件中仅有读源码、局部构建与本机测试/缓存操作,没有发现提交/推送、正式工作树修改或外部请求。原进程与现场写者已退出;隔离目录与本机诊断产物均保留。 +- 原执行器 allowed_paths 使用精确路径/目录前缀匹配,工作包的 ** 不起通配作用。新单元采用明确文件清单;诊断只能在这些现有测试中或仓外生成,禁止继续使用原会话。 +- 任务 worktree 已完整移至本卡登记位置,Git 与 development-manager 子模块路径已修复;没有删除文件。 +- Dev 启动 SELF 收据:/tmp/pr651-resume-20261020-a1.json(发现嵌套 worktree)、a2 至 a5(后续恢复检查,均未宣称通过)。原始完整文件在 /tmp。 +- 权威 Backend main 已由 API 核验并合入 c16495006dbf1cf6ee581b48d99bba1e7f9a377c;继承的 O2 改动仅为必需目标同步,不属本次修复。固定 Guidelines 与依赖已物化,配置与治理准入均通过。 +- 新单元目标:分别确认三项失败根因,在现有测试的精确路径内建立 red/green 证据;若确需修改生产逻辑,先形成能复现真实行为缺陷的失败测试。禁止弱化断言、仅拉长时间或不受控压力测试。 + +## Review 与交付 + +- 主协调者独立 Review:base c16495006dbf1cf6ee581b48d99bba1e7f9a377c,实现对象 2ee469011e66b6bedcf6e9d87c8b1c026f7a8cef;已核对真实完成事件的顺序、共享帧位于所有私有帧之后、等待仍有界、所有隐私与锚点断言保留;无阻塞发现。 +- 实施包与终态原始事件分开保存:/tmp/pr651-repair-unit-r2.json、/tmp/pr651-repair-r2.json、/tmp/agent-exec-1000/662a926e726431b930433273fc26f39a/raw-e8392e75611f.log;有效 completed 已在实际 result envelope 中核验。 +- 原始实验输出:/tmp/pr651-r2-red-anchor.txt、/tmp/pr651-r2-green-anchor-experiment.txt、/tmp/pr651-r2-stall-redgreen.txt。临时实验代码已撤除。 +- 交付:当前最终 HEAD 仍需自身 CI(ci/vet-and-test 与 governance),权威目标同步核验后经 PR 合入;GATE 与实际合入结果在源码外记录,不修改冻结候选。 diff --git a/.task/templates/task-card.md b/.task/templates/task-card.md new file mode 100644 index 0000000..e3a0237 --- /dev/null +++ b/.task/templates/task-card.md @@ -0,0 +1,66 @@ +# <任务标题> 任务卡 + +任务主记录:。 +状态:<进行中 / 待审核 / 已合入 / 已放弃>;owner:<填写>。 + +## 基线与授权 + +- 权威仓:<仓 URL 或逻辑 ID>;root_target:main;immediate_target:
+- branch_kind:;initiative_id:<稳定 ID,普通任务为空> +- R0(initial_main_sha):<完整 SHA>;工作区:<路径>(worktree / clone);分支: +- 父任务信息(仅子任务填写):parent_branch=<父分支>;parent_head_sha=;parent_pr=<父 PR> +- accepting_requirements:(仅 initiative 可为 true;冻结后为 false) +- 需求分流:verdict=;selected_initiative=<分支或无>;候选=<分支@HEAD 列表或无>;理由=<不适配/适配依据>;triage 收据=<源码外路径与摘要> +- 授权与交付终点:<本次允许的动作与终点;未授权事项写明> +- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md;外部 Agent 一单元一新进程/会话/工作区) + +## 目标与非目标 + +<预期结果、受影响入口/模块/契约;明确不做什么。> + +## Agent 实施收据 + +按根目录 AGENT-EXECUTION.md 路由;外部 Agent 一单元一新进程/会话/隔离工作区。实施、修复与测试必须通过登记的候选执行;未配置或候选安全耗尽停在 unavailable/blocked,禁止 host 隐式代写。 + +| 单元 ID | 预期范围 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 | +|---|---|---|---|---|---|---|---|---|---| +| 待定义 | — | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | — | + +## 复用材料(task-start 自动检索结果摘录) + +- 相关近期提交: 命中;无则写无>。 +- 相关经验条目:<检索命中;无则写无>。 + +## 同步记录 + +| 节点 | sync_base_sha | tested_head_sha | checked_at | 结果 | +|---|---|---|---|---| +| 启动 | | | <日期时间> | <一致/已同步> | +| 验收前 | <填写> | <填写> | <填写> | <填写> | + +字段没有发生的写"无",不省略。本卡与 PR 描述同源:开 PR 时将"目标与非目标、结果与验证、影响与剩余项"复制进 PR 描述,不另写第二份。 + +## 交付调度(有多个就绪任务时) + +- 调度记录 / entry_id / 当前 attempt_id:<平台或调度服务链接及标识;未入队写无>。 +- 入队源 HEAD / owner / 依赖:<完整 SHA、身份、依赖或无>。 +- 固定候选引用:<调度尝试中的 base/head、组件与配置;未固定写无>。 +- 窗口、最终 CI、Review 与真实合入结果:<引用调度记录及原始证据,不复制全队列>。 + +定义见 [合入调度与 CI 分配](../standards/merge-queue.md)。本段在候选固定前整理;固定后先使用源码之外的记录保存运行结果,再按项目闭合流程回填。 + +## 结果与验证(完成后填写) + +- 结果:<交付了什么;未发生写无>。 +- 验证:<结论性摘要:PASS/FAIL/SKIP/NOT_RUN 逐项;逐条证据见下表,来源标注与写入规则见 [按影响选择验证与证据](../standards/verification.md)>。 +- 影响与剩余项:<影响面、遗留风险、后续任务>。 + +| 时间(UTC) | 命令 | 被测 HEAD | exit | 来源 | 摘要/位置 | +|---|---|---|---|---|---| +| <时间> | <命令;由项目 evidence 工具写入,无工具时附命令+对象 SHA+输出 digest 并标 SELF> | | | GATE/REPLAY/SELF | <摘要/位置> | + +## 回收(task-finish) + +- 合入核对:<分支已包含于目标分支的核对输出>。 +- 未跟踪/ignored 文件处置:<归档/可再生/存疑 逐项;无则写无>。 +- 回收动作:。 diff --git a/AGENT-EXECUTION.md b/AGENT-EXECUTION.md new file mode 100644 index 0000000..0ae9b4c --- /dev/null +++ b/AGENT-EXECUTION.md @@ -0,0 +1,67 @@ +# Project Agent Execution Configuration +# 项目工程 Agent 执行路由配置 + +适用范围:定义本项目中由 AI / Agent 主导的代码实施、修复、测试与审阅所调用的 Agent CLI 路由。 +解析器识别正文中唯一一个 ```agent-execution 代码块。正文外的说明文字可根据项目需要调整。 + +通用硬规则见 Development-Guidelines 的 [Agent 执行与协作契约](deps/development-guidelines/standards/agent-execution.md)。 +本配置为项目本地路由权威,声明具体的候选 profile、尝试次数上限、超时、集成入口与豁免路径; +代码块须通过 `deps/development-guidelines/templates/schemas/agent-execution.schema.json` 校验后才能登记为 active。 +`fallback_to_host` 只能为 `never`;旧值 `all-unavailable` 仅供迁移工具读取,不能通过准入。 +下例的 CLI 名称、命令与路径均为占位,按项目实际执行器替换。 + +```agent-execution +schema: agent-execution/v1 + +execution: + fresh_process_per_unit: true + permission: run-everything + fallback_to_host: never + max_parallel: 3 + timeout_seconds: 7200 + idle_timeout_seconds: 600 + summary_bytes: 16384 + raw_output_bytes: 10485760 + max_attempts_per_profile: 3 + emergency_profile: codex + +roles: + implementation: [cursor, antigravity, pi] + repair: [cursor, antigravity, pi] + test: [pi, antigravity, cursor] + review: [pi, antigravity, cursor, codex] + +profiles: + cursor: + adapter: cursor + executable: "cursor-agent-stable" + model: auto + enabled: true + + antigravity: + adapter: antigravity + executable: agy + model: auto + enabled: true + + pi: + adapter: pi + executable: pi + model: auto + enabled: true + + codex: + adapter: codex + executable: codex + model: auto + enabled: true + +integration: + exempt_paths: [".task/**", "docs/superpowers/specs/**", "docs/superpowers/plans/**"] + transport: + command: "scripts/agent-exec apply" + admission: + command: "scripts/agent-exec admit" + cleanup: + command: "scripts/agent-exec cleanup" +``` diff --git a/AGENTS.md b/AGENTS.md index 1ac936d..9472b18 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,3 +34,21 @@ - **SSH key**:`~/.ssh/home_gitea`(ED25519,公钥指纹 `SHA256:GYXaOXIMeloQ1fKSfdJ/ndgPQzs/Ix7Hk62ekfkfQ20`,Gitea 侧密钥名 GMKtec); `~/.ssh/config` 已有 `git-ssh.u1680832.nyat.app` 主机条目,克隆/推送直接可用。 + +## Agent 执行与新项目准入(强制) + +1. 缺 Guidelines 固定版本(`deps/development-guidelines` gitlink 未就绪或 HEAD 不符)时,Agent 只能只读分析,不得实施。 +2. 缺有效 `AGENT-EXECUTION.md`(`scripts/dm governance check` 未通过)时禁止 AI 实施。 +3. 缺共享执行器(`scripts/agent-exec` 或 `deps/development-manager` 不可用)时,状态为 `blocked/unavailable`,不得改由 host 替代。 +4. 不允许 host 代写实施单元;实施、修复与测试必须经 `AGENT-EXECUTION.md` 登记的候选执行。 +5. 人类直接开发按其自身授权与项目规则进行,不受本条款约束。 +6. 分支保护与规范 tag 由用户负责设置与维护,Agent 不得代为设置。 + +入口: + +- 需求分流:`scripts/task triage discover|decide|verify` +- 任务启动:`scripts/task start …` +- PR 检查:`scripts/task pr-check …` +- Agent 执行:`scripts/agent-exec run|apply|admit|validate` + +AI 实施单元仅通过 `scripts/agent-exec run` 运行(见 AGENT-EXECUTION.md),其结果经 `scripts/agent-exec apply` 集成。 diff --git a/PROJECT.md b/PROJECT.md index 9dd72c7..f7a1594 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -9,6 +9,8 @@ - `go test ./...` — 单元 + 端到端(mock runner 全链路:流式/折叠部件/HITL 三分支/子代理任务树/工件/上传解析/中文检索/SSE 在线与重放/命令路由/插件装卸 fail-closed)。 - `CGO_ENABLED=0 go build ./cmd/agent-system` — 单二进制(静态,内嵌前端后)。 +- `scripts/agent-exec validate --repo .` — 校验 AGENT-EXECUTION.md 执行路由配置。 +- `scripts/dm governance check --repo .` — 只读核验治理文件与 gitlink(CI:.gitea/workflows/governance.yml)。 - 存储:嵌入式 SQLite(modernc.org/sqlite,纯 Go);FTS5 全文 + CJK LIKE 兜底。 - 依赖:agent-system-core(deps submodule + replace)、agent-native(runtime/llm 复用)。 diff --git a/cmd/agent-system/account_io.go b/cmd/agent-system/account_io.go new file mode 100644 index 0000000..65db6da --- /dev/null +++ b/cmd/agent-system/account_io.go @@ -0,0 +1,89 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "io" + "os" +) + +func runCapabilities(args []string) int { + fs := flag.NewFlagSet("capabilities", flag.ExitOnError) + asJSON := fs.Bool("json", false, "以 JSON 打印能力,不打开数据库") + _ = fs.Parse(args) + caps := map[string]any{ + "accountTransaction": true, + "accountAudit": true, + "accountOpsOpen": true, + "passwordStdin": true, + "dsnFile": true, + "outFile": true, + } + if !*asJSON { + fmt.Fprintln(os.Stderr, "用法:agent-system capabilities --json") + return 2 + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(caps); err != nil { + fmt.Fprintln(os.Stderr, "capabilities:", err) + return 1 + } + return 0 +} + +func resolveAccountDSN(flagDSN, dsnFile string) (string, error) { + if flagDSN != "" { + fmt.Fprintln(os.Stderr, "warning: --dsn is deprecated; use AGENT_SYSTEM_DSN or --dsn-file") + } + if dsnFile != "" { + info, err := os.Stat(dsnFile) + if err != nil { + return "", err + } + if info.Mode().Perm() != 0o600 { + return "", fmt.Errorf("dsn file %s must be mode 0600", dsnFile) + } + raw, err := os.ReadFile(dsnFile) + if err != nil { + return "", err + } + return string(trimNewline(raw)), nil + } + if env := os.Getenv("AGENT_SYSTEM_DSN"); env != "" { + return env, nil + } + return flagDSN, nil +} + +func resolveAccountPassword(flagPassword string, fromStdin bool) (string, error) { + if flagPassword != "" { + fmt.Fprintln(os.Stderr, "warning: --password is deprecated; use --password-stdin") + } + if !fromStdin { + return flagPassword, nil + } + raw, err := io.ReadAll(os.Stdin) + if err != nil { + return "", err + } + return string(trimNewline(raw)), nil +} + +func writeExclusiveSecret(path, body string) error { + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return err + } + defer f.Close() + _, err = io.WriteString(f, body+"\n") + return err +} + +func trimNewline(raw []byte) []byte { + for len(raw) > 0 && (raw[len(raw)-1] == '\n' || raw[len(raw)-1] == '\r') { + raw = raw[:len(raw)-1] + } + return raw +} diff --git a/cmd/agent-system/account_io_test.go b/cmd/agent-system/account_io_test.go new file mode 100644 index 0000000..9306498 --- /dev/null +++ b/cmd/agent-system/account_io_test.go @@ -0,0 +1,156 @@ +package main + +import ( + "agent-system-backend/internal/store" + "bytes" + "io" + "os" + "os/exec" + "path/filepath" + "testing" +) + +func accountCLI(t *testing.T, args ...string) (int, string) { + t.Helper() + out, err := os.CreateTemp(t.TempDir(), "capture-") + if err != nil { + t.Fatal(err) + } + defer out.Close() + oldOut, oldErr := os.Stdout, os.Stderr + os.Stdout = out + os.Stderr = out + defer func() { os.Stdout = oldOut; os.Stderr = oldErr }() + rc := runUser(args) + out.Seek(0, 0) + raw, err := io.ReadAll(out) + if err != nil { + t.Fatal(err) + } + return rc, string(raw) +} +func cliStore(t *testing.T) (string, *store.Store) { + t.Helper() + t.Setenv("AGENT_SYSTEM_DSN", "") + dir := t.TempDir() + s, e := store.OpenSQLite(filepath.Join(dir, "agent-system.db")) + if e != nil { + t.Fatal(e) + } + t.Cleanup(func() { s.Close() }) + if e := s.EnsureSystemIdentity("fixture", "sqlite"); e != nil { + t.Fatal(e) + } + return dir, s +} +func TestCreateAndInitializeAccountDoNotRequireCredentialOutput(t *testing.T) { + for _, sub := range []string{"create", "initialize-account"} { + t.Run(sub, func(t *testing.T) { + dir, s := cliStore(t) + if _, e := s.BootstrapFirstAdminWrite(store.AccountWrite{Owner: "root", Name: "Root", Actor: "test"}); e != nil { + t.Fatal(e) + } + args := []string{sub, "--data", dir, "--owner", "ada"} + if sub == "initialize-account" { + a, e := s.CreatePendingAccount("root", "ada", store.AccountProfile{Name: "Ada"}) + if e != nil { + t.Fatal(e) + } + args = append(args, "--user-id", a.ID, "--expected-state-version", "1", "--expected-generation", "0") + } + rc, _ := accountCLI(t, args...) + if rc != 0 { + t.Fatalf("command failed without credential output file: %d", rc) + } + }) + } +} +func TestExistingOutputAndConflictingFormatsRefuseBeforeMutation(t *testing.T) { + for _, kind := range []string{"existing", "conflicting"} { + t.Run(kind, func(t *testing.T) { + dir, s := cliStore(t) + out := filepath.Join(t.TempDir(), "secret") + args := []string{"create", "--data", dir, "--owner", "ada", "--out-file", out} + if kind == "existing" { + if e := os.WriteFile(out, []byte("existing"), 0600); e != nil { + t.Fatal(e) + } + } else { + args = append(args, "--out", filepath.Join(t.TempDir(), "json-secret")) + } + rc, _ := accountCLI(t, args...) + if rc == 0 { + t.Fatal("unsafe output selection succeeded") + } + if _, e := s.GetUser("ada"); e == nil { + t.Fatal("output refusal happened after account mutation") + } + }) + } +} + +func TestAPIKeyCreateUsesExclusivePrivateOutput(t *testing.T) { + if dir := os.Getenv("U7_APIKEY_CHILD_DIR"); dir != "" { + rc := runAPIKey([]string{"create", "--data", dir, "--owner", "ada", "--name", "fixture", "--out-file", os.Getenv("U7_APIKEY_CHILD_OUT")}) + if rc != 0 { + t.Fatal("apikey create rejected its private output destination") + } + return + } + for _, existing := range []bool{false, true} { + name := "new" + if existing { + name = "existing" + } + t.Run(name, func(t *testing.T) { + dir, st := cliStore(t) + if err := st.CreateAccount("ada", "Ada", "", false); err != nil { + t.Fatal(err) + } + out := filepath.Join(t.TempDir(), "credential") + if existing { + if err := os.WriteFile(out, []byte("preserve"), 0600); err != nil { + t.Fatal(err) + } + } + cmd := exec.Command(os.Args[0], "-test.run=^TestAPIKeyCreateUsesExclusivePrivateOutput$") + cmd.Env = append(os.Environ(), "U7_APIKEY_CHILD_DIR="+dir, "U7_APIKEY_CHILD_OUT="+out) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + if existing { + if err == nil { + t.Fatal("existing output was overwritten or mutation succeeded") + } + raw, readErr := os.ReadFile(out) + if readErr != nil || string(raw) != "preserve" { + t.Fatal("existing output was modified") + } + keys, listErr := st.ListAPIKeys("ada") + if listErr != nil || len(keys) != 0 { + t.Fatal("mutation happened before output preflight") + } + return + } + if err != nil { + t.Fatal("new private output was rejected") + } + info, statErr := os.Stat(out) + if statErr != nil || info.Mode().Perm() != 0600 { + t.Fatal("credential output missing or not private") + } + raw, readErr := os.ReadFile(out) + if readErr != nil || len(raw) == 0 { + t.Fatal("credential output empty") + } + if bytes.Contains(stdout.Bytes(), bytes.TrimSpace(raw)) { + t.Fatal("credential was written to stdout") + } + keys, listErr := st.ListAPIKeys("ada") + if listErr != nil || len(keys) != 1 { + t.Fatal("successful issue was not committed") + } + }) + } +} diff --git a/cmd/agent-system/env.go b/cmd/agent-system/env.go new file mode 100644 index 0000000..845169b --- /dev/null +++ b/cmd/agent-system/env.go @@ -0,0 +1,150 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + "time" + + "agent-system-backend/internal/execenv" +) + +func runEnv(args []string) int { + if len(args) == 0 { + fmt.Fprintln(os.Stderr, "用法:agent-system env --root ") + return 2 + } + sub := args[0] + fs := flag.NewFlagSet("env "+sub, flag.ExitOnError) + rootPath := fs.String("root", "", "环境根目录") + ttl := fs.Duration("ttl", 7*24*time.Hour, "gc 保留窗口") + bin := fs.String("bin", "", "python link 要指向的解释器") + _ = fs.Parse(args[1:]) + if *rootPath == "" { + fmt.Fprintln(os.Stderr, "env: --root 必填") + return 2 + } + root, err := execenv.Open(*rootPath) + if err != nil { + fmt.Fprintln(os.Stderr, "env:", err) + return 1 + } + ctx := context.Background() + switch sub { + case "ensure": + py, err := root.EnsurePython(ctx) + if err != nil { + fmt.Fprintln(os.Stderr, "env ensure:", err) + return 1 + } + id, err := execenv.NewBroker(root, nil, execenv.Pip{Python: py}).EnsureBase(ctx) + if err != nil { + fmt.Fprintln(os.Stderr, "env ensure:", err) + return 1 + } + fmt.Println(id) + return runDoctor(ctx, root, py, true) + case "lock": + id, err := root.CurrentBase() + if err != nil || id == "" { + fmt.Fprintln(os.Stderr, "env lock: 没有 current 基础层") + return 1 + } + lock, err := root.ReadLock(id) + if err != nil { + fmt.Fprintln(os.Stderr, "env lock:", err) + return 1 + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(lock); err != nil { + fmt.Fprintln(os.Stderr, "env lock:", err) + return 1 + } + return 0 + case "gc": + removed, err := execenv.NewBroker(root, nil, execenv.Pip{}).GC(*ttl) + if err != nil { + fmt.Fprintln(os.Stderr, "env gc:", err) + return 1 + } + for _, id := range removed { + fmt.Println(id) + } + return 0 + case "doctor": + py, ok := root.PythonBin() + if !ok { + py = "/usr/bin/python3" + } + return runDoctor(ctx, root, py, true) + case "rebase": + py, err := root.EnsurePython(ctx) + if err != nil { + fmt.Fprintln(os.Stderr, "env rebase:", err) + return 1 + } + id, err := execenv.NewBroker(root, nil, execenv.Pip{Python: py}).Rebase(ctx) + if err != nil { + fmt.Fprintln(os.Stderr, "env rebase:", err) + return 1 + } + fmt.Println(id) + return 0 + case "python": + action := "install" + if fs.NArg() > 0 { + action = fs.Arg(0) + } + switch action { + case "install": + py, err := root.EnsurePython(ctx) + if err != nil { + fmt.Fprintln(os.Stderr, "env python:", err) + return 1 + } + fmt.Println(py) + return 0 + case "link": + if *bin == "" { + fmt.Fprintln(os.Stderr, "env python link: --bin 必填") + return 2 + } + if err := root.LinkPython(*bin); err != nil { + fmt.Fprintln(os.Stderr, "env python link:", err) + return 1 + } + fmt.Println(*bin) + return 0 + default: + fmt.Fprintln(os.Stderr, "env python: install | link") + return 2 + } + default: + fmt.Fprintf(os.Stderr, "env: 未知动作 %s\n", sub) + return 2 + } +} + +func runDoctor(ctx context.Context, root *execenv.Root, py string, sandbox bool) int { + var pyPath []string + if id, err := root.CurrentBase(); err == nil && id != "" { + pyPath = append(pyPath, root.LayerDir(id)) + } + mounts := append([]string{}, pyPath...) + if prefix := root.PythonPrefix(); prefix != "" { + mounts = append(mounts, prefix) + } + if resolved, err := filepath.EvalSymlinks(py); err == nil { + py = resolved + } + if err := execenv.Doctor(ctx, py, pyPath, nil, sandbox, mounts); err != nil { + fmt.Fprintln(os.Stderr, "env doctor:", err) + return 1 + } + fmt.Println("doctor ok") + return 0 +} diff --git a/cmd/agent-system/main.go b/cmd/agent-system/main.go index f06c6f8..d10cfe7 100644 --- a/cmd/agent-system/main.go +++ b/cmd/agent-system/main.go @@ -10,11 +10,11 @@ import ( "fmt" "os" "strings" - "time" "agent-system-backend/internal/app" "agent-system-backend/internal/config" "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" "agent-system-backend/pkg/identity" ) @@ -43,9 +43,15 @@ func main() { os.Exit(runUser(os.Args[2:])) case "identity": os.Exit(runIdentity(os.Args[2:])) + case "capabilities": + os.Exit(runCapabilities(os.Args[2:])) + case "env": + os.Exit(runEnv(os.Args[2:])) + case "migrate": + os.Exit(runMigrate(os.Args[2:])) default: fmt.Fprintf(os.Stderr, "agent-system: 未知子命令 %s\n", os.Args[1]) - fmt.Fprintln(os.Stderr, "用法:agent-system [参数]") + fmt.Fprintln(os.Stderr, "用法:agent-system [参数]") os.Exit(2) } } @@ -65,20 +71,37 @@ func main() { execBackend := fs.String("exec-backend", "", "执行后端:direct(默认,现状直启)| sandbox(bwrap 隔离档:断网、只读系统根、仅会话目录可写、进程数限额)") execDepsDir := fs.String("exec-deps-dir", "", "sandbox 档只读依赖层目录(site-packages 等;空=不挂载)") execWorkspace := fs.String("exec-workspace", "", "sandbox 档工作区模式:空=直写会话目录 | overlay=冻结基底+私有 upper+CAS 回收(S0)") + execEnvRoot := fs.String("exec-env-root", "", "分层 Python 环境根(解释器、基础层、扩展层;空=不启用)") tier := fs.String("tier", "", "部署档:空=auto(DSN→PG,否则 SQLite)| local(SQLite,不允许 DSN)| team(PG fail-closed:缺 DSN/未认证拒绝启动,PG 故障拒绝就绪)") pgMaxOpen := fs.Int("pg-max-open", 0, "PG 最大打开连接(0=默认 16)") pgMaxIdle := fs.Int("pg-max-idle", 0, "PG 最大空闲连接(0=默认 8)") pgIdleSec := fs.Int("pg-conn-max-idle-sec", 0, "PG 空闲连接回收秒数(0=默认 300)") pgLifetimeSec := fs.Int("pg-conn-max-lifetime-sec", 0, "PG 连接最长生命周期秒数(0=不限)") pgQueryTimeoutSec := fs.Int("pg-query-timeout-sec", 0, "PG statement_timeout 秒数(0=不限;覆盖全部业务查询)") + adoptGuard := fs.Bool("adopt-instance-guard", false, "数据库被替换或锁文件被重建后,显式重写 SQLite 活动服务守卫") + envFile := fs.String("env-file", "", "受保护 dotenv 文件(权限 600;补齐进程环境未设置的 SUPER_ADMIN_OWNER/SUPER_ADMIN_PASSWORD;空 = 取 AGENT_SYSTEM_ENV_FILE)") + extension.RegisterCLIFlags(fs) fs.Parse(os.Args[1:]) + handled, hookErr := extension.RunCLIHooks(extension.Options{ + Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, Mock: *mock, + StorageDSN: *dsn, AuthMode: *auth, DisableExecTools: *noExec, ExecBackend: *execBackend, + }) + if hookErr != nil { + fmt.Fprintln(os.Stderr, "agent-system:", hookErr) + os.Exit(1) + } + if handled { + return + } cliSet := visited(fs) + delete(cliSet, "adopt-instance-guard") + delete(cliSet, "env-file") resolved, err := config.Load(*cfgFile, os.LookupEnv, config.Config{ Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, Mock: *mock, StorageDSN: *dsn, ArtifactsDir: *artifacts, AuthMode: *auth, Instance: *instance, InsecureListen: *insecure, AllowedHostsRaw: *allowedHosts, DisableExecTools: *noExec, - ExecBackend: *execBackend, ExecDepsDir: *execDepsDir, ExecWorkspace: *execWorkspace, + ExecBackend: *execBackend, ExecDepsDir: *execDepsDir, ExecWorkspace: *execWorkspace, ExecEnvRoot: *execEnvRoot, Tier: *tier, PGMaxOpen: *pgMaxOpen, PGMaxIdle: *pgMaxIdle, PGConnMaxIdleSec: *pgIdleSec, PGConnMaxLifetimeSec: *pgLifetimeSec, PGQueryTimeoutSec: *pgQueryTimeoutSec, }, cliSet) @@ -92,13 +115,48 @@ func main() { } os.Exit(1) } + superAdmin, err := config.LoadSuperAdmin(os.LookupEnv, *envFile, resolved.AuthEnabled()) + if err == nil { + err = app.ValidateSuperAdmin(resolved.AuthEnabled(), superAdmin) + } + if err != nil { + fmt.Fprintln(os.Stderr, "agent-system:", err) + os.Exit(1) + } + pluginDefaults, merr := extension.MergeHostOptions(extension.Options{}) + if merr != nil { + fmt.Fprintln(os.Stderr, "agent-system:", merr) + os.Exit(1) + } + disableExec := resolved.DisableExecTools + if !cliSet["disableExecTools"] { + disableExec = disableExec || pluginDefaults.DisableExecTools + } + execBackendResolved := resolved.ExecBackend + if !cliSet["execBackend"] && execBackendResolved == "" { + execBackendResolved = pluginDefaults.ExecBackend + } + extOpt := extension.Options{ + Addr: resolved.Addr, DataDir: resolved.DataDir, PluginsDir: resolved.PluginsDir, + DisableUploadIndexing: pluginDefaults.DisableUploadIndexing, + DisableConvCardMirror: pluginDefaults.DisableConvCardMirror, + DisableExecTools: disableExec, ExecBackend: execBackendResolved, + } + if err := extension.PrepareCompiled(&extOpt); err != nil { + fmt.Fprintln(os.Stderr, "agent-system:", err) + os.Exit(1) + } if err := app.Run(app.Options{ Addr: resolved.Addr, DataDir: resolved.DataDir, PluginsDir: resolved.PluginsDir, ForceMock: resolved.Mock, StorageDSN: resolved.StorageDSN, ArtifactsDir: resolved.ArtifactsDir, AuthMode: resolved.AuthMode, InstanceID: resolved.Instance, InsecureListen: resolved.InsecureListen, AllowedHosts: resolved.AllowedHosts(), - DisableExecTools: resolved.DisableExecTools, ExecBackend: resolved.ExecBackend, ExecDepsDir: resolved.ExecDepsDir, - ExecWorkspace: resolved.ExecWorkspace, - Tier: resolved.Tier, + DisableUploadIndexing: extOpt.DisableUploadIndexing, + DisableConvCardMirror: extOpt.DisableConvCardMirror, + DisableExecTools: extOpt.DisableExecTools, ExecBackend: extOpt.ExecBackend, ExecDepsDir: resolved.ExecDepsDir, + ExecWorkspace: resolved.ExecWorkspace, ExecEnvRoot: resolved.ExecEnvRoot, + Tier: resolved.Tier, + AdoptInstanceGuard: *adoptGuard, + SuperAdmin: superAdmin, PGPool: store.PoolOptions{ MaxOpenConn: resolved.PGMaxOpen, MaxIdleConn: resolved.PGMaxIdle, ConnMaxIdleTimeSec: resolved.PGConnMaxIdleSec, ConnMaxLifetimeSec: resolved.PGConnMaxLifetimeSec, @@ -122,6 +180,14 @@ func visited(fs *flag.FlagSet) map[string]bool { name = "allowedHosts" case "disable-exec-tools": name = "disableExecTools" + case "exec-backend": + name = "execBackend" + case "exec-deps-dir": + name = "execDepsDir" + case "exec-workspace": + name = "execWorkspace" + case "exec-env-root": + name = "execEnvRoot" case "pg-max-open": name = "pgMaxOpen" case "pg-max-idle": @@ -160,6 +226,7 @@ func runConfig(args []string) int { execBackend := fs.String("exec-backend", "", "") execDepsDir := fs.String("exec-deps-dir", "", "") execWorkspace := fs.String("exec-workspace", "", "") + execEnvRoot := fs.String("exec-env-root", "", "") tier := fs.String("tier", "", "") pgMaxOpen := fs.Int("pg-max-open", 0, "") pgMaxIdle := fs.Int("pg-max-idle", 0, "") @@ -171,7 +238,7 @@ func runConfig(args []string) int { Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, Mock: *mock, StorageDSN: *dsn, ArtifactsDir: *artifacts, AuthMode: *auth, Instance: *instance, InsecureListen: *insecure, AllowedHostsRaw: *allowedHosts, DisableExecTools: *noExec, - ExecBackend: *execBackend, ExecDepsDir: *execDepsDir, ExecWorkspace: *execWorkspace, + ExecBackend: *execBackend, ExecDepsDir: *execDepsDir, ExecWorkspace: *execWorkspace, ExecEnvRoot: *execEnvRoot, Tier: *tier, PGMaxOpen: *pgMaxOpen, PGMaxIdle: *pgMaxIdle, PGConnMaxIdleSec: *pgIdleSec, PGConnMaxLifetimeSec: *pgLifetimeSec, PGQueryTimeoutSec: *pgQueryTimeoutSec, }, visited(fs)) @@ -194,22 +261,47 @@ func runAPIKey(args []string) int { fs := flag.NewFlagSet("apikey "+sub, flag.ExitOnError) data := fs.String("data", "./data", "数据目录") dsn := fs.String("dsn", "", "PostgreSQL 档 DSN(空 = 嵌入式档)") + dsnFile := fs.String("dsn-file", "", "0600权限的PostgreSQL DSN文件") owner := fs.String("owner", "", "租户名(owner)") name := fs.String("name", "default", "密钥名称") + outFile := fs.String("out-file", "", "把明文密钥独占写入该文件(0600)") fs.Parse(args[1:]) if *owner == "" { fmt.Fprintln(os.Stderr, "apikey: --owner 必填") return 2 } + if sub != "create" && *outFile != "" { + fmt.Fprintln(os.Stderr, "apikey: 此子命令不生成密钥") + return 2 + } + resolvedDSN, err := resolveAccountDSN(*dsn, *dsnFile) + if err != nil { + fmt.Fprintln(os.Stderr, "apikey:", err) + return 1 + } + *dsn = resolvedDSN switch sub { case "create": + delivery, err := reserveCredentialOutput(*outFile, "") + if err != nil { + fmt.Fprintln(os.Stderr, "apikey create:", err) + return 1 + } + defer delivery.abort() key, err := app.CreateAPIKey(*dsn, *data, *owner, *name) if err != nil { fmt.Fprintln(os.Stderr, "apikey create:", err) return 1 } // 明文仅此一次输出;库中只存 SHA-256 摘要。 - fmt.Println(key) + if delivery.file == nil { + fmt.Println(key) // Existing API-key CLI compatibility; owner grants require a file. + return 0 + } + if err := delivery.deliver("api-key", *owner, key, nil); err != nil { + fmt.Fprintln(os.Stderr, "apikey create:", err) + return 1 + } return 0 case "list": keys, err := app.ListAPIKeys(*dsn, *data, *owner) @@ -238,12 +330,11 @@ func runAPIKey(args []string) int { } } -// deliverCredential 凭据单次交付(KTD14):--out 时写 0600 JSON 文件,stdout 只报 -// 属主/用途/文件路径不含明文;缺省沿用 stdout 一次输出。文件含 expiresAt 便于交接。 +// deliverCredential writes only to a new private file. The secret never goes +// to stdout or an existing path; a JSON payload may include expiresAt. func deliverCredential(outPath, kind, owner, plaintext string, expiresAt any) error { if outPath == "" { - fmt.Println(plaintext) - return nil + return fmt.Errorf("protected credential delivery path required") } payload := map[string]any{"kind": kind, "owner": owner, "secret": plaintext} if expiresAt != nil { @@ -253,37 +344,162 @@ func deliverCredential(outPath, kind, owner, plaintext string, expiresAt any) er if err != nil { return err } - if err := os.WriteFile(outPath, buf, 0o600); err != nil { + if err := writeExclusiveSecret(outPath, string(buf)); err != nil { return err } fmt.Fprintf(os.Stderr, "凭据已写入 %s(0600,仅此一次生成;勿入库勿入日志)\n", outPath) return nil } -// runUser:create / reset-password / list / issue-login-key / revoke-login-key / bootstrap-admin。 -// 默认 create 无初始密码并签发激活 Key;--generate-password 兼容旧随机密码路径。 +type credentialOutput struct { + path string + file *os.File + json bool + mutated bool +} + +// Reserve the destination before changing the account. O_EXCL also rejects an +// existing symlink, so an existing secret can never be overwritten. +func reserveCredentialOutput(rawPath, jsonPath string) (*credentialOutput, error) { + if rawPath != "" && jsonPath != "" { + return nil, fmt.Errorf("--out and --out-file are mutually exclusive") + } + out := &credentialOutput{} + out.path, out.json = rawPath, false + if jsonPath != "" { + out.path, out.json = jsonPath, true + } + if out.path == "" { + return out, nil + } + f, err := os.OpenFile(out.path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return nil, err + } + out.file = f + return out, nil +} + +func (out *credentialOutput) abort() { + if out == nil || out.file == nil { + return + } + info, err := out.file.Stat() + _ = out.file.Close() + if out.mutated || err != nil { + return + } + pathInfo, pathErr := os.Stat(out.path) + if pathErr == nil && os.SameFile(info, pathInfo) { + _ = os.Remove(out.path) + } +} + +func (out *credentialOutput) deliver(kind, owner, plaintext string, expiresAt any) error { + if out.file == nil { + return fmt.Errorf("protected credential delivery path required") + } + out.mutated = true // Keep the private recovery artifact if delivery fails after DB commit. + var body []byte + if out.json { + payload := map[string]any{"kind": kind, "owner": owner, "secret": plaintext} + if expiresAt != nil { + payload["expiresAt"] = expiresAt + } + var err error + body, err = json.MarshalIndent(payload, "", " ") + if err != nil { + return err + } + } else { + body = []byte(plaintext) + } + body = append(body, '\n') + if _, err := out.file.Write(body); err != nil { + return err + } + if err := out.file.Sync(); err != nil { + return err + } + if err := out.file.Close(); err != nil { + return err + } + out.file = nil + fmt.Fprintf(os.Stderr, "凭据已写入 %s(0600,仅此一次生成;勿入库勿入日志)\n", out.path) + return nil +} + +// runUser:本地 owner 账号初始化、续办和只读/撤销操作。 func runUser(args []string) int { if len(args) == 0 { - fmt.Fprintln(os.Stderr, "用法:agent-system user [flags]") + fmt.Fprintln(os.Stderr, "用法:agent-system user [flags]") return 2 } sub := args[0] fs := flag.NewFlagSet("user "+sub, flag.ExitOnError) data := fs.String("data", "./data", "数据目录") - dsn := fs.String("dsn", "", "PostgreSQL 档 DSN(空 = 嵌入式档)") + dsn := fs.String("dsn", "", "PostgreSQL 档 DSN(空 = 嵌入式档;已弃用,优先 AGENT_SYSTEM_DSN 或 --dsn-file)") + dsnFile := fs.String("dsn-file", "", "0600 权限的 DSN 文件") owner := fs.String("owner", "", "用户名(owner)") + userID := fs.String("user-id", "", "待初始化账号稳定ID") + expectedStateVersion := fs.Int64("expected-state-version", 0, "待初始化账号状态版本") + expectedGeneration := fs.Int64("expected-generation", -1, "待初始化账号代数") name := fs.String("name", "", "显示名") - password := fs.String("password", "", "密码(create 空=激活 Key;加 --generate-password 则随机)") - admin := fs.Bool("admin", false, "管理员") - role := fs.String("role", "user", "角色 user|admin") - openExisting := fs.Bool("open-existing", false, "只打开既有存储") - generatePassword := fs.Bool("generate-password", false, "create 时生成随机初始密码") - purpose := fs.String("purpose", "recover", "issue-login-key:activate|recover") - ttlMin := fs.Int("ttl-minutes", 30, "登录 Key 有效分钟(5–120)") - revokeAll := fs.Bool("revoke-all-credentials", false, "签发时撤销全部长期 API Key 与会话") + password := fs.String("password", "", "密码(已弃用;请用 --password-stdin)") + passwordStdin := fs.Bool("password-stdin", false, "从 stdin 读取密码") + outFile := fs.String("out-file", "", "凭据交付文件(仅历史兼容子命令可用)") + instanceID := fs.String("instance-id", "", "严格核验库内服务身份") + admin := fs.Bool("admin", false, "管理员(现已拒绝:仅超级管理员可在服务端任命管理员)") + role := fs.String("role", "user", "角色 user|admin(admin 现已拒绝)") + openExisting := fs.Bool("open-existing", false, "兼容旧参数;账户命令始终只打开既有存储") + generatePassword := fs.Bool("generate-password", false, "旧随机初始密码参数(现已拒绝)") + fs.String("purpose", "recover", "旧 issue-login-key 参数(现已拒绝)") + fs.Int("ttl-minutes", 30, "旧 issue-login-key 参数(现已拒绝)") + fs.Bool("revoke-all-credentials", false, "旧 issue-login-key 参数(现已拒绝)") keyID := fs.String("id", "", "revoke-login-key 凭据编号") - out := fs.String("out", "", "凭据交付文件(0600 JSON,代替 stdout 明文;非交互推荐)") + out := fs.String("out", "", "凭据交付文件(JSON;仅历史兼容子命令可用)") fs.Parse(args[1:]) + if sub == "reset-password" || sub == "issue-login-key" { + fmt.Fprintln(os.Stderr, "RECOVERY_CONTRACT_REPLACED: use POST /api/admin/users/{owner}/one-time-password") + return 2 // reject before DSN/password reads, storage opens or output reservation + } + if sub == "create" && (*password != "" || *passwordStdin || *generatePassword) { + fmt.Fprintln(os.Stderr, "RECOVERY_CONTRACT_REPLACED: local creation uses trusted activation only") + return 2 + } + // No CLI path may appoint an administrator or establish the super + // administrator (R4, R5); reject before storage opens or output reservation. + if sub == "create" && (*admin || *role == "admin") { + fmt.Fprintln(os.Stderr, "user create:", app.ErrCLIAdminAppointment) + return 2 + } + if sub == "bootstrap-admin" { + fmt.Fprintln(os.Stderr, "user bootstrap-admin:", app.ErrCLIBootstrapAdminDisabled) + return 2 + } + resolvedDSN, err := resolveAccountDSN(*dsn, *dsnFile) + if err != nil { + fmt.Fprintln(os.Stderr, "user:", err) + return 1 + } + resolvedPassword, err := resolveAccountPassword(*password, *passwordStdin) + if err != nil { + fmt.Fprintln(os.Stderr, "user:", err) + return 1 + } + if *instanceID != "" { + _ = os.Setenv("AGENT_SYSTEM_INSTANCE_ID", *instanceID) + } + *dsn = resolvedDSN + *password = resolvedPassword + if (sub == "create" || sub == "initialize-account") && (*outFile != "" || *out != "") { + fmt.Fprintln(os.Stderr, "user:", "local activation redemption is removed; do not use --out/--out-file. Ask an administrator to issue POST /api/admin/users/{owner}/one-time-password") + return 2 + } + if sub != "create" && sub != "initialize-account" && (*outFile != "" || *out != "") { + fmt.Fprintln(os.Stderr, "user: 此子命令不生成凭据") + return 2 + } switch sub { case "create": if *owner == "" { @@ -299,41 +515,29 @@ func runUser(args []string) int { fmt.Fprintf(os.Stderr, "user create: 未知角色 %q\n", *role) return 2 } - pw, err := app.CreateUserAccount(*dsn, *data, *owner, *name, *password, isAdmin, *openExisting, *generatePassword) + _, opID, err := app.CreateUserAccount(*dsn, *data, *owner, *name, *password, isAdmin, *openExisting, *generatePassword) if err != nil { fmt.Fprintln(os.Stderr, "user create:", err) return 1 } - if err := deliverCredential(*out, "login-key", *owner, pw, nil); err != nil { - fmt.Fprintln(os.Stderr, "user create:", err) - return 1 - } - if *out == "" { - if *password == "" && !*generatePassword { - fmt.Fprintln(os.Stderr, "一次性激活 Key 仅此一次输出;兑换后设置密码。") - } else if *password == "" { - fmt.Fprintln(os.Stderr, "初始密码仅此一次输出;该用户首次登录须修改密码。") - } - } + fmt.Fprintf(os.Stderr, "owner=%s created as pending; ask an administrator to issue POST /api/admin/users/%s/one-time-password\n", *owner, *owner) + fmt.Fprintf(os.Stderr, "operation_id=%s\n", opID) return 0 - case "reset-password": - if *owner == "" { - fmt.Fprintln(os.Stderr, "user reset-password: --owner 必填") + case "initialize-account": + if *userID == "" || *expectedStateVersion < 1 || *expectedGeneration < 0 { + fmt.Fprintln(os.Stderr, "user initialize-account: --user-id、--expected-state-version、--expected-generation 必填") return 2 } - pw, err := app.ResetUserPassword(*dsn, *data, *owner, *password, *openExisting) + _, targetOwner, err := app.InitializeAccount(*dsn, *data, *userID, *expectedStateVersion, *expectedGeneration) if err != nil { - fmt.Fprintln(os.Stderr, "user reset-password:", err) + fmt.Fprintln(os.Stderr, "user initialize-account:", err) return 1 } - if err := deliverCredential(*out, "password", *owner, pw, nil); err != nil { - fmt.Fprintln(os.Stderr, "user reset-password:", err) - return 1 - } - if *out == "" && *password == "" { - fmt.Fprintln(os.Stderr, "新密码仅此一次输出;该用户下次登录须修改密码(其余会话已吊销)。") - } + fmt.Fprintf(os.Stderr, "owner=%s remains pending; ask an administrator to issue POST /api/admin/users/%s/one-time-password\n", targetOwner, targetOwner) return 0 + case "reset-password": + fmt.Fprintln(os.Stderr, "RECOVERY_CONTRACT_REPLACED: use POST /api/admin/users/{owner}/one-time-password") + return 2 case "list": users, err := app.ListUserAccounts(*dsn, *data, *openExisting) if err != nil { @@ -344,27 +548,8 @@ func runUser(args []string) int { fmt.Println(string(out)) return 0 case "issue-login-key": - if *owner == "" { - fmt.Fprintln(os.Stderr, "user issue-login-key: --owner 必填") - return 2 - } - if *purpose != store.LoginKeyPurposeActivate && *purpose != store.LoginKeyPurposeRecover { - fmt.Fprintf(os.Stderr, "user issue-login-key: 无效 purpose %q\n", *purpose) - return 2 - } - plain, err := app.IssueUserLoginKey(*dsn, *data, *owner, *purpose, *openExisting, *revokeAll, time.Duration(*ttlMin)*time.Minute) - if err != nil { - fmt.Fprintln(os.Stderr, "user issue-login-key:", err) - return 1 - } - if err := deliverCredential(*out, "login-key", *owner, plain, time.Now().Add(time.Duration(*ttlMin)*time.Minute).UTC()); err != nil { - fmt.Fprintln(os.Stderr, "user issue-login-key:", err) - return 1 - } - if *out == "" { - fmt.Fprintln(os.Stderr, "一次性登录 Key 仅此一次输出。") - } - return 0 + fmt.Fprintln(os.Stderr, "RECOVERY_CONTRACT_REPLACED: use POST /api/admin/users/{owner}/one-time-password") + return 2 case "revoke-login-key": if *keyID == "" { fmt.Fprintln(os.Stderr, "user revoke-login-key: --id 必填") @@ -376,24 +561,6 @@ func runUser(args []string) int { } fmt.Println("revoked") return 0 - case "bootstrap-admin": - if *owner == "" { - fmt.Fprintln(os.Stderr, "user bootstrap-admin: --owner 必填") - return 2 - } - plain, err := app.BootstrapAdmin(*dsn, *data, *owner, *openExisting) - if err != nil { - fmt.Fprintln(os.Stderr, "user bootstrap-admin:", err) - return 1 - } - if err := deliverCredential(*out, "bootstrap-admin", *owner, plain, time.Now().Add(30*time.Minute).UTC()); err != nil { - fmt.Fprintln(os.Stderr, "user bootstrap-admin:", err) - return 1 - } - if *out == "" { - fmt.Fprintln(os.Stderr, "首位管理员激活 Key 仅此一次输出。") - } - return 0 default: fmt.Fprintln(os.Stderr, "user: 未知子命令", sub) return 2 diff --git a/cmd/agent-system/migrate.go b/cmd/agent-system/migrate.go new file mode 100644 index 0000000..21dd629 --- /dev/null +++ b/cmd/agent-system/migrate.go @@ -0,0 +1,30 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "agent-system-backend/internal/app" +) + +func runMigrate(args []string) int { + fs := flag.NewFlagSet("migrate", flag.ExitOnError) + data := fs.String("data", "./data", "数据目录(SQLite 档)") + dsn := fs.String("dsn", "", "PostgreSQL DSN(空则读 AGENT_SYSTEM_DSN)") + adopt := fs.Bool("adopt-instance-guard", false, "数据库被替换或锁文件被重建后,显式重写守卫") + _ = fs.Parse(args) + storage := *dsn + if storage == "" { + storage = os.Getenv("AGENT_SYSTEM_DSN") + } + if err := app.Migrate(app.Options{ + DataDir: *data, + StorageDSN: storage, + AdoptInstanceGuard: *adopt, + }); err != nil { + fmt.Fprintln(os.Stderr, "agent-system:", err) + return 1 + } + return 0 +} diff --git a/cmd/agent-system/user_contract_test.go b/cmd/agent-system/user_contract_test.go new file mode 100644 index 0000000..5a5131b --- /dev/null +++ b/cmd/agent-system/user_contract_test.go @@ -0,0 +1,115 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "agent-system-backend/internal/store" +) + +func TestLegacyUserCredentialCommandsStopBeforeOpeningStorage(t *testing.T) { + data := t.TempDir() + for _, args := range [][]string{ + {"reset-password", "--data", data, "--owner", "victim", "--password", "attacker-password"}, + {"issue-login-key", "--data", data, "--owner", "victim", "--purpose", "recover", "--revoke-all-credentials"}, + } { + if code := runUser(args); code != 2 { + t.Fatalf("legacy command did not reject before storage: %v code=%d", args[0], code) + } + } + out := filepath.Join(data, "delivery.json") + if code := runUser([]string{"create", "--data", data, "--owner", "victim", "--password", "attacker-password", "--out", out}); code != 2 { + t.Fatalf("password-bearing local create accepted: %d", code) + } + if _, err := os.Stat(filepath.Join(data, "agent-system.db")); !os.IsNotExist(err) { + t.Fatalf("rejected commands opened/created a database: %v", err) + } + if _, err := os.Stat(out); !os.IsNotExist(err) { + t.Fatalf("rejected commands wrote a delivery file: %v", err) + } +} + +func TestUserCLIRejectsAdministratorAppointmentBeforeOpeningStorage(t *testing.T) { + data := t.TempDir() + out := filepath.Join(data, "delivery.json") + for _, args := range [][]string{ + {"create", "--data", data, "--owner", "mallory", "--admin", "--out", out}, + {"create", "--data", data, "--owner", "mallory", "--role", "admin", "--out", out}, + {"bootstrap-admin", "--data", data, "--owner", "mallory", "--out", out}, + } { + if code := runUser(args); code != 2 { + t.Fatalf("administrator appointment accepted: %v code=%d", args, code) + } + } + if _, err := os.Stat(filepath.Join(data, "agent-system.db")); !os.IsNotExist(err) { + t.Fatalf("rejected commands opened/created a database: %v", err) + } + if _, err := os.Stat(out); !os.IsNotExist(err) { + t.Fatalf("rejected commands wrote a delivery file: %v", err) + } +} + +func TestUserCLIAdministratorAppointmentLeavesExistingStoreUnchanged(t *testing.T) { + dir, s := cliStore(t) + if _, err := s.BootstrapSuperAdmin("root", mustHash(t, "Root-Password-1")); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{ + {"create", "--data", dir, "--owner", "mallory", "--admin", "--out-file", filepath.Join(t.TempDir(), "a")}, + {"create", "--data", dir, "--owner", "mallory", "--role", "admin", "--out-file", filepath.Join(t.TempDir(), "b")}, + {"bootstrap-admin", "--data", dir, "--owner", "mallory", "--out-file", filepath.Join(t.TempDir(), "c")}, + } { + rc, output := accountCLI(t, args...) + if rc != 2 || !strings.Contains(output, "super administrator") { + t.Fatalf("%v: rc=%d output=%q", args, rc, output) + } + } + if _, err := s.GetUser("mallory"); err == nil { + t.Fatal("rejected CLI appointment created an account") + } + rc, _ := accountCLI(t, "create", "--data", dir, "--owner", "ada") + if rc != 0 { + t.Fatalf("ordinary local create failed: %d", rc) + } + if ada, err := s.GetAccountByOwner("ada"); err != nil || ada.IsAdmin || ada.IsSuperAdmin { + t.Fatalf("ordinary local create: %+v %v", ada, err) + } +} + +func mustHash(t *testing.T, password string) string { + t.Helper() + hash, err := store.HashPassword(password) + if err != nil { + t.Fatal(err) + } + return hash +} + +func TestCredentialDeliveryCreatesExclusive0600File(t *testing.T) { + dir := t.TempDir() + out := filepath.Join(dir, "credential.json") + if err := os.WriteFile(out, []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + if err := deliverCredential(out, "activation", "alice", "secret", nil); err == nil { + t.Fatal("existing permissive file was overwritten") + } + bytes, err := os.ReadFile(out) + if err != nil || string(bytes) != "old" { + t.Fatalf("existing file changed: %q %v", bytes, err) + } + out = filepath.Join(dir, "new.json") + if err := deliverCredential(out, "activation", "alice", "secret", nil); err != nil { + t.Fatal(err) + } + info, err := os.Stat(out) + if err != nil || info.Mode().Perm() != 0o600 { + t.Fatalf("delivery mode: %v %v", info, err) + } + bytes, err = os.ReadFile(out) + if err != nil || !strings.Contains(string(bytes), `"secret": "secret"`) { + t.Fatalf("delivery payload: %v", err) + } +} diff --git a/cmd/storage-migrate/integration_test.go b/cmd/storage-migrate/integration_test.go index d4997c0..efc41d5 100644 --- a/cmd/storage-migrate/integration_test.go +++ b/cmd/storage-migrate/integration_test.go @@ -9,8 +9,68 @@ import ( "testing" "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" ) +func TestMigrateTrustedContextManifestWithoutRawScope(t *testing.T) { + target, cleanup := pgSchemaDSN(t) + defer cleanup() + fixture := buildFixture(t) + src, err := store.OpenSQLite(fixture) + if err != nil { + t.Fatal(err) + } + m := extension.ProjectContextManifest{ProjectID: "p1", AttemptID: "model-attempt", InputSHA256: "request-hash", Delivery: "attempted", + Included: []extension.ProjectContextManifestEntry{{SourceID: "source-a", Revision: "r1", SourceHash: "ha", InputSHA256: "input-hash"}}} + if err := src.AppendContextManifestWithScope("alice", "r1", m, store.ContextManifestScope{Bound: true}); err != nil { + t.Fatal(err) + } + m.Delivery = "responded" + if err := src.AppendContextManifestWithScope("alice", "r1", m, store.ContextManifestScope{Bound: true}); err != nil { + t.Fatal(err) + } + task, err := src.CreateWorkTask("alice", "rdm", "p1", "c1", "document", "Prepare document") + if err != nil { + t.Fatal(err) + } + stepHash := strings.Repeat("a", 64) + if _, err := src.DB().Exec(`INSERT INTO workflow_step_sources(run_id,step_id,owner,project_id,step_attempt_id,output_sha256,attempt_id,authorization_epoch,publication_epoch,watermark,version,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`, + "r1", "draft", "alice", "p1", "step-attempt", stepHash, "model-attempt", "auth-1", "pub-1", "water-1", "v1", "2026-10-14T00:00:00Z"); err != nil { + t.Fatal(err) + } + if err := src.Close(); err != nil { + t.Fatal(err) + } + snapshot := filepath.Join(t.TempDir(), "snapshot.db") + if err := cmdBackup([]string{"--source", fixture, "--out", snapshot}); err != nil { + t.Fatal(err) + } + if err := cmdMigrate([]string{"--source", snapshot, "--target", target, "--migration-id", "context-copy"}); err != nil { + t.Fatal(err) + } + pg, err := store.OpenPostgres(target, filepath.Join(t.TempDir(), "artifacts")) + if err != nil { + t.Fatal(err) + } + defer pg.Close() + receipts, err := pg.ListContextManifests("alice", "r1") + if err != nil || len(receipts) != 2 || len(receipts[1].Included) != 1 || receipts[1].Included[0].SourceID != "source-a" { + t.Fatalf("content-free model receipts not copied: count=%d err=%v", len(receipts), err) + } + workScope, err := pg.WorkTaskScope("alice", "rdm", task.ID) + if err != nil || workScope.ProjectID != "p1" || workScope.ConversationID != "c1" { + t.Fatalf("trusted work task binding not copied: scope=%+v err=%v", workScope, err) + } + stepSource, err := pg.WorkflowStepSource("alice", "r1", "draft") + if err != nil || stepSource.StepAttemptID != "step-attempt" || stepSource.AttemptID != "model-attempt" || stepSource.ProjectID != "p1" { + t.Fatalf("workflow step source not copied: source=%+v err=%v", stepSource, err) + } + var rawColumns int + if err := pg.DB().QueryRow(`SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=current_schema() AND table_name='context_manifests' AND column_name IN ('resolve_goal','resolve_target_id')`).Scan(&rawColumns); err != nil || rawColumns != 0 { + t.Fatalf("migrated PG schema has raw prompt columns: count=%d err=%v", rawColumns, err) + } +} + // U4 集成(环境门控 AGENT_PG_TEST_DSN:CI test-pg job / 本机隔离实例): // 夹具 → 受管迁移 → 校验和验证 → 幂等重跑 → 断点续传 → 非空目标拒绝 → // 未知 owner 阻塞。每用例独立 PG schema(与 store 测试同隔离约定)。 @@ -45,6 +105,18 @@ func TestMigrateEndToEndAndIdempotent(t *testing.T) { target, cleanup := pgSchemaDSN(t) defer cleanup() fixture := buildFixture(t) + src, err := sql.Open("sqlite", fixture) + if err != nil { + t.Fatal(err) + } + defer src.Close() + var sourcePublic, sourceStaff, sourcePrivate, sourceSecret string + var sourceKeyVersion int64 + if err := src.QueryRow(`SELECT public_revision,staff_no_revision,private_revision,cursor_key_version,cursor_secret FROM account_directory_revision WHERE id=1`).Scan( + &sourcePublic, &sourceStaff, &sourcePrivate, &sourceKeyVersion, &sourceSecret, + ); err != nil { + t.Fatal(err) + } if err := cmdMigrate([]string{"--source", fixture, "--target", target, "--migration-id", "mig-e2e"}); err != nil { t.Fatalf("首次迁移:%v", err) @@ -61,6 +133,16 @@ func TestMigrateEndToEndAndIdempotent(t *testing.T) { } defer st.Close() db := st.DB() + var targetPublic, targetStaff, targetPrivate, targetSecret string + var targetKeyVersion int64 + if err := db.QueryRow(`SELECT public_revision,staff_no_revision,private_revision,cursor_key_version,cursor_secret FROM account_directory_revision WHERE id=1`).Scan( + &targetPublic, &targetStaff, &targetPrivate, &targetKeyVersion, &targetSecret, + ); err != nil { + t.Fatal(err) + } + if targetPublic != sourcePublic || targetStaff != sourceStaff || targetPrivate != sourcePrivate || targetKeyVersion != sourceKeyVersion || targetSecret != sourceSecret { + t.Fatal("迁移后的目录版本或游标密钥与源库不一致") + } var pinned, archived bool if err := db.QueryRow(`SELECT pinned, archived FROM conversations WHERE id='c1'`).Scan(&pinned, &archived); err != nil || !pinned || archived { t.Fatalf("布尔映射:pinned=%v archived=%v err=%v", pinned, archived, err) @@ -85,6 +167,15 @@ func TestMigrateEndToEndAndIdempotent(t *testing.T) { if _, err := db.Exec(`INSERT INTO command_audit (time, command_id, source, entrance, state, operation_id, conversation_id, payload, owner) VALUES ('2026-10-11T03:00:00Z','cmd-2','http','chat','done','op-2','c1','{}','alice')`); err != nil { t.Fatalf("序列推进失败(复制后插入撞主键):%v", err) } + var linkedResource string + if err := db.QueryRow(`SELECT resource_id FROM project_audit_links WHERE host_audit_offset=7 AND source_id='commit-1'`).Scan(&linkedResource); err != nil || linkedResource != "doc-1" { + t.Fatalf("审计链接迁移丢失: resource=%q err=%v", linkedResource, err) + } + var newLinkID int64 + if err := db.QueryRow(`INSERT INTO project_audit_links(host_audit_offset,project_id,owner,source_kind,source_id,operation_id,resource_kind,resource_id,run_id,disclosure_class,idempotency_key,created_at) + VALUES (7,'p1','alice','domain-commit','commit-2','op-1','document','doc-2','','metadata','outbox-2','2026-10-11T03:00:01Z') RETURNING id`).Scan(&newLinkID); err != nil || newLinkID <= 3 { + t.Fatalf("审计链接序列未推进: id=%d err=%v", newLinkID, err) + } // 报告不含秘密值。 var report string if err := db.QueryRow(`SELECT report FROM migration_control WHERE migration_id='mig-e2e'`).Scan(&report); err != nil || strings.Contains(report, "sk-secret-value") { @@ -92,6 +183,105 @@ func TestMigrateEndToEndAndIdempotent(t *testing.T) { } } +func TestCommandAuditExclusiveCopyRollsBackAndNeverRewindsSequence(t *testing.T) { + target, cleanup := pgSchemaDSN(t) + defer cleanup() + fixture := buildFixture(t) + src, err := openSourceRO(fixture) + if err != nil { + t.Fatal(err) + } + defer src.Close() + st, err := store.OpenPostgres(target, filepath.Join(t.TempDir(), "artifacts")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + db := st.DB() + if _, err := db.Exec(controlDDL); err != nil { + t.Fatal(err) + } + cols, pk, err := targetColumns(db, "command_audit") + if err != nil { + t.Fatal(err) + } + bools, err := boolColumns(db, "command_audit", cols) + if err != nil { + t.Fatal(err) + } + count, checksum, err := sourceScan(src, "command_audit", cols, pk, bools) + if err != nil { + t.Fatal(err) + } + blocker, err := db.Begin() + if err != nil { + t.Fatal(err) + } + var existing int + if err := blocker.QueryRow(`SELECT COUNT(*) FROM command_audit`).Scan(&existing); err != nil { + t.Fatal(err) + } + timeoutDB, err := sql.Open("pgx", store.AppendPGRuntimeParam(target, "lock_timeout", "100")) + if err != nil { + t.Fatal(err) + } + if _, err := migrateCommandAudit(timeoutDB, src, "retry-audit", cols, pk, bools, count, checksum); err == nil || !strings.Contains(err.Error(), "lock timeout") { + t.Fatalf("exclusive copy did not fail on a held reader: %v", err) + } + timeoutDB.Close() + if err := blocker.Rollback(); err != nil { + t.Fatal(err) + } + if _, err := migrateCommandAudit(db, src, "retry-audit", cols, pk, bools, count+1, checksum); err == nil { + t.Fatal("mismatched copy committed despite in-transaction target count check") + } + var rows, done int + if err := db.QueryRow(`SELECT COUNT(*) FROM command_audit`).Scan(&rows); err != nil || rows != 0 { + t.Fatalf("failed copy left rows: %d %v", rows, err) + } + if err := db.QueryRow(`SELECT COUNT(*) FROM migration_table_state WHERE table_name='command_audit'`).Scan(&done); err != nil || done != 0 { + t.Fatalf("failed copy recorded done: %d %v", done, err) + } + var enabled string + if err := db.QueryRow(`SELECT tgenabled FROM pg_trigger WHERE tgrelid='command_audit'::regclass AND tgname='project_audit_insert_guard_v1'`).Scan(&enabled); err != nil || enabled != "A" { + t.Fatalf("failed copy left guard disabled: %q %v", enabled, err) + } + var seq string + if err := db.QueryRow(`SELECT pg_get_serial_sequence('command_audit','id')`).Scan(&seq); err != nil { + t.Fatal(err) + } + // setval is non-transactional; an interrupted offline attempt can leave a + // forward gap even though its rows and trigger-state changes roll back. + tx, err := db.Begin() + if err != nil { + t.Fatal(err) + } + if _, err := tx.Exec(`LOCK TABLE command_audit IN ACCESS EXCLUSIVE MODE`); err != nil { + t.Fatal(err) + } + if _, err := tx.Exec(`ALTER TABLE command_audit DISABLE TRIGGER project_audit_insert_guard_v1`); err != nil { + t.Fatal(err) + } + if _, err := tx.Exec(`SELECT setval($1::regclass,50,true)`, seq); err != nil { + t.Fatal(err) + } + if err := tx.Rollback(); err != nil { + t.Fatal(err) + } + if _, err := migrateCommandAudit(db, src, "retry-audit", cols, pk, bools, count, checksum); err != nil { + t.Fatalf("retry after rollback: %v", err) + } + var last int64 + if err := db.QueryRow(`SELECT last_value FROM ` + seq).Scan(&last); err != nil || last < 50 { + t.Fatalf("copy rewound sequence: last=%d err=%v", last, err) + } + var nextID int64 + if err := db.QueryRow(`INSERT INTO command_audit(time,command_id,source,entrance,state,operation_id,conversation_id,owner,payload) + VALUES ('2026-10-14T00:00:00Z','after-copy','','','committed','','','alice','{}') RETURNING id`).Scan(&nextID); err != nil || nextID <= 50 { + t.Fatalf("post-copy ID did not advance: id=%d err=%v", nextID, err) + } +} + func TestMigrateResumeAfterInterrupt(t *testing.T) { target, cleanup := pgSchemaDSN(t) defer cleanup() diff --git a/cmd/storage-migrate/main.go b/cmd/storage-migrate/main.go index 92de9f2..3ecd045 100644 --- a/cmd/storage-migrate/main.go +++ b/cmd/storage-migrate/main.go @@ -242,6 +242,9 @@ func cmdBackup(args []string) error { return err } defer db.Close() + if err := refuseCandidateRawContextScope(db); err != nil { + return err + } if _, err := db.Exec(`VACUUM INTO ?`, *out); err != nil { return fmt.Errorf("vacuum into: %w", err) } @@ -281,10 +284,12 @@ func cmdBackup(args []string) error { // copyOrder:按外键依赖排序的业务表全集(projects 最先,引用方在后)。 // admission_leases 是运行态执行许可(迁移无意义),明确排除。 var copyOrder = []string{ - "projects", "users", "profiles", "settings", "plugins", - "conversations", "runs", "messages", "events", "artifacts", - "hitl_cards", "plans", "todos", "knowledge", "knowledge_vectors", - "activity_cards", "project_event_shares", "command_audit", "prompt_modules", + "projects", "users", "roles", "role_permissions", "user_role_bindings", "system_grants", "authority_state", "policy_receipts", "role_authority_import_state", + "account_directory_revision", "profiles", "settings", "plugins", + "conversations", "runs", "messages", "project_work_outbox", "events", "artifacts", + "document_render_deliveries", + "hitl_cards", "human_decisions", "plans", "todos", "knowledge", "knowledge_vectors", + "activity_cards", "project_event_shares", "command_audit", "project_audit_links", "prompt_modules", "sessions", "api_keys", "plugin_grants", "plugin_schema", "plugin_kv", "background_tasks", "work_task_bindings", "memory_entries", "audit_events", "context_manifests", "workflow_step_sources", "pending_confirms", "llm_usage_daily", @@ -297,6 +302,19 @@ var copyOrder = []string{ "agent_system_identity", "account_events", "redeem_attempts", + "recovery_proposals", "recovery_proposal_proofs", + // v35 分层 Python 环境(无外键;申请与会话绑定要随库迁走) + "exec_env_requests", "exec_env_bindings", + // U8:活动服务锁的库内标记(无外键;迁移须保留以免目标库丢掉锁根) + "instance_guard", +} + +// schemaSeededSingletons:schema 迁移在每个新库上预置的单行表。它们不是目标 +// 业务数据,复制时须由源库的行替换。 +var schemaSeededSingletons = map[string]bool{ + "account_directory_revision": true, + "authority_state": true, + "role_authority_import_state": true, } const controlDDL = ` @@ -336,6 +354,9 @@ func cmdMigrate(args []string) error { return err } defer src.Close() + if err := refuseCandidateRawContextScope(src); err != nil { + return err + } snapID, _, err := fileSHA256(*source) if err != nil { return err @@ -344,6 +365,9 @@ func cmdMigrate(args []string) error { if err != nil { return err } + if err := validateSourceMigrationLedger(src); err != nil { + return err + } // 目标:先用 store 同款迁移器建 schema/扩展(OpenPostgres 会跑迁移与 // 未知版本门禁),再落控制表。DSN 不回显、不入报告。 @@ -410,6 +434,57 @@ func cmdMigrate(args []string) error { return writeReport(rep, *out) } +func validateSourceMigrationLedger(src *sql.DB) error { + rows, err := src.Query(`SELECT version,name FROM schema_migrations`) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var version int + var name string + if err := rows.Scan(&version, &name); err != nil { + return err + } + if err := store.ValidateMigrationIdentity(version, name); err != nil { + return fmt.Errorf("source %w", err) + } + } + return rows.Err() +} + +// Candidate context-scope v35 may contain full model prompts in private columns. +// Never copy or back up such a source implicitly; it must be migrated and +// reviewed in an isolated Backend store before this tool accepts it. +func refuseCandidateRawContextScope(db *sql.DB) error { + if !sqliteHasTable(db, "context_manifests") { + return nil + } + var columns int + if err := db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('context_manifests') WHERE name IN ('resolve_goal','resolve_target_id')`).Scan(&columns); err != nil { + return err + } + if columns > 0 { + return fmt.Errorf("candidate context scope text columns remain; isolate and scrub source before backup or migration") + } + var version int + if sqliteHasTable(db, "schema_migrations") { + if err := db.QueryRow(`SELECT COALESCE(MAX(version),0) FROM schema_migrations`).Scan(&version); err != nil { + return err + } + } + if version >= 35 { + if !sqliteHasTable(db, "context_scope_scrub_state") { + return fmt.Errorf("candidate context physical scrub marker missing; isolate and scrub source before backup or migration") + } + var pending int + if err := db.QueryRow(`SELECT pending FROM context_scope_scrub_state WHERE id=1`).Scan(&pending); err != nil || pending != 0 { + return fmt.Errorf("candidate context physical scrub pending or unverified; isolate and scrub source before backup or migration") + } + } + return nil +} + // guardIdentity:目标非空且身份不同即拒绝(R7:不覆盖他人数据)。 // - 存在其他 migration_id 的完成记录 → 拒绝; // - 同 migration_id 但 source_snapshot_id 不同(换了快照续传)→ 拒绝; @@ -450,6 +525,11 @@ func guardIdentity(db *sql.DB, migrationID, snapID string, srcVersion int) error func businessRows(db *sql.DB) (int64, error) { var total int64 for _, t := range copyOrder { + // Schema setup seeds these singletons before a migration starts. They are + // replaced from the source during copying and are not target business data. + if schemaSeededSingletons[t] { + continue + } var n int64 if err := db.QueryRow(`SELECT COUNT(*) FROM "` + t + `"`).Scan(&n); err != nil { if strings.Contains(err.Error(), "does not exist") { @@ -508,9 +588,18 @@ func migrateTable(db *sql.DB, src *sql.DB, migrationID, table string) (map[strin if countNow != srcCount || sumNow != srcChecksum { return nil, fmt.Errorf("源与登记不符(疑似换快照):count %d/%d checksum %s…/%s…", countNow, srcCount, sumNow[:12], srcChecksum[:12]) } + if table == "command_audit" { + var guarded bool + if err := db.QueryRow(`SELECT project_audit_cutoff_safe('command_audit'::regclass) AND project_audit_writers_guarded('command_audit'::regclass)`).Scan(&guarded); err != nil || !guarded { + return nil, fmt.Errorf("command_audit idempotent guard is unsafe: safe=%v err=%v", guarded, err) + } + } entry := map[string]any{"table": table, "source_count": countNow, "target_count": dstCountStored, "checksum": sumNow, "state": "done(idempotent)"} return entry, nil } + if table == "command_audit" { + return migrateCommandAudit(db, src, migrationID, cols, pk, bools, countNow, sumNow) + } if err := copyRows(db, src, table, cols); err != nil { return nil, err } @@ -663,7 +752,11 @@ func sourceScan(src *sql.DB, table string, cols, pk []string, bools map[string]b return scanChecksum(rows, cols, bools) } -func targetScan(db *sql.DB, table string, cols, pk []string, bools map[string]bool) (int64, string, error) { +type rowsQuerier interface { + Query(string, ...any) (*sql.Rows, error) +} + +func targetScan(db rowsQuerier, table string, cols, pk []string, bools map[string]bool) (int64, string, error) { rows, err := db.Query(`SELECT ` + quoteJoin(cols, `"`) + ` FROM "` + table + `" ORDER BY ` + quoteJoin(pk, `"`)) if err != nil { return 0, "", err @@ -686,6 +779,18 @@ func copyRows(db *sql.DB, src *sql.DB, table string, cols []string) error { if err != nil { return err } + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if err := copyRowsInTx(tx, src, table, cols, bools); err != nil { + return err + } + return tx.Commit() +} + +func copyRowsInTx(tx *sql.Tx, src *sql.DB, table string, cols []string, bools map[string]bool) error { colList := quoteJoin(cols, `"`) ph := make([]string, len(cols)) for i := range ph { @@ -702,11 +807,15 @@ func copyRows(db *sql.DB, src *sql.DB, table string, cols []string) error { for i := range vals { ptrs[i] = &vals[i] } - tx, err := db.Begin() - if err != nil { - return err + // The destination migrator seeds fresh singletons (directory revision, + // authority epoch, role import state) at schema startup. Replace them with + // the source's rows so existing cursors/visible-change history and authority + // state remain tied to the migrated data. + if schemaSeededSingletons[table] { + if _, err := tx.Exec(`DELETE FROM "` + table + `"`); err != nil { + return err + } } - defer tx.Rollback() stmt, err := tx.Prepare(fmt.Sprintf(`INSERT INTO "%s" (%s) VALUES (%s) ON CONFLICT DO NOTHING`, table, colList, placeholders)) if err != nil { return err @@ -734,7 +843,76 @@ func copyRows(db *sql.DB, src *sql.DB, table string, cols []string) error { if err := srcRows.Err(); err != nil { return err } - return tx.Commit() + return nil +} + +// command_audit alone needs an offline transaction: explicit source IDs do not +// pass the online monotone guard, and disabling it must never become visible +// separately from the copied rows, sequence advance, or done marker. +func migrateCommandAudit(db *sql.DB, src *sql.DB, migrationID string, cols, pk []string, bools map[string]bool, sourceCount int64, sourceChecksum string) (map[string]any, error) { + tx, err := db.Begin() + if err != nil { + return nil, err + } + defer tx.Rollback() + if _, err := tx.Exec(`LOCK TABLE command_audit IN ACCESS EXCLUSIVE MODE`); err != nil { + return nil, err + } + if _, err := tx.Exec(`ALTER TABLE command_audit DISABLE TRIGGER project_audit_insert_guard_v1`); err != nil { + return nil, err + } + if err := copyRowsInTx(tx, src, "command_audit", cols, bools); err != nil { + return nil, err + } + targetCount, targetChecksum, err := targetScan(tx, "command_audit", cols, pk, bools) + if err != nil { + return nil, err + } + if targetCount != sourceCount || targetChecksum != sourceChecksum { + return nil, fmt.Errorf("command_audit copy mismatch: count %d/%d checksum %s/%s", targetCount, sourceCount, targetChecksum, sourceChecksum) + } + if err := advanceCommandAuditSequence(tx); err != nil { + return nil, err + } + if _, err := tx.Exec(`ALTER TABLE command_audit ENABLE ALWAYS TRIGGER project_audit_insert_guard_v1`); err != nil { + return nil, err + } + var guarded bool + if err := tx.QueryRow(`SELECT project_audit_cutoff_safe('command_audit'::regclass) AND project_audit_writers_guarded('command_audit'::regclass)`).Scan(&guarded); err != nil || !guarded { + return nil, fmt.Errorf("command_audit guard not restored: safe=%v err=%v", guarded, err) + } + if _, err := tx.Exec(`INSERT INTO migration_table_state (migration_id, table_name, source_count, target_count, checksum, state, finished_at) + VALUES ($1,'command_audit',$2,$3,$4,'done',$5) + ON CONFLICT(migration_id, table_name) DO UPDATE SET source_count=excluded.source_count, target_count=excluded.target_count, checksum=excluded.checksum, state=excluded.state, finished_at=excluded.finished_at`, + migrationID, sourceCount, targetCount, sourceChecksum, nowISO()); err != nil { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, err + } + return map[string]any{"table": "command_audit", "source_count": sourceCount, "target_count": targetCount, "checksum": sourceChecksum, "state": "done"}, nil +} + +func advanceCommandAuditSequence(tx *sql.Tx) error { + var seq string + if err := tx.QueryRow(`SELECT pg_get_serial_sequence('command_audit','id')`).Scan(&seq); err != nil || seq == "" { + return fmt.Errorf("command_audit sequence missing: %v", err) + } + var last int64 + var called bool + if err := tx.QueryRow(fmt.Sprintf(`SELECT last_value,is_called FROM %s`, seq)).Scan(&last, &called); err != nil { + return err + } + var maxID sql.NullInt64 + if err := tx.QueryRow(`SELECT MAX(id) FROM command_audit`).Scan(&maxID); err != nil { + return err + } + if maxID.Valid && (maxID.Int64 > last || (maxID.Int64 == last && !called)) { + if _, err := tx.Exec(`SELECT setval($1::regclass,$2,true)`, seq, maxID.Int64); err != nil { + return err + } + } + return nil } func sqliteBool(v any) bool { @@ -752,30 +930,34 @@ func sqliteBool(v any) bool { // 后续应用插入会撞主键)。无序列表为空操作。 func advanceSequences(db *sql.DB, table string) error { rows, err := db.Query(` - SELECT pg_get_serial_sequence($1, column_name) + SELECT column_name, pg_get_serial_sequence(format('%I.%I',table_schema,table_name), column_name) FROM information_schema.columns - WHERE table_schema=current_schema() AND table_name=$1 - AND (column_default LIKE 'nextval%' OR is_identity='YES')`, table) + WHERE table_schema=current_schema() AND table_name=$1`, table) if err != nil { return err } defer rows.Close() - var seqs []string + var seqs []struct{ name, column string } for rows.Next() { - var s any - if err := rows.Scan(&s); err != nil { + var column string + var seq sql.NullString + if err := rows.Scan(&column, &seq); err != nil { return err } - if s != nil { - seqs = append(seqs, s.(string)) + if seq.Valid { + seqs = append(seqs, struct{ name, column string }{seq.String, column}) } } if err := rows.Err(); err != nil { return err } for _, s := range seqs { - if _, err := db.Exec(fmt.Sprintf(`SELECT setval('%s', COALESCE((SELECT MAX(id) FROM "%s"), 1))`, s, table)); err != nil { - return fmt.Errorf("setval %s: %w", s, err) + // Sequence ownership remains authoritative when a column's default is a + // guarded function rather than a literal nextval expression. + column := `"` + strings.ReplaceAll(s.column, `"`, `""`) + `"` + tableName := `"` + strings.ReplaceAll(table, `"`, `""`) + `"` + if _, err := db.Exec(fmt.Sprintf(`SELECT setval($1::regclass, COALESCE((SELECT MAX(%s) FROM %s), 1))`, column, tableName), s.name); err != nil { + return fmt.Errorf("setval %s (%s): %w", s.name, s.column, err) } } return nil @@ -784,7 +966,9 @@ func advanceSequences(db *sql.DB, table string) error { // checkOwners:业务行引用的 owner 必须存在于 users(单租户默认 ”/'local' // 除外)。未知 owner 阻塞迁移(U4/R7:不静默带过未知归属)。 func checkOwners(db *sql.DB, src *sql.DB) error { - ownerTables := []string{"projects", "conversations", "runs", "artifacts", "knowledge", "hitl_cards", "profiles", "activity_cards", "project_event_shares", "command_audit", "work_task_bindings", "context_manifests", "workflow_step_sources"} + ownerTables := []string{"projects", "conversations", "runs", "artifacts", "knowledge", "hitl_cards", "profiles", "activity_cards", + "project_event_shares", "command_audit", "project_audit_links", "project_work_outbox", "work_task_bindings", "context_manifests", "workflow_step_sources", + "sessions", "api_keys", "login_keys", "account_events", "recovery_proposals"} for _, t := range ownerTables { if !sqliteHasTable(src, t) { continue diff --git a/cmd/storage-migrate/main_test.go b/cmd/storage-migrate/main_test.go index 8731e0e..33cd71c 100644 --- a/cmd/storage-migrate/main_test.go +++ b/cmd/storage-migrate/main_test.go @@ -1,7 +1,10 @@ package main import ( + "bytes" + "database/sql" "encoding/json" + "os" "path/filepath" "strings" "testing" @@ -27,6 +30,23 @@ func buildFixture(t *testing.T) string { return path } +func TestMigrateRejectsSourceLedgerNameCollisionBeforeTargetOpen(t *testing.T) { + source := buildFixture(t) + db, err := sql.Open("sqlite", source) + if err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`UPDATE schema_migrations SET name='work_task_bindings' WHERE version=35`); err != nil { + db.Close() + t.Fatal(err) + } + db.Close() + err = cmdMigrate([]string{"--source", source, "--target", "invalid://target", "--migration-id", "name-collision"}) + if err == nil || !strings.Contains(err.Error(), "source migration identity mismatch") || !strings.Contains(err.Error(), "legacy feature migration numbering") { + t.Fatalf("candidate branch ledger was not rejected before opening target: %v", err) + } +} + func seedEdgeRows(t *testing.T, st *store.Store) { t.Helper() // users(含 is_admin/must_change 布尔)与单租户默认身份。 @@ -70,8 +90,10 @@ func seedEdgeRows(t *testing.T, st *store.Store) { mustExec(t, st, `INSERT INTO activity_cards (id, project_id, conversation_id, ref, owner, payload, status, pinned, created_at, updated_at, version) VALUES ('ac1', 'p1', 'c1', '', 'alice', '{"title":"卡"}', 'active', 1, '2026-10-11T01:00:00Z', '2026-10-11T01:00:00Z', 2)`) // 命令审计(SERIAL 主键:显式 id 插入)。 - mustExec(t, st, `INSERT INTO command_audit (id, time, command_id, source, entrance, state, operation_id, conversation_id, payload, owner) VALUES - (7, '2026-10-11T01:00:00Z', 'cmd-1', 'http', 'chat', 'done', 'op-1', 'c1', '{"actor":"alice"}', 'alice')`) + mustExec(t, st, `INSERT INTO command_audit (id, time, command_id, source, entrance, state, operation_id, conversation_id, payload, owner, project_id, actor_name) VALUES + (7, '2026-10-11T01:00:00Z', 'cmd-1', 'http', 'chat', 'committed', 'op-1', 'c1', '{"actor":"alice","commandId":"cmd-1","operationId":"op-1","state":"committed","conversationId":"c1"}', 'alice', 'p1', '爱丽丝')`) + mustExec(t, st, `INSERT INTO project_audit_links (id,host_audit_offset,project_id,owner,source_kind,source_id,operation_id,resource_kind,resource_id,run_id,disclosure_class,idempotency_key,created_at) VALUES + (3,7,'p1','alice','domain-commit','commit-1','op-1','document','doc-1','','metadata','outbox-1','2026-10-11T01:00:01Z')`) // 设置('' 默认租户 scope)。 mustExec(t, st, `INSERT INTO settings (owner, scope, "values") VALUES ('', 'runtime', '{"rateLimitPerMin":0}')`) // 秘密字段(值不外泄:迁移按行复制,报告无正文)。 @@ -170,7 +192,8 @@ func TestCopyOrderCoversAllFixtureTables(t *testing.T) { for _, t := range copyOrder { known[t] = true } - known["schema_migrations"] = true // 迁移器管理,不随迁 + known["schema_migrations"] = true // 迁移器管理,不随迁 + known["context_scope_scrub_state"] = true // SQLite 候选原文物理清理状态,不随迁 // 运行态表不随迁:执行许可是进程瞬态;执行租约与跨实例命令属当前 // 实例的调度现场(接管语义在 U5 内由 lease/fencing 处理,不复制历史)。 for _, rt := range []string{"admission_leases", "conversation_executions", "run_commands"} { @@ -195,3 +218,108 @@ func TestCopyOrderCoversAllFixtureTables(t *testing.T) { t.Fatalf("copyOrder 漏表(新表加入 schema 后须同步工具清单):%v", missing) } } + +func TestBackupAndMigrateRejectCandidateRawContextScope(t *testing.T) { + path := buildFixture(t) + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`ALTER TABLE context_manifests ADD COLUMN resolve_goal TEXT NOT NULL DEFAULT ''`); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`ALTER TABLE context_manifests ADD COLUMN resolve_target_id TEXT NOT NULL DEFAULT ''`); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`DELETE FROM schema_migrations WHERE version=41`); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`INSERT INTO context_manifests(id,attempt_id,owner,target_id,project_id,input_sha256,delivery,manifest_json,created_at,scope_bound,resolve_goal,resolve_target_id) VALUES('candidate-ctx','candidate-attempt','alice','r1','p1','input-hash','attempted','{}','2026-10-14T00:00:00Z',1,'OLD_RESTRICTED_BODY','doc-private')`); err != nil { + t.Fatal(err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + out := filepath.Join(t.TempDir(), "unsafe-backup.db") + if err := cmdBackup([]string{"--source", path, "--out", out}); err == nil || !strings.Contains(err.Error(), "candidate context scope text") { + t.Fatalf("backup accepted candidate v38 raw scope: %v", err) + } + if _, err := os.Stat(out); !os.IsNotExist(err) { + t.Fatalf("rejected backup still created output: %v", err) + } + if err := cmdMigrate([]string{"--source", path, "--target", "postgres://invalid", "--migration-id", "unsafe"}); err == nil || !strings.Contains(err.Error(), "candidate context scope text") { + t.Fatalf("migration accepted candidate v38 raw source: %v", err) + } + scrubbed, err := store.OpenSQLite(path) + if err != nil { + t.Fatal(err) + } + if err := scrubbed.Close(); err != nil { + t.Fatal(err) + } + if err := cmdBackup([]string{"--source", path, "--out", out}); err != nil { + t.Fatalf("scrubbed source rejected: %v", err) + } + data, err := os.ReadFile(out) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(data, []byte("OLD_RESTRICTED_BODY")) { + t.Fatal("backup retained candidate raw scope body") + } +} + +func TestBackupAndMigrateRejectPendingOrMissingContextScrubMarker(t *testing.T) { + path := buildFixture(t) + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + for _, query := range []string{ + `CREATE TABLE IF NOT EXISTS context_scope_scrub_state(id INTEGER PRIMARY KEY,pending INTEGER NOT NULL)`, + `INSERT OR REPLACE INTO context_scope_scrub_state(id,pending) VALUES(1,1)`, + } { + if _, err := db.Exec(query); err != nil { + t.Fatal(err) + } + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + assertRejected := func(name string) { + t.Helper() + out := filepath.Join(t.TempDir(), name+".db") + if err := cmdBackup([]string{"--source", path, "--out", out}); err == nil { + t.Fatalf("backup accepted %s context scrub state", name) + } + if _, err := os.Stat(out); !os.IsNotExist(err) { + t.Fatalf("rejected %s backup created output: %v", name, err) + } + if err := cmdMigrate([]string{"--source", path, "--target", "postgres://invalid", "--migration-id", name}); err == nil { + t.Fatalf("migration accepted %s context scrub state", name) + } + } + assertRejected("pending") + db, err = sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`DROP TABLE context_scope_scrub_state`); err != nil { + t.Fatal(err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + assertRejected("missing") + db, err = sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`DELETE FROM schema_migrations WHERE version=41`); err != nil { + t.Fatal(err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + assertRejected("v38-missing") +} diff --git a/cmd/storage-migrate/seeded_singletons_test.go b/cmd/storage-migrate/seeded_singletons_test.go new file mode 100644 index 0000000..fed6f96 --- /dev/null +++ b/cmd/storage-migrate/seeded_singletons_test.go @@ -0,0 +1,101 @@ +package main + +import ( + "database/sql" + "path/filepath" + "testing" + + "agent-system-backend/internal/store" +) + +// 新库 schema 预置的单行表:必须在 copyOrder 内、确由迁移预置、不计入 +// businessRows,且复制时由源库行替换(而非 ON CONFLICT 保留目标预置行)。 + +func openFreshStore(t *testing.T, name string) *store.Store { + t.Helper() + st, err := store.OpenSQLite(filepath.Join(t.TempDir(), name)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + return st +} + +func tableColumns(t *testing.T, db *sql.DB, table string) []string { + t.Helper() + rows, err := db.Query(`SELECT name FROM pragma_table_info(?) ORDER BY cid`, table) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + var cols []string + for rows.Next() { + var c string + if err := rows.Scan(&c); err != nil { + t.Fatal(err) + } + cols = append(cols, c) + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + if len(cols) == 0 { + t.Fatalf("%s 无列", table) + } + return cols +} + +func TestSchemaSeededSingletonsAreSeededAndCopied(t *testing.T) { + inOrder := map[string]bool{} + for _, tbl := range copyOrder { + inOrder[tbl] = true + } + fresh := openFreshStore(t, "fresh.db").DB() + for tbl := range schemaSeededSingletons { + if !inOrder[tbl] { + t.Errorf("%s 不在 copyOrder 内", tbl) + } + var n int + if err := fresh.QueryRow(`SELECT COUNT(*) FROM "` + tbl + `"`).Scan(&n); err != nil { + t.Fatal(err) + } + if n != 1 { + t.Errorf("%s 新库预置行数=%d,期望 1", tbl, n) + } + } + n, err := businessRows(fresh) + if err != nil { + t.Fatal(err) + } + if n != 0 { + t.Fatalf("新库 businessRows=%d,预置单行表不得计为业务数据", n) + } +} + +func TestCopyReplacesSeededAuthorityState(t *testing.T) { + src := openFreshStore(t, "src.db").DB() + if _, err := src.Exec(`UPDATE authority_state SET authority_epoch=7, catalog_version=9 WHERE id=1`); err != nil { + t.Fatal(err) + } + dst := openFreshStore(t, "dst.db").DB() + cols := tableColumns(t, src, "authority_state") + tx, err := dst.Begin() + if err != nil { + t.Fatal(err) + } + defer tx.Rollback() + if err := copyRowsInTx(tx, src, "authority_state", cols, nil); err != nil { + t.Fatal(err) + } + if err := tx.Commit(); err != nil { + t.Fatal(err) + } + var count int + var epoch, catalog int64 + if err := dst.QueryRow(`SELECT COUNT(*), MAX(authority_epoch), MAX(catalog_version) FROM authority_state`).Scan(&count, &epoch, &catalog); err != nil { + t.Fatal(err) + } + if count != 1 || epoch != 7 || catalog != 9 { + t.Fatalf("目标 authority_state 未被源行替换:count=%d epoch=%d catalog=%d", count, epoch, catalog) + } +} diff --git a/deps/agent-document-renderer b/deps/agent-document-renderer new file mode 160000 index 0000000..3f25999 --- /dev/null +++ b/deps/agent-document-renderer @@ -0,0 +1 @@ +Subproject commit 3f259990b5d43ec0d318f39d496816140ab559b5 diff --git a/deps/agent-intent b/deps/agent-intent index 343b1e6..651a2e1 160000 --- a/deps/agent-intent +++ b/deps/agent-intent @@ -1 +1 @@ -Subproject commit 343b1e6d9a5b58e47bf7e594826221036ae8175c +Subproject commit 651a2e1e03c4326888ef9f742ce592140ae30ab8 diff --git a/deps/agent-native b/deps/agent-native index c348441..2fc66be 160000 --- a/deps/agent-native +++ b/deps/agent-native @@ -1 +1 @@ -Subproject commit c348441691e8bdab784a03aa9be64d53dacff341 +Subproject commit 2fc66be65201232ea38ba64d457efad5565d869d diff --git a/deps/agent-squad b/deps/agent-squad index 490b0b2..c7121bd 160000 --- a/deps/agent-squad +++ b/deps/agent-squad @@ -1 +1 @@ -Subproject commit 490b0b2dfa1567cb16ff7a7230fd78e52bc2938e +Subproject commit c7121bda2a681dc46078350f7929e56e142db57f diff --git a/deps/agent-system-core b/deps/agent-system-core index 5773e74..0cf0344 160000 --- a/deps/agent-system-core +++ b/deps/agent-system-core @@ -1 +1 @@ -Subproject commit 5773e74086791731059730bd3726dc8c9cd1381f +Subproject commit 0cf03446c458e1238a3d20a031becd4450d63228 diff --git a/deps/development-guidelines b/deps/development-guidelines new file mode 160000 index 0000000..2688fd2 --- /dev/null +++ b/deps/development-guidelines @@ -0,0 +1 @@ +Subproject commit 2688fd2be6a52c0bf110e7e6366b6f291ab5e4eb diff --git a/deps/development-manager b/deps/development-manager new file mode 160000 index 0000000..67784f4 --- /dev/null +++ b/deps/development-manager @@ -0,0 +1 @@ +Subproject commit 67784f43eb0430bf596c2cda685cbb4946d2e032 diff --git a/docs/iam-u3-account-write-fence-inventory.md b/docs/iam-u3-account-write-fence-inventory.md new file mode 100644 index 0000000..0f4b0b2 --- /dev/null +++ b/docs/iam-u3-account-write-fence-inventory.md @@ -0,0 +1,120 @@ +# IAM U3 A1: account write fence inventory + +Status: stage 4–5 implementation inventory; the fixed candidate is the commit containing this file, and stage 6 human review remains pending. The route table records the **4053c4d baseline finding** and the intended final sink; its `Now` column is historical, not a claim about the amended candidate. Scope follows the approved Documents A1–A4 amendment `fb3a8214df963697ebd745ad11eb852ed9444fe3` and approved delegated-actor amendment `5783940e4c434102cbca8c22bd282d87b28fa840`. + +The 66 non-GET `/api` registrations were enumerated from `internal/server/server.go`. Paths below omit `/api`. `A` means `s.owner(r)` (auth-off: implicit `store.OwnerDefault`); `B` means body owner, `K` redeemed-key owner, `S` current password-session owner. Target resource owner never substitutes for actor. In the historical `Now` column, `T` means the baseline already had a final Store check; `M` means a Backend DB write lacked one; `E` means an external or process-local effect; `—` means no business mutation or permitted credential cleanup. `D` means a short Store transaction with actor account lock and enabled read **before** resource/scope/conversation locks; `F` means stage immutable bytes, then `D` for the visible DB reference; `X` means last effective-state check before external action plus cancellation; `C` means `D` at each command handler's actual sink; `P` means `D` for Backend projection and `X` at the separate RDM callback. `Vnn` identifies the route for traceability; verification uses representative concurrency cases and complete source-to-sink review, rather than one duplicate test per route. + +| # | Route → handler (source line) | Actor | Final sink | Now | Required fence | Verify | +|---:|---|---|---|---|---|---| +| 01 | `POST /auth/login` → `login` (`accounts.go:148`) | B | `CreateSessionVerified` | T | retain account/version transaction check | V01 | +| 02 | `POST /auth/login-key/redeem` → `redeemLoginKey` (`accounts.go:106`) | K | `RedeemLoginKey`; failed-attempt security counter | T | retain account/grant transaction check | V02 | +| 03 | `POST /auth/recovery-grants/redeem` → `redeemLoginKey` (`accounts.go:106`) | K | same as 02 | T | same as 02 | V03 | +| 04 | `POST /auth/logout` → `logout` (`accounts.go:202`) | A | `DeleteSession` credential cleanup | — | permit cleanup after disable | V04 | +| 05 | `POST /auth/password` → `changePassword` (`accounts.go:214`) | S | checked setup/forced/active password Store transaction | T | retain session/account recheck | V05 | +| 06 | `POST /admin/users` → `createAdminUser` (`accounts.go:321`) | A | `CreatePendingAccount` | T | retain actor enabled/admin transaction check | V06 | +| 07 | `PATCH /me/profile` → `patchMeProfile` (`accounts.go:394`) | S | `PatchAccountProfile` | T | retain actor/target transaction check | V07 | +| 08 | `PATCH /admin/users/{userId}/profile` → `patchAdminAccountProfile` (`accounts.go:419`) | A | `PatchAccountProfile` | T | retain actor/target transaction check | V08 | +| 09 | `PUT /admin/users/{userId}/state` → `setAdminAccountState` (`accounts.go:444`) | A | `SetAccountEnabled` | T | retain shared account lock and in-tx actor check | V09 | +| 10 | `POST /admin/users/{userId}/recovery-proposals` → `createAdminRecoveryProposal` (`recovery.go:7`) | A | `CreateRecoveryProposal` | T | retain actor/target transaction check | V10 | +| 11 | `DELETE /admin/users/{userId}/recovery-proposals/{id}` → `revokeAdminRecoveryProposal` (`recovery.go:47`) | A | `RevokeRecoveryProposal` | T | retain actor/target transaction check | V11 | +| 12 | `POST /auth/recovery-proposals/{id}/confirm` → `confirmRecoveryProposal` (`recovery.go:81`) | S | `ConfirmRecoveryProposal` | T | retain owner/session proof in transaction | V12 | +| 13 | `POST /auth/recovery-proposals/{id}/rotate-grant` → `rotateRecoveryGrant` (`recovery.go:105`) | S | `RotateRecoveryGrant` | T | retain owner/session proof in transaction | V13 | +| 14 | `POST /admin/users/{owner}/password` → `resetAdminUserPassword` (`accounts.go:468`) | A | 410 response only | — | no business sink | V14 | +| 15 | `POST /admin/users/{owner}/login-keys` → `issueAdminLoginKey` (`accounts.go:478`) | A | 410 response only | — | no business sink | V15 | +| 16 | `DELETE /admin/users/{owner}/login-keys/{id}` → `revokeAdminLoginKey` (`accounts.go:494`) | A | `RevokeLoginKeyCascade` | M | D: lock/recheck admin actor too, then target/grant | V16 | +| 17 | `POST /projects` → `createProject` (`server.go:713`) | A | `CreateProject` or RDM `Create` + Host projection | M/E | P | V17 | +| 18 | `PATCH /projects/{id}` → `patchProject` (`server.go:734`) | A | RDM `Rename` or `RenameProject`; `UpdateProjectSettings` | M/E | P; D for every settings write | V18 | +| 19 | `DELETE /projects/{id}` → `deleteProject` (`server.go:812`) | A | RDM `Archive` or `DeleteProject` | M/E | P | V19 | +| 20 | `POST /projects/{id}/conversations` → `createConversation` (`server.go:875`) | A | `CreateConversation[InProject]`; RDM `Work` precheck | M | D; keep domain work check | V20 | +| 21 | `PATCH /conversations/{id}` → `patchConversation` (`server.go:914`) | A | update, permission, note, flags: separate Store writes | M | D at each final write | V21 | +| 22 | `DELETE /conversations/{id}` → `deleteConversation` (`server.go:950`) | A | `DeleteConversation` | M | D | V22 | +| 23 | `POST /conversations/{id}/messages` → `postMessage` (`server.go:956`) | A | message, run, message-run link; optional Bus dispatch | M/E | D per write; C for command; actor lock before conversation lock | V23 | +| 24 | `POST /conversations/{id}/rewind` → `rewindFiles` (`rewind.go:27`) | A | checkpoint files, then `AppendMessage` | M/E | X for rewind, D for message | V24 | +| 25 | `POST /conversations/{id}/regenerate` → `regenerateMessage` (`borrow.go:159`) | A | message flags/exclusion, regen marker, rewind files, new run | M/E | D per write, X for rewind | V25 | +| 26 | `POST /messages/{id}/exclude` → `excludeMessage` (`borrow.go:122`) | A | `ExcludeRunGroup(messageID)` | M | D; derive conversation owner in transaction | V26 | +| 27 | `POST /messages/{id}/variant` → `selectVariant` (`borrow.go:146`) | A | `SelectVariant(messageID)` | M | D; derive conversation owner in transaction | V27 | +| 28 | `POST /secrets` → `createSecret` (`borrow.go:21`) | A | `AddSecret` | M | D | V28 | +| 29 | `POST /secrets/{id}/activate` → `activateSecret` (`borrow.go:39`) | A | `ActivateSecret` | M | D | V29 | +| 30 | `PATCH /secrets/{id}` → `renameSecret` (`borrow.go:43`) | A | `RenameSecret` | M | D | V30 | +| 31 | `DELETE /secrets/{id}` → `deleteSecret` (`borrow.go:54`) | A | `DeleteSecret` | M | D | V31 | +| 32 | `POST /context-entries` → `saveContextEntry` (`borrow.go:64`) | A | `SaveContextEntry` | M | D | V32 | +| 33 | `DELETE /context-entries/{id}` → `deleteContextEntry` (`borrow.go:78`) | A | `DeleteContextEntry` | M | D | V33 | +| 34 | `POST /prompt-commands` → `saveUserPromptCommand` (`borrow.go:87`) | A | `SaveUserCommand`, then process Bus register | M/E | D; X before Bus mutation | V34 | +| 35 | `DELETE /prompt-commands/{id}` → `deleteUserPromptCommand` (`borrow.go:102`) | A | `DeleteUserCommand`, then Bus unregister | M/E | D; X before Bus mutation | V35 | +| 36 | `POST /runs/{id}/cancel` → `cancelRun` (`server.go:1278`) | A | PG `EnqueueCommand` or local cancel handle | M/E | D for enqueue with in-tx run owner; X locally | V36 | +| 37 | `POST /hitl/{id}/resolve` → `resolveHITL` (`server.go:1314`) | A | local HITL/card/event writes or PG `EnqueueCommand` | M | D at each Store sink, including queue | V37 | +| 38 | `POST /cards/{id}/actions/{actionId}` → `cardAction` (`server.go:1401`) | A | Cards action or Bus confirm, then receipt message | M/E | D for card/message; C for confirmed handler | V38 | +| 39 | `POST /cards/{id}/pin` → `cardPin` (`server.go:1453`) | A | card version write | M | D | V39 | +| 40 | `POST /cards/{id}/dismiss` → `cardDismiss` (`server.go:1462`) | A | card version/event write | M | D | V40 | +| 41 | `POST /cards/{id}/to_project` → `cardToProject` (`server.go:1386`) | A | project card copy and original dismissal | M | D at both writes | V41 | +| 42 | `POST /projects/{id}/uploads` → `upload` (`server.go:1469`) | A | Blob + artifact row, card, knowledge/vector index | M/E | F; D at later card/index writes | V42 | +| 43 | `PUT /artifacts/{id}/content` → `putArtifactContent` (`server.go:1651`) | A | **in-place Blob overwrite before SQL UPDATE** | M/E | F using new immutable blob ref, guarded DB pointer swap | V43 | +| 44 | `DELETE /artifacts/{id}` → `deleteArtifact` (`server.go:1656`) | A | metadata, index/vector rows, Blob deletion | M/E | D for visible rows; X for post-commit GC | V44 | +| 45 | `POST /profiles` → `saveProfile` (`server.go:1667`) | A | profile Store row | M | D | V45 | +| 46 | `DELETE /profiles/{id}` → `deleteProfile` (`server.go:1683`) | A | profile Store row | M | D | V46 | +| 47 | `POST /prompts` → `savePromptModule` (`server.go:2023`) | A | prompt Store row | M | D | V47 | +| 48 | `DELETE /prompts/{id}` → `deletePromptModule` (`server.go:2044`) | A | prompt Store row | M | D | V48 | +| 49 | `PUT /settings/user` → `putSettingsUser` (`server.go:1717`) | A | settings transaction, then separate snapshot row | M | D for settings and snapshot | V49 | +| 50 | `PUT /settings/project/{id}` → `putSettingsProject` (`server.go:1726`) | A | settings transaction, then separate snapshot row | M | D for settings and snapshot | V50 | +| 51 | `PUT /settings/system` → `putSettingsSystem` (`server.go:1782`) | A | system settings and snapshot under synthetic owner | M | D with **request actor**, not `OwnerSystemRow` | V51 | +| 52 | `POST /tools` → `createUserTool` (`server.go:1815`) | A | process Registry register; future HTTP tool execution | E | X for registration and each tool execution | V52 | +| 53 | `DELETE /tools/{name}` → `deleteUserTool` (`server.go:1834`) | A | process Registry unregister | E | X | V53 | +| 54 | `POST /plugins/{id}/enabled` → `setPluginEnabled` (`platform_api.go:132`) | A | `SetPluginEnabled` upsert | M | D | V54 | +| 55 | `POST /plugins/{id}/activation` → `setSurfaceActivation` (`platform_api.go:251`) | A | activation transaction, separate audit row | M | D for both writes | V55 | +| 56 | `POST /surfaces` → `submitSurface` (`platform_api.go:194`) | A | snapshot transaction, then event/pending row | M | D for snapshot/event writes | V56 | +| 57 | `POST /surfaces/{id}/actions` → `submitSurfaceAction` (`platform_api.go:271`) | A | action insert, Bus dispatch, action-state update | M/E | D for both action writes; C for dispatch | V57 | +| 58 | `POST /plugins/reload` → `reloadPlugins` (`server.go:1908`) | A | process registry/sidecar changes and `UpsertPlugin` | M/E | X for process changes; D for DB writes | V58 | +| 59 | `POST /commands/{id}` → `execCommand` (`server.go:1211`) | A | Bus audit/card/pending or arbitrary command handler/RDM | M/E | C, X for external handler | V59 | +| 60 | `POST /projects/{id}/knowledge` → `addKnowledge` (`server.go:2055`) | A | `AddKnowledge` batch transaction | M | D; in-tx project/owner check | V60 | +| 61 | `POST /tasks` → `createBackgroundTask` (`platform_api.go:16`) | A | task insert | M | D | V61 | +| 62 | `POST /tasks/{id}` → `updateBackgroundTask` (`platform_api.go:52`) | A | task update | M | D | V62 | +| 63 | `POST /memory` → `putMemory` (`platform_api.go:69`) | A | memory upsert | M | D | V63 | +| 64 | `POST /audit` → `postAudit` (`platform_api.go:112`) | A | client-origin business audit row | M | D; security-denial audit is a separate exception | V64 | +| 65 | `POST /admin/backup` → `backup` (`server.go:2224`) | A | directory + SQLite backup file | E | X and explicit admin authorization | V65 | +| 66 | `POST /admin/recovery-holds/{runId}/release` → `releaseRecoveryHold` (`accounts.go:526`) | A | recovery-hold UPDATE | M | D with actor admin check and run/hold owner lookup | V66 | + +The route count is not a full write count. `GET /api/llm/budget` → `llmBudget` (`platform_api.go`) calls `AccountTokens(owner, 0, budget)`; the amended Store returns a read-only total when tokens are zero. **V67** therefore has no business write. Ordinary authenticated GETs can refresh session `last_seen_at` as credential maintenance; session/API-key in-flight revocation remains a separate impact analysis. + +## Host and run manager exits + +| ID | Entry and actor | Final sink | Now → required fence | Verify | +|---|---|---|---|---| +| H01 | `inprocHost.KVPut`, host owner (`hostcaps.go:295`) | `PutPluginKV` | M → D | VH01 | +| H02 | `inprocHost.Audit`, host owner; actor label is `plugin:` (`hostcaps.go:309`) | `AppendAuditEvent` | M → D using host owner | VH02 | +| H03 | `KnowledgeWrite/Publish/Delete`, host owner (`hostcaps.go:350-424`) | knowledge rows, vector rows; publish deletes then writes | M/E → D at every DB sink; X before embedding/remote model | VH03 | +| H04 | `TaskCreate/Update`, host owner (`hostcaps.go:427-443`) | background-task rows | M → D | VH04 | +| H05 | `EnsureProject/RenameOwnedProject`, host owner (`project_lifecycle.go:125-135`) | Backend projection rows, RDM callback upstream | M/E → actor D before project-name lock; X at callback | VH05 | +| H06 | `SurfaceSave`, host owner (`hostcaps.go:481`) | `surfacesvc.Submit` snapshot/event | M → D at Store sinks | VH06 | +| H07 | `Complete`, host owner (`hostcaps.go:319`) | external LLM request | E → X with context cancellation | VH07 | +| H08 | scoped Host reconstructed from owner/plugin ID (`hostcaps.go:103`) | same H01-H07 methods | M/E → same final sink fences; early `requireEffective` alone is insufficient | VH08 | +| G01 | `startClaimedRun`/subagent spawn, run owner (`runmgr.go:1443,1924`) | `CreateRun`, `UpdateRun` and admission rows | M → D for business state; keep failure-only cleanup explicit | VG01 | +| G02 | `Emit`, `AskHITL`, cards, plan/todos (`runmgr.go:676-935`) | events, HITL/card, plan/todo rows | M → D at each final Store write | VG02 | +| G03 | `WriteArtifact`, run owner (`runmgr.go:968`) | Blob, artifact, event, knowledge/vector rows | M/E → F then D for each later write | VG03 | +| G04 | `ExecuteTool`, run owner (`runmgr.go:1080-1167`) | Registry HTTP/command/filesystem side effects, output artifacts | E/M → X before external call; C/D for command and output sinks | VG04 | +| G05 | `stageInputs`, checkpoint recording/rewind and subagent transcript (`inputs.go:17-90`, `rewind.go:37-99`, `subagent_transcript.go:46-84`) | workspace files | E → X before each effect and cancellation; no cross-system atomic claim | VG05 | +| G06 | `ResolveHITL`, run/card owner (`runmgr.go:1780-1860`) | HITL/card/event and possible run writes | M → D | VG06 | +| G07 | background notice delivery and subagent completion (`runmgr.go:1982-2041,2444-2455`) | run/message/event plus transcript files | M/E → D for business rows, X for files | VG07 | +| G08 | `settleRunWithFence`, run owner (`runmgr.go:1700-1722`) | final run + assistant message | T for enabled check in `store/recovery.go:184-220`; retain narrow failure-state exception | VG08 | +| G09 | `OnUsage`, run owner (`app.go:668-680`) | usage and budget audit rows | M → D or documented accounting-only exception | VG09 | + +At the 4053c4d baseline, Host/runmgr effective-account queries were early checks, Bus confirmation and Surface writes had separate sinks, and artifact content could be overwritten in place before a checked SQL update. These are the reasons for the sink-level mapping below; file paths and old line numbers in the historical tables are navigational rather than final-SHA evidence. + +## Amended candidate mapping and limits + +This section supersedes the historical `Now` column when checking the amended candidate. V01–V15 retain their existing account/credential transaction or response-only behavior; V16 uses actor-checked login-key revocation. V17–V23 route project, conversation, message, run creation and link writes through account-checked Store entries. V24–V35 use guarded file/message and borrow/secret/context/command entries; V25 prepares the message exclusion and regeneration marker in one checked transaction, then consumes the marker only with successful run settlement. V36–V44 guard command queue, HITL, card, surface/event, artifact metadata and index writes. Artifact content updates switch immutable blob references in the checked transaction. V45–V51 guard profile, prompt, settings and snapshot writes, using the **request actor** for system settings. V52–V59 combine last checks for process/HTTP/tool effects with guarded plugin, surface, card, command pending and audit DB writes. V60–V66 use guarded knowledge, task, memory, audit and hold updates. V65 refuses whole-database backup under authenticated mode because administrator status does not grant access to other owners' business content; auth-off backup has a last check. V67 is read-only at zero tokens. + +VH01–VH04/VH06/VH08 use the Host owner and final Store account checks for Backend-owned rows. H05 delegated rename now derives a **single-project** projection from the current authenticated lifecycle or command Host; its final Store transaction locks the real operator and creator in stable order, checks both effective states and verifies the persisted project owner. The global `NewScopedHost` cannot derive this delegated capability; its original creator-owned methods remain. VH07 checks immediately before its external model call and passes the caller context. VG01–VG09 use checked run, admission/lease, event, HITL, card, artifact, index, notice, command and settle sinks. File staging, transcript, tool HTTP/search/code execution and overlay writeback check just before effects and propagate cancellation. Failed/cancelled/interrupted run sealing, lease release, notice deletion and failed-start marker deletion are narrowly scoped safety cleanup; positive token usage is accounting for model work already incurred. + +The H05/V17–V19 late-write blocker was reproduced in the earlier candidate and repaired under the separately approved Documents `5783940` plan. Tests cover creator/operator effective state, a disable between early check and final write on SQLite/PG, a two-process PG disable, opposite lock order progress, and both HTTP PATCH and no-conversation command Host sources. The RDM database authorization/intent commit is still separate from Backend's account/project transaction; a permission withdrawal or cross-database failure is handled by the existing I1 failure intent and recovery path rather than a zero-window assertion. Completion and stage 6 claims require exact-HEAD receipts and human review. + +The account lock closes the **account-enabled versus Backend-owned business write** race on the same database. It does not prove that a session or API key revoked after request authentication cannot complete a write while that account remains enabled; that is a distinct credential-version fence and outside this amendment's promise. RDM commits in its own database and external requests, processes and files cannot share the Backend transaction. The last check can race with those effects, and a multi-file operation can leave earlier files committed when a later check fails. No cross-database or external zero-window claim is made. + +**Sink discovery still required:** Backend source alone cannot enumerate every dynamically registered Bus handler, plugin sidecar command, or the RDM callback's independent database writes. V17–V19, V23, V38, V57–V59 and VH05 must record the actual composed handlers and their final sinks in the tested composition; an unknown or unguarded handler blocks the A1 completion claim. The external HTTP tool destination and filesystem actions depend on runtime configuration, so VG04/VG05 must verify the actual enabled tool set and cancellation points. + +## Common implementation and verification gate + +1. Build one short Store write-transaction primitive: `Begin` → PG actor advisory lock (`account:`) → `SELECT enabled ... FOR UPDATE` → existing resource/scope/conversation/admission locks → resource authorization and write → commit. The actor lock must precede all other PG locks, including project-name locks; participating account owners use sorted order, then bootstrap lock where required. `SetAccountEnabled` already takes the account lock (`store/account_state.go:20-45`); `lockAccountUsers` supplies sorted PG account locking (`store/account_model.go:115-145`). SQLite uses `_txlock=immediate` (`store/store.go:188-199`). In auth-off mode, only a missing `OwnerDefault` row is implicitly enabled; an existing disabled row rejects (`app/account_guard.go:10-23`). No DB connection or transaction spans request body decoding, embedding, tool execution, RDM callback, or full HTTP lifetime. +2. Make methods with only resource IDs derive owner inside the guarded transaction. Pass actor separately for system settings (`OwnerSystemRow`), delegated Host calls, commands, queue commands, audit and background jobs. Preserve project/duty authorization as a separate condition; account enabled does not grant resource access. For files, stage invisible immutable bytes and switch the visible DB reference in `D`; failed transaction deletes staged bytes, post-commit old-blob GC may continue. RDM's independent database keeps the explicit cross-database window from the amendment. +3. Review **every** V/VH/VG route against its final sink on the fixed candidate SHA. Use deterministic pause-and-disable tests for representative project, message/run, artifact, settings, command/surface and Host/runmgr paths on SQLite and isolated PostgreSQL. Test account lock ordering with two PG connections. Verify the existing credential routes' in-transaction ordering. For external effects, verify the last check and cancellation and state the remaining check-to-effect window. Record exact command, tested SHA and output in the Dev task card; local SELF is not independent REPLAY or CI GATE. + +Stage 6 stops if any Backend-owned business write lacks a final actor-enabled transaction fence, if artifact content remains visibly mutable after disable via in-place overwrite, or if an external/RDM effect is described as same-DB atomic. Session/API-key in-flight revocation is a separate amendment impact analysis; enabled checks do not prove credential-revocation fencing. diff --git a/eval/live/work_checkpoints_persistence_test.go b/eval/live/work_checkpoints_persistence_test.go new file mode 100644 index 0000000..91bac06 --- /dev/null +++ b/eval/live/work_checkpoints_persistence_test.go @@ -0,0 +1,40 @@ +//go:build live + +package live + +import ( + "database/sql" + "fmt" + "path/filepath" + "testing" +) + +func TestPersistedRunningCheckpointMaxDoesNotMissShortWindow(t *testing.T) { + path := filepath.Join(t.TempDir(), "checkpoint.db") + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if _, err = db.Exec(`CREATE TABLE events (conversation_id TEXT, seq INTEGER, type TEXT, payload TEXT)`); err != nil { + t.Fatal(err) + } + for seq, item := range []struct { + conversation, status, state string + checkpoint int + }{ + {"wanted", "active", "running", 1}, + {"other", "active", "running", 9}, + {"wanted", "active", "running", 2}, + {"wanted", "failed", "failed", 99}, + } { + payload := fmt.Sprintf(`{"card":{"status":%q,"task":{"executionState":%q,"checkpointSeq":%d}}}`, item.status, item.state, item.checkpoint) + if _, err = db.Exec(`INSERT INTO events(conversation_id,seq,type,payload) VALUES(?,?,?,?)`, item.conversation, seq+1, "card.updated", payload); err != nil { + t.Fatal(err) + } + } + got, err := persistedRunningCheckpointMax(path, "wanted") + if err != nil || got != 2 { + t.Fatalf("persisted in-flight checkpoints: max=%d err=%v; want 2", got, err) + } +} diff --git a/eval/live/work_checkpoints_test.go b/eval/live/work_checkpoints_test.go new file mode 100644 index 0000000..82937bf --- /dev/null +++ b/eval/live/work_checkpoints_test.go @@ -0,0 +1,299 @@ +//go:build live + +package live + +import ( + core "agent-system-core" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "syscall" + "testing" + "time" + + _ "modernc.org/sqlite" +) + +type checkpointManifest struct { + PID int `json:"pid"` + Binary, BinarySHA256, BackendSHA, DevSHA, Base, DataDir, Mode string +} + +func checkpointEvalOutputName(filter, authorization string) (string, bool) { + if filter == "A01" && authorization == "approved-20261013" { + return "candidate-extra-a01", true + } + if filter == "A01" && authorization == "approved-followup-20261013" { + return "candidate-followup-a01", true + } + if filter == "A01" && authorization == "approved-r02-20261013" { + return "candidate-r02-a01", true + } + if filter == "A01" && authorization == "approved-r03-20261013" { + return "candidate-r03-a01", true + } + if filter == "A01" && authorization == "approved-r04-20261014" { + return "candidate-r04-a01", true + } + if filter == "A02,A04" { + return "candidate-remaining", true + } + return "", false +} + +func persistedRunningCheckpointMax(path, conversationID string) (int, error) { + db, err := sql.Open("sqlite", "file:"+path+"?mode=ro") + if err != nil { + return 0, err + } + defer db.Close() + rows, err := db.Query(`SELECT payload FROM events WHERE conversation_id = ? AND type = 'card.updated' ORDER BY seq`, conversationID) + if err != nil { + return 0, err + } + defer rows.Close() + maximum := 0 + for rows.Next() { + var raw []byte + if err = rows.Scan(&raw); err != nil { + return 0, err + } + var event struct { + Card struct { + Status string `json:"status"` + Task struct { + ExecutionState string `json:"executionState"` + CheckpointSeq int `json:"checkpointSeq"` + } `json:"task"` + } `json:"card"` + } + if err = json.Unmarshal(raw, &event); err != nil { + return 0, err + } + if event.Card.Status == "active" && event.Card.Task.ExecutionState == "running" && event.Card.Task.CheckpointSeq > maximum { + maximum = event.Card.Task.CheckpointSeq + } + } + return maximum, rows.Err() +} + +func TestFollowupA01KeepsItsOwnOneShotResult(t *testing.T) { + for _, tc := range []struct { + filter, authorization, name string + allowed bool + }{ + {"A01", "approved-20261013", "candidate-extra-a01", true}, + {"A01", "approved-followup-20261013", "candidate-followup-a01", true}, + {"A01", "approved-r02-20261013", "candidate-r02-a01", true}, + {"A01", "approved-r03-20261013", "candidate-r03-a01", true}, + {"A01", "approved-r04-20261014", "candidate-r04-a01", true}, + {"A02,A04", "approved-r03-20261013", "candidate-remaining", true}, + {"A01", "", "", false}, + {"A01", "unknown", "", false}, + {"A02,A04", "", "candidate-remaining", true}, + } { + name, allowed := checkpointEvalOutputName(tc.filter, tc.authorization) + if name != tc.name || allowed != tc.allowed { + t.Fatalf("filter=%q authorization=%q: got %q/%v; want %q/%v", tc.filter, tc.authorization, name, allowed, tc.name, tc.allowed) + } + } +} + +func verifiedCheckpointManifest(t *testing.T) checkpointManifest { + t.Helper() + path := os.Getenv("WORK_CHECKPOINT_EVAL_OWNERSHIP_MANIFEST") + if path == "" { + t.Fatal("explicit task-owned process manifest required") + } + raw, e := os.ReadFile(path) + if e != nil { + t.Fatal(e) + } + var m checkpointManifest + if json.Unmarshal(raw, &m) != nil { + t.Fatal("invalid manifest") + } + u, e := url.Parse(m.Base) + if e != nil || u.Hostname() != "127.0.0.1" || m.Base != os.Getenv("AGENT_EVAL_BASE") || m.PID <= 0 || !filepath.IsAbs(m.DataDir) || !strings.Contains(m.DataDir, "fast-slow-u2-u3-") { + t.Fatal("manifest scope mismatch") + } + if e = syscall.Kill(m.PID, 0); e != nil { + t.Fatal("owned process is not live") + } + actual, e := os.Readlink(fmt.Sprintf("/proc/%d/exe", m.PID)) + if e != nil || actual != m.Binary { + t.Fatal("process executable mismatch") + } + bytes, e := os.ReadFile(actual) + if e != nil { + t.Fatal(e) + } + sum := sha256.Sum256(bytes) + if hex.EncodeToString(sum[:]) != m.BinarySHA256 { + t.Fatal("binary hash mismatch") + } + if _, e = os.Stat(filepath.Join(m.DataDir, "agent-system.db")); e != nil { + t.Fatal("isolated data missing") + } + return m +} + +// The batch driver invokes this once per baseline/candidate, using the same +// three synthetic materials, route and profile. It never resolves a business HITL. +func TestWorkCheckpoints(t *testing.T) { + m := verifiedCheckpointManifest(t) + if m.Mode != "model" { + t.Fatal("real model evaluation requires model mode") + } + materials := os.Getenv("AGENT_EVAL_MATERIALS") + outDir := os.Getenv("AGENT_EVAL_OUT") + if materials == "" || outDir == "" { + t.Fatal("explicit external material and evidence directories required") + } + if e := os.MkdirAll(outDir, 0700); e != nil { + t.Fatal(e) + } + c := &client{base: m.Base, http: &http.Client{Timeout: 30 * time.Second}, t: t} + label := os.Getenv("WORK_CHECKPOINT_EVAL_LABEL") + if label != "baseline" && label != "candidate" { + t.Fatal("fixed baseline/candidate label required") + } + caseFilter := os.Getenv("WORK_CHECKPOINT_EVAL_CASES") + outputName := label + if caseFilter != "" { + if label != "candidate" { + t.Fatal("candidate cases only") + } + var allowed bool + outputName, allowed = checkpointEvalOutputName(caseFilter, os.Getenv("WORK_CHECKPOINT_EXTRA_A01")) + if !allowed { + t.Fatal("explicitly authorized case selection required") + } + } + if _, err := os.Stat(filepath.Join(outDir, outputName+".json")); err == nil { + t.Fatal("sample already exists; do not retry") + } + all := []map[string]any{} + total := 0 + for _, testCase := range []struct { + id, focus string + files []string + }{{"A01", "", []string{"需求.txt", "会议.txt", "评审.txt"}}, {"A02", "重点核对预算、外部依赖和交付日期;请保留限制", []string{"需求.txt", "会议.txt", "评审.txt"}}, {"A04", "", []string{"需求.txt", "会议.txt", "扫描.pdf"}}} { + if caseFilter != "" && !strings.Contains(caseFilter, testCase.id) { + continue + } + if total >= 120000 { + t.Fatal("instance reported token budget exhausted") + } + var project struct { + ID string `json:"id"` + } + if e := c.do("POST", "/api/projects", map[string]any{"name": "checkpoint-" + label + "-" + testCase.id}, &project); e != nil { + t.Fatal(e) + } + var conv struct { + ID string `json:"id"` + } + if e := c.do("POST", "/api/projects/"+project.ID+"/conversations", map[string]any{"title": testCase.id, "profileId": "checkpoint-eval"}, &conv); e != nil { + t.Fatal(e) + } + ids := []string{} + for _, name := range testCase.files { + bytes, e := os.ReadFile(filepath.Join(materials, name)) + if e != nil { + t.Fatal(e) + } + ids = append(ids, c.upload(t, project.ID, name, bytes)) + } + var send map[string]any + if e := c.do("POST", "/api/conversations/"+conv.ID+"/messages", map[string]any{"text": testCase.focus, "attachmentIds": ids, "requestId": "checkpoint-" + label + "-" + testCase.id}, &send); e != nil { + t.Fatal(e) + } + deadline := time.Now().Add(300 * time.Second) + var snapshot struct { + Cards []core.ActivityCard `json:"cards"` + ReplaySeq int64 `json:"replaySeq"` + } + observations := []map[string]any{} + seen := map[string]bool{} + for { + // Decode into fresh storage: reusing a slice lets JSON overwrite Task + // pointers already saved as historical observations. + snapshot.Cards = nil + if e := c.do("GET", "/api/conversations/"+conv.ID+"/cards", nil, &snapshot); e != nil { + t.Fatal(e) + } + terminal := false + for _, card := range snapshot.Cards { + if card.Kind != core.CardKindProgress { + continue + } + if card.Task != nil { + key := fmt.Sprintf("%s:%d", card.ID, card.Task.Revision) + if !seen[key] { + seen[key] = true + observations = append(observations, map[string]any{"observedAt": time.Now().UTC(), "card": card, "replaySeq": snapshot.ReplaySeq}) + } + terminal = card.Task.Terminal() + } else { + terminal = card.Status == core.CardDone || card.Status == core.CardFailed + } + } + if terminal { + break + } + if label == "baseline" && time.Until(deadline) < 298*time.Second && len(snapshot.Cards) == 0 { + runObj, ok := send["run"].(map[string]any) + if ok { + var state struct { + Run core.Run `json:"run"` + } + if c.do("GET", "/api/runs/"+fmt.Sprint(runObj["id"]), nil, &state) == nil && state.Run.Status == core.RunCompleted { + break + } + } + } + if time.Now().After(deadline) { + t.Fatalf("%s background did not finish within budget", testCase.id) + } + time.Sleep(250 * time.Millisecond) + } + var detail map[string]json.RawMessage + if e := c.do("GET", "/api/conversations/"+conv.ID, nil, &detail); e != nil { + t.Fatal(e) + } + var runs []core.Run + _ = json.Unmarshal(detail["runs"], &runs) + for _, r := range runs { + if r.Usage != nil { + total += r.Usage.TotalTokens + } + } + record := map[string]any{"case": testCase.id, "projectId": project.ID, "conversationId": conv.ID, "send": send, "observations": observations, "finalCards": snapshot.Cards, "detail": detail, "reportedTokensCumulative": total, "quality": "requires independent comparison against provided materials; quote existence is not semantic proof"} + all = append(all, record) + raw, _ := json.MarshalIndent(map[string]any{"manifest": m, "label": label, "cases": all, "reportedTokens": total, "noticeModelTurns": "disabled equally in controlled comparison"}, "", " ") + if e := os.WriteFile(filepath.Join(outDir, outputName+".json"), raw, 0600); e != nil { + t.Fatal(e) + } + if label == "candidate" && testCase.id == "A01" { + maximum, err := persistedRunningCheckpointMax(filepath.Join(m.DataDir, "agent-system.db"), conv.ID) + if err != nil { + t.Fatal(err) + } + if maximum < 2 { + t.Fatal("A01 has fewer than two persisted running checkpoints; inspect raw evidence") + } + } + if total > 120000 { + t.Fatal("reported instance token target exceeded; no further sample started") + } + } + t.Logf("%s: %d material tasks, cumulative reported tokens=%d; raw quality evidence=%s", label, len(all), total, filepath.Join(outDir, outputName+".json")) +} diff --git a/go.mod b/go.mod index e61a94f..f976403 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.27 require ( agent-command v0.0.0 + agent-document-renderer v0.0.0 agent-intent v0.0.0 agent-native v0.0.0 agent-squad v0.0.0 @@ -26,12 +27,15 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/kr/text v0.2.0 // indirect github.com/mattn/go-isatty v0.0.24 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rogpeppe/go-internal v1.16.0 // indirect golang.org/x/sync v0.23.0 // indirect golang.org/x/sys v0.48.0 // indirect golang.org/x/text v0.41.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.77.1 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.12.1 // indirect @@ -52,3 +56,5 @@ replace error-code-system => ./deps/error-code-system replace logging-system => ./deps/logging-system replace agent-command => ./deps/agent-command + +replace agent-document-renderer => ./deps/agent-document-renderer diff --git a/go.sum b/go.sum index b4fc65f..1ac67f1 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,7 @@ github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -25,6 +26,10 @@ github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0 h1:7Q+xNAZFmnfYOMweHN3c/PDFUKKfY1pVJ26K++QvVfU= github.com/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0/go.mod h1:1fEHWurg7pvf5SG6XNE5Q8UZmOwex51Mkx3SLhrW5B4= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= @@ -37,6 +42,8 @@ github.com/redis/go-redis/v9 v9.14.0 h1:u4tNCjXOyzfgeLN+vAZaW1xUooqWDqVEsZN0U01j github.com/redis/go-redis/v9 v9.14.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g= +github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -55,6 +62,8 @@ golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU= golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/app/account_directory.go b/internal/app/account_directory.go new file mode 100644 index 0000000..aaa673f --- /dev/null +++ b/internal/app/account_directory.go @@ -0,0 +1,415 @@ +package app + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "sort" + "strings" + "sync" + "unicode" + "unicode/utf8" + + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" +) + +type accountDirectorySlot struct { + mu sync.RWMutex + pluginID string + resolver extension.AccountDirectoryScopeResolver + secret [32]byte + keyVersion int +} + +func newAccountDirectorySlot(st *store.Store) (*accountDirectorySlot, error) { + key, err := st.GetAccountCursorKey() + if err != nil { + return nil, err + } + return &accountDirectorySlot{secret: key.Secret, keyVersion: key.Version}, nil +} + +func (s *accountDirectorySlot) register(pluginID string, fn extension.AccountDirectoryScopeResolver) error { + if fn == nil || pluginID != "rdm" { + return store.ErrAccountForbidden + } + s.mu.Lock() + defer s.mu.Unlock() + if s.resolver != nil { + return errors.New("account directory resolver already registered") + } + s.pluginID, s.resolver = pluginID, fn + return nil +} + +type directoryPolicy struct { + allowed map[string]bool + match []string + candidates map[string]bool + owners map[string]bool + rows []store.DirectoryAccountRow + all bool + status string + version string +} + +type directoryCursor struct { + KeyVersion int `json:"k"` + Version string `json:"v"` + Owner string `json:"o"` + UserID string `json:"u"` +} + +type directoryVisibleRow struct { + UserID string + Owner string + Enabled bool + Name string + StaffNo string +} + +func knownDirectoryField(field string) bool { + return field == "owner" || field == "name" || field == "staffNo" +} + +func (h inprocHost) directoryPolicy(ctx context.Context, q extension.AccountDirectoryQuery, loadRows bool) (directoryPolicy, error) { + var p directoryPolicy + if err := h.requireEffective(); err != nil { + return p, err + } + if h.actor == "" || h.caps.st == nil || h.caps.directory == nil || q.SchemaVersion != extension.AccountDirectorySchemaVersion { + return p, store.ErrAccountForbidden + } + actor, err := h.caps.st.GetUserAuthState(h.actor) + if err != nil || !actor.Enabled { + return p, store.ErrAccountForbidden + } + if q.Mode != extension.AccountDirectoryModeExact && q.Mode != extension.AccountDirectoryModeFuzzy { + return p, store.ErrInvalidAccountProfile + } + if !utf8.ValidString(q.Query) || utf8.RuneCountInString(q.Query) > 128 { + return p, store.ErrInvalidAccountProfile + } + for _, r := range q.Query { + if unicode.IsControl(r) { + return p, store.ErrInvalidAccountProfile + } + } + p.status = q.Status + if p.status == "" { + p.status = "enabled" + } + if p.status != "enabled" && p.status != "disabled" && p.status != "all" { + return p, store.ErrInvalidAccountProfile + } + var scope extension.AccountDirectoryScope + switch q.Purpose { + case extension.AccountDirectoryPurposeSystemUsers: + if !actor.IsAdmin || q.ProjectID != "" || q.DocumentID != "" { + return p, store.ErrAccountForbidden + } + scope = extension.AccountDirectoryScope{AllowedFields: []string{"owner", "name", "staffNo"}, + MatchFields: []string{"owner", "name", "staffNo"}, AllCandidates: true, ScopeVersion: "system.users.v1"} + case extension.AccountDirectoryPurposeProjectMembers, extension.AccountDirectoryPurposeProjectRoster, extension.AccountDirectoryPurposeDocumentDuties: + // project.member-roster is an explicit enumeration of current project + // members: only the trusted RDM resolver may name them, every account + // status is allowed and an empty query lists the whole roster. + roster := q.Purpose == extension.AccountDirectoryPurposeProjectRoster + if h.pluginID != "rdm" || q.ProjectID == "" || (q.Purpose == extension.AccountDirectoryPurposeDocumentDuties && q.DocumentID == "") || + (q.Purpose == extension.AccountDirectoryPurposeProjectMembers && q.DocumentID != "") || (roster && q.DocumentID != "") || + (!roster && p.status != "enabled") { + return p, store.ErrAccountForbidden + } + h.caps.directory.mu.RLock() + resolver, pluginID := h.caps.directory.resolver, h.caps.directory.pluginID + h.caps.directory.mu.RUnlock() + if resolver == nil || pluginID != h.pluginID { + return p, store.ErrAccountForbidden + } + scope, err = resolver(ctx, extension.AccountDirectoryScopeRequest{ + Actor: h.actor, PluginID: h.pluginID, Purpose: q.Purpose, ProjectID: q.ProjectID, DocumentID: q.DocumentID, + }) + if err != nil { + return p, store.ErrAccountForbidden + } + if (q.Purpose == extension.AccountDirectoryPurposeDocumentDuties || roster) && scope.AllCandidates { + return p, store.ErrAccountForbidden + } + default: + return p, store.ErrAccountForbidden + } + if scope.ScopeVersion == "" { + return p, store.ErrAccountForbidden + } + p.allowed = map[string]bool{"owner": true} + for _, field := range scope.AllowedFields { + if !knownDirectoryField(field) { + return p, store.ErrAccountForbidden + } + p.allowed[field] = true + } + matchAllowed := map[string]bool{} + for _, field := range scope.MatchFields { + if !knownDirectoryField(field) || !p.allowed[field] { + return p, store.ErrAccountForbidden + } + matchAllowed[field] = true + } + requested := q.SearchFields + if len(requested) == 0 { + requested = []string{"owner", "name"} + } + for _, field := range requested { + if !matchAllowed[field] { + return p, store.ErrAccountForbidden + } + p.match = append(p.match, field) + } + sort.Strings(p.match) + p.candidates = make(map[string]bool, len(scope.CandidateIDs)) + p.owners = make(map[string]bool, len(scope.CandidateOwners)) + for _, id := range scope.CandidateIDs { + if id == "" { + return p, store.ErrAccountForbidden + } + p.candidates[id] = true + } + for _, owner := range scope.CandidateOwners { + if owner == "" { + return p, store.ErrAccountForbidden + } + p.owners[owner] = true + } + p.all = scope.AllCandidates + if q.Purpose == extension.AccountDirectoryPurposeSystemUsers { + p.all = true + } + visible := make([]directoryVisibleRow, 0) + if loadRows { + rows, err := h.caps.st.ListDirectoryAccountRows() + if err != nil { + return p, err + } + for _, row := range rows { + if !directoryRowAllowed(row, p) { + continue + } + p.rows = append(p.rows, row) + item := directoryVisibleRow{UserID: row.UserID, Owner: row.Owner, Enabled: row.Enabled} + if p.allowed["name"] { + item.Name = row.Name + } + if p.allowed["staffNo"] { + item.StaffNo = row.StaffNo + } + visible = append(visible, item) + } + } + ids := make([]string, 0, len(p.candidates)) + for id := range p.candidates { + ids = append(ids, id) + } + sort.Strings(ids) + owners := make([]string, 0, len(p.owners)) + for owner := range p.owners { + owners = append(owners, owner) + } + sort.Strings(owners) + allowed := make([]string, 0, len(p.allowed)) + for field := range p.allowed { + allowed = append(allowed, field) + } + sort.Strings(allowed) + fingerprint := struct { + Actor, Plugin, Purpose, Project, Document, Mode, Query, Status, Scope string + Match, Allowed, IDs, Owners []string + Visible []directoryVisibleRow + All bool + }{Actor: h.actor, Plugin: h.pluginID, Purpose: q.Purpose, Project: q.ProjectID, Document: q.DocumentID, + Mode: q.Mode, Query: strings.TrimSpace(q.Query), Status: p.status, + Scope: scope.ScopeVersion, Match: p.match, Allowed: allowed, IDs: ids, Owners: owners, Visible: visible, All: p.all} + encoded, err := json.Marshal(fingerprint) + if err != nil { + return p, err + } + digest := sha256.Sum256(encoded) + p.version = hex.EncodeToString(digest[:]) + return p, nil +} + +func (s *accountDirectorySlot) encodeCursor(c directoryCursor) (string, error) { + c.KeyVersion = s.keyVersion + data, err := json.Marshal(c) + if err != nil { + return "", err + } + mac := hmac.New(sha256.New, s.secret[:]) + _, _ = mac.Write([]byte("iam-directory-cursor-v1\x00")) + _, _ = mac.Write(data) + return base64.RawURLEncoding.EncodeToString(data) + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil +} + +func (s *accountDirectorySlot) decodeCursor(token, version string) (directoryCursor, error) { + var c directoryCursor + payload, signature, found := strings.Cut(token, ".") + if !found { + return c, store.ErrConflict + } + data, err := base64.RawURLEncoding.DecodeString(payload) + if err != nil || len(data) > 1024 { + return c, store.ErrConflict + } + provided, err := base64.RawURLEncoding.DecodeString(signature) + if err != nil { + return c, store.ErrConflict + } + mac := hmac.New(sha256.New, s.secret[:]) + _, _ = mac.Write([]byte("iam-directory-cursor-v1\x00")) + _, _ = mac.Write(data) + if !hmac.Equal(provided, mac.Sum(nil)) || json.Unmarshal(data, &c) != nil || c.KeyVersion != s.keyVersion || c.Version != version || c.Owner == "" || c.UserID == "" { + return directoryCursor{}, store.ErrConflict + } + return c, nil +} + +func directoryEntry(row store.DirectoryAccountRow, p directoryPolicy) extension.AccountDirectoryEntry { + entry := extension.AccountDirectoryEntry{UserID: row.UserID, Owner: row.Owner, Valid: row.Enabled} + if p.allowed["name"] { + entry.Name = row.Name + } + if p.allowed["staffNo"] { + entry.StaffNo = row.StaffNo + } + return entry +} + +func directoryMatches(row store.DirectoryAccountRow, fields []string, mode, query string) bool { + for _, field := range fields { + value := row.Owner + switch field { + case "name": + value = row.Name + case "staffNo": + value = row.StaffNo + } + if mode == extension.AccountDirectoryModeExact && strings.EqualFold(value, query) { + return true + } + if mode == extension.AccountDirectoryModeFuzzy && strings.Contains(strings.ToLower(value), strings.ToLower(query)) { + return true + } + } + return false +} + +func directoryRowAllowed(row store.DirectoryAccountRow, p directoryPolicy) bool { + if !p.all && !p.candidates[row.UserID] && !p.owners[row.Owner] { + return false + } + return p.status == "all" || (p.status == "enabled" && row.Enabled) || (p.status == "disabled" && !row.Enabled) +} + +func (h inprocHost) QueryAccountDirectory(ctx context.Context, q extension.AccountDirectoryQuery) (extension.AccountDirectoryPage, error) { + query := strings.TrimSpace(q.Query) + // project.member-roster enumerates the resolved members: an empty query + // lists every row inside the scope with normal limit/cursor paging. + enumerate := query == "" && q.Purpose == extension.AccountDirectoryPurposeProjectRoster + p, err := h.directoryPolicy(ctx, q, query != "" || enumerate) + if err != nil { + return extension.AccountDirectoryPage{}, err + } + maxLimit := 50 + if q.Purpose == extension.AccountDirectoryPurposeSystemUsers { + maxLimit = 100 + } + if q.Limit == 0 { + q.Limit = 20 + } + if q.Limit < 1 || q.Limit > maxLimit { + return extension.AccountDirectoryPage{}, store.ErrInvalidAccountProfile + } + page := extension.AccountDirectoryPage{Entries: []extension.AccountDirectoryEntry{}, DirectoryVersion: p.version} + if query == "" && !enumerate { + if q.Cursor != "" { + return extension.AccountDirectoryPage{}, store.ErrConflict + } + return page, nil + } + if query != "" && q.Mode == extension.AccountDirectoryModeFuzzy && utf8.RuneCountInString(query) < 2 { + return extension.AccountDirectoryPage{}, store.ErrInvalidAccountProfile + } + var after directoryCursor + if q.Cursor != "" { + after, err = h.caps.directory.decodeCursor(q.Cursor, p.version) + if err != nil { + return extension.AccountDirectoryPage{}, err + } + } + matched := make([]store.DirectoryAccountRow, 0, q.Limit+1) + for _, row := range p.rows { + if !enumerate && !directoryMatches(row, p.match, q.Mode, query) { + continue + } + if after.Owner != "" && (row.Owner < after.Owner || (row.Owner == after.Owner && row.UserID <= after.UserID)) { + continue + } + matched = append(matched, row) + if len(matched) > q.Limit { + break + } + } + more := len(matched) > q.Limit + if more { + matched = matched[:q.Limit] + } + for _, row := range matched { + page.Entries = append(page.Entries, directoryEntry(row, p)) + } + if more { + last := matched[len(matched)-1] + page.NextCursor, err = h.caps.directory.encodeCursor(directoryCursor{Version: p.version, Owner: last.Owner, UserID: last.UserID}) + if err != nil { + return extension.AccountDirectoryPage{}, err + } + } + return page, nil +} + +func (h inprocHost) LookupAccountDirectory(ctx context.Context, q extension.AccountDirectoryQuery, userID string) (extension.AccountDirectoryLookup, error) { + if userID == "" || q.Cursor != "" { + return extension.AccountDirectoryLookup{}, store.ErrInvalidAccountProfile + } + p, err := h.directoryPolicy(ctx, q, true) + if err != nil { + return extension.AccountDirectoryLookup{}, err + } + for _, row := range p.rows { + if row.UserID == userID { + return extension.AccountDirectoryLookup{Entry: directoryEntry(row, p), DirectoryVersion: p.version}, nil + } + } + return extension.AccountDirectoryLookup{}, store.ErrNotFound +} + +func (h inprocHost) LookupAccountDirectoryOwner(ctx context.Context, q extension.AccountDirectoryQuery, owner string) (extension.AccountDirectoryLookup, error) { + if owner == "" || q.Cursor != "" { + return extension.AccountDirectoryLookup{}, store.ErrInvalidAccountProfile + } + p, err := h.directoryPolicy(ctx, q, true) + if err != nil { + return extension.AccountDirectoryLookup{}, err + } + for _, row := range p.rows { + if row.Owner == owner { + return extension.AccountDirectoryLookup{Entry: directoryEntry(row, p), DirectoryVersion: p.version}, nil + } + } + return extension.AccountDirectoryLookup{}, store.ErrNotFound +} + +var _ extension.AccountDirectoryProvider = inprocHost{} diff --git a/internal/app/account_directory_test.go b/internal/app/account_directory_test.go new file mode 100644 index 0000000..39b894b --- /dev/null +++ b/internal/app/account_directory_test.go @@ -0,0 +1,317 @@ +package app + +import ( + "context" + "database/sql" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" +) + +func openDirectoryTestStore(t *testing.T) *store.Store { + t.Helper() + dsn := os.Getenv("AGENT_PG_TEST_DSN") + if dsn == "" { + st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "directory.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + return st + } + admin, err := sql.Open("pgx", dsn) + if err != nil { + t.Fatal(err) + } + schema := "t_" + strings.ToLower(store.NewID("dir")) + if _, err := admin.Exec(`CREATE EXTENSION IF NOT EXISTS pg_trgm`); err != nil { + t.Fatal(err) + } + if _, err := admin.Exec(`CREATE SCHEMA ` + schema); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _, _ = admin.Exec(`DROP SCHEMA IF EXISTS ` + schema + ` CASCADE`) + _ = admin.Close() + }) + separator := "?" + if strings.Contains(dsn, "?") { + separator = "&" + } + st, err := store.OpenPostgres(dsn+separator+"search_path="+schema+",public", filepath.Join(t.TempDir(), "artifacts")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + return st +} + +func TestAccountDirectoryAppliesDomainScopeBeforeSearchAndCursor(t *testing.T) { + st := openDirectoryTestStore(t) + t.Logf("directory provider dialect=%s", st.Dialect()) + if err := st.CreateAccount("admin", "", "hash", true); err != nil { + t.Fatal(err) + } + alice, err := st.CreatePendingAccount("admin", "alice", store.AccountProfile{Name: "同名", StaffNo: "hidden-a"}) + if err != nil { + t.Fatal(err) + } + bob, err := st.CreatePendingAccount("admin", "bob", store.AccountProfile{Name: "同名", StaffNo: "hidden-b"}) + if err != nil { + t.Fatal(err) + } + outsider, err := st.CreatePendingAccount("admin", "outsider", store.AccountProfile{Name: "同名", StaffNo: "hidden-x"}) + if err != nil { + t.Fatal(err) + } + slot, err := newAccountDirectorySlot(st) + if err != nil { + t.Fatal(err) + } + h := inprocHost{caps: hostcaps{st: st, directory: slot}, pluginID: "rdm", actor: "admin", owner: "admin"} + query := extension.AccountDirectoryQuery{SchemaVersion: extension.AccountDirectorySchemaVersion, + Purpose: extension.AccountDirectoryPurposeProjectMembers, ProjectID: "project-1", + Mode: extension.AccountDirectoryModeFuzzy, Query: "同名", Limit: 1} + if _, err := h.QueryAccountDirectory(context.Background(), query); err == nil { + t.Fatal("domain directory ran without trusted resolver") + } + if err := (pluginRegistry{pluginID: "rdm", caps: h.caps}).RegisterAccountDirectoryScopeResolver(func(context.Context, extension.AccountDirectoryScopeRequest) (extension.AccountDirectoryScope, error) { + return extension.AccountDirectoryScope{AllowedFields: []string{"owner", "name"}, MatchFields: []string{"owner", "name"}, + CandidateIDs: []string{alice.ID, bob.ID}, ScopeVersion: "scope-1"}, nil + }); err != nil { + t.Fatal(err) + } + page, err := h.QueryAccountDirectory(context.Background(), query) + if err != nil || len(page.Entries) != 1 || page.Entries[0].UserID != alice.ID || page.Entries[0].StaffNo != "" || page.NextCursor == "" { + t.Fatalf("first scoped page: %+v %v", page, err) + } + query.Cursor = page.NextCursor + otherSlot, err := newAccountDirectorySlot(st) + if err != nil { + t.Fatal(err) + } + otherSlot.pluginID, otherSlot.resolver = slot.pluginID, slot.resolver + otherHost := h + otherHost.caps.directory = otherSlot + second, err := otherHost.QueryAccountDirectory(context.Background(), query) + if err != nil || len(second.Entries) != 1 || second.Entries[0].UserID != bob.ID || second.NextCursor != "" { + t.Fatalf("second scoped page on another host: %+v %v", second, err) + } + query.Cursor = "" + query.Query = "hidden-x" + if found, err := h.QueryAccountDirectory(context.Background(), query); err != nil || len(found.Entries) != 0 { + t.Fatalf("hidden staff number matched: %+v %v", found, err) + } + query.SearchFields = []string{"staffNo"} + if _, err := h.QueryAccountDirectory(context.Background(), query); err == nil { + t.Fatal("hidden staff number accepted as search field") + } + query.SearchFields = nil + query.Query = "同名" + if hit, err := h.LookupAccountDirectoryOwner(context.Background(), query, "alice"); err != nil || hit.Entry.UserID != alice.ID { + t.Fatalf("explicit owner lookup failed to resolve stable ID: %+v %v", hit, err) + } + if _, err := h.LookupAccountDirectoryOwner(context.Background(), query, "outsider"); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("owner lookup leaked outsider: %v", err) + } + if _, err := h.LookupAccountDirectory(context.Background(), query, "not-in-scope"); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("exact lookup leaked outsider: %v", err) + } + page, err = h.QueryAccountDirectory(context.Background(), query) + if err != nil { + t.Fatal(err) + } + if _, err := st.PatchAccountProfile("admin", alice.ID, alice.ProfileVersion, map[string]string{"phone": "private-edit"}); err != nil { + t.Fatal(err) + } + query.Cursor = page.NextCursor + if _, err := h.QueryAccountDirectory(context.Background(), query); err != nil { + t.Fatalf("private edit invalidated candidate cursor: %v", err) + } + if _, err := st.PatchAccountProfile("admin", alice.ID, alice.ProfileVersion+1, map[string]string{"staffNo": "still-hidden"}); err != nil { + t.Fatal(err) + } + if _, err := h.QueryAccountDirectory(context.Background(), query); err != nil { + t.Fatalf("unavailable field invalidated candidate cursor: %v", err) + } + if _, err := st.PatchAccountProfile("admin", outsider.ID, outsider.ProfileVersion, map[string]string{"name": "out-of-scope-change"}); err != nil { + t.Fatal(err) + } + if _, err := h.QueryAccountDirectory(context.Background(), query); err != nil { + t.Fatalf("out-of-scope edit invalidated candidate cursor: %v", err) + } + if _, err := st.PatchAccountProfile("admin", alice.ID, alice.ProfileVersion+2, map[string]string{"name": "changed"}); err != nil { + t.Fatal(err) + } + if _, err := h.QueryAccountDirectory(context.Background(), query); !errors.Is(err, store.ErrConflict) { + t.Fatalf("visible edit did not invalidate cursor: %v", err) + } +} + +// project.member-roster:可信 RDM 解析方点名的项目成员清单。空 query 枚举作用域内 +// 全部行(含已停用账号),limit/游标翻页照旧;非 rdm 插件、带 documentId、 +// AllCandidates 解析结果一律拒绝;member-candidate 的空 query 仍是空页。 +func TestAccountDirectoryProjectRosterEnumeratesResolvedMembers(t *testing.T) { + st := openDirectoryTestStore(t) + if err := st.CreateAccount("admin", "Admin", "hash", true); err != nil { + t.Fatal(err) + } + alice, err := st.CreatePendingAccount("admin", "alice", store.AccountProfile{Name: "阿丽", StaffNo: "hidden-a"}) + if err != nil { + t.Fatal(err) + } + bob, err := st.CreatePendingAccount("admin", "bob", store.AccountProfile{Name: "小博", StaffNo: "hidden-b"}) + if err != nil { + t.Fatal(err) + } + if _, err := st.CreatePendingAccount("admin", "outsider", store.AccountProfile{Name: "局外人", StaffNo: "hidden-x"}); err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", bob.ID, bob.StateVersion, false); err != nil { + t.Fatal(err) + } + var requests []extension.AccountDirectoryScopeRequest + resolver := func(_ context.Context, req extension.AccountDirectoryScopeRequest) (extension.AccountDirectoryScope, error) { + requests = append(requests, req) + return extension.AccountDirectoryScope{AllowedFields: []string{"owner", "name"}, MatchFields: []string{"owner", "name"}, + CandidateOwners: []string{alice.Owner, bob.Owner}, ScopeVersion: "roster-1"}, nil + } + slot, err := newAccountDirectorySlot(st) + if err != nil { + t.Fatal(err) + } + slot.pluginID, slot.resolver = "rdm", resolver + h := inprocHost{caps: hostcaps{st: st, directory: slot}, pluginID: "rdm", actor: "admin", owner: "admin"} + roster := extension.AccountDirectoryQuery{SchemaVersion: extension.AccountDirectorySchemaVersion, + Purpose: extension.AccountDirectoryPurposeProjectRoster, ProjectID: "project-1", + Mode: extension.AccountDirectoryModeFuzzy, Status: "all"} + page, err := h.QueryAccountDirectory(context.Background(), roster) + if err != nil { + t.Fatal(err) + } + if len(page.Entries) != 2 || page.NextCursor != "" { + t.Fatalf("roster page=%+v", page) + } + if page.Entries[0] != (extension.AccountDirectoryEntry{UserID: alice.ID, Owner: "alice", Name: "阿丽", Valid: true}) || + page.Entries[1] != (extension.AccountDirectoryEntry{UserID: bob.ID, Owner: "bob", Name: "小博", Valid: false}) { + t.Fatalf("roster entries=%+v", page.Entries) + } + if len(requests) != 1 || requests[0].Purpose != extension.AccountDirectoryPurposeProjectRoster || + requests[0].ProjectID != "project-1" || requests[0].DocumentID != "" || requests[0].Actor != "admin" { + t.Fatalf("resolver requests=%+v", requests) + } + // 游标翻页:同一签名游标,limit 1 两页走完。 + roster.Limit = 1 + first, err := h.QueryAccountDirectory(context.Background(), roster) + if err != nil || len(first.Entries) != 1 || first.Entries[0].UserID != alice.ID || first.NextCursor == "" { + t.Fatalf("roster first page=%+v %v", first, err) + } + roster.Cursor = first.NextCursor + second, err := h.QueryAccountDirectory(context.Background(), roster) + if err != nil || len(second.Entries) != 1 || second.Entries[0].UserID != bob.ID || second.NextCursor != "" { + t.Fatalf("roster second page=%+v %v", second, err) + } + roster.Cursor, roster.Limit = "", 0 + // 非检索目的的空 query 依旧空页:member-candidate 行为不变。 + roster.Purpose, roster.Status = extension.AccountDirectoryPurposeProjectMembers, "enabled" + if page, err := h.QueryAccountDirectory(context.Background(), roster); err != nil || len(page.Entries) != 0 { + t.Fatalf("member candidate empty query=%+v %v", page, err) + } + roster.Purpose = extension.AccountDirectoryPurposeProjectRoster + // 非 rdm 插件、documentId 限定、全量作用域解析结果:均拒绝。 + other := h + other.pluginID = "genui" + if _, err := other.QueryAccountDirectory(context.Background(), roster); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("non-rdm roster: %v", err) + } + withDocument := roster + withDocument.DocumentID = "doc-1" + if _, err := h.QueryAccountDirectory(context.Background(), withDocument); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("document roster: %v", err) + } + wideSlot, err := newAccountDirectorySlot(st) + if err != nil { + t.Fatal(err) + } + wideSlot.pluginID, wideSlot.resolver = "rdm", func(context.Context, extension.AccountDirectoryScopeRequest) (extension.AccountDirectoryScope, error) { + return extension.AccountDirectoryScope{AllowedFields: []string{"owner", "name"}, MatchFields: []string{"owner", "name"}, + AllCandidates: true, ScopeVersion: "roster-wide"}, nil + } + wide := h + wide.caps.directory = wideSlot + if _, err := wide.QueryAccountDirectory(context.Background(), roster); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("all-candidate roster: %v", err) + } +} + +func TestPGDirectoryCursorContinuesInAnotherProcess(t *testing.T) { + baseDSN := os.Getenv("AGENT_PG_TEST_DSN") + if baseDSN == "" { + t.Skip("isolated PostgreSQL test DSN required") + } + if os.Getenv("IAM_U3_DIRECTORY_CHILD") == "1" { + schema := os.Getenv("IAM_U3_DIRECTORY_SCHEMA") + separator := "?" + if strings.Contains(baseDSN, "?") { + separator = "&" + } + st, err := store.OpenExistingPostgres(baseDSN+separator+"search_path="+schema+",public", filepath.Join(t.TempDir(), "artifacts")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + slot, err := newAccountDirectorySlot(st) + if err != nil { + t.Fatal(err) + } + h := inprocHost{caps: hostcaps{st: st, directory: slot}, actor: "admin", owner: "admin"} + q := extension.AccountDirectoryQuery{SchemaVersion: extension.AccountDirectorySchemaVersion, + Purpose: extension.AccountDirectoryPurposeSystemUsers, Mode: extension.AccountDirectoryModeFuzzy, + Query: "same", Limit: 1, Cursor: os.Getenv("IAM_U3_DIRECTORY_CURSOR")} + page, err := h.QueryAccountDirectory(context.Background(), q) + if err != nil || len(page.Entries) != 1 || page.Entries[0].Owner != "beta" { + t.Fatalf("cross-process page: %+v %v", page, err) + } + return + } + st := openDirectoryTestStore(t) + if err := st.EnsureSystemIdentity("u3-directory-cross-process", "pg"); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("admin", "Admin", "hash", true); err != nil { + t.Fatal(err) + } + for _, owner := range []string{"alpha", "beta"} { + if _, err := st.CreatePendingAccount("admin", owner, store.AccountProfile{Name: "same"}); err != nil { + t.Fatal(err) + } + } + slot, err := newAccountDirectorySlot(st) + if err != nil { + t.Fatal(err) + } + h := inprocHost{caps: hostcaps{st: st, directory: slot}, actor: "admin", owner: "admin"} + q := extension.AccountDirectoryQuery{SchemaVersion: extension.AccountDirectorySchemaVersion, + Purpose: extension.AccountDirectoryPurposeSystemUsers, Mode: extension.AccountDirectoryModeFuzzy, + Query: "same", Limit: 1} + page, err := h.QueryAccountDirectory(context.Background(), q) + if err != nil || len(page.Entries) != 1 || page.Entries[0].Owner != "alpha" || page.NextCursor == "" { + t.Fatalf("initial page: %+v %v", page, err) + } + var schema string + if err := st.DB().QueryRow(`SELECT current_schema()`).Scan(&schema); err != nil { + t.Fatal(err) + } + cmd := exec.Command(os.Args[0], "-test.run=^TestPGDirectoryCursorContinuesInAnotherProcess$") + cmd.Env = append(os.Environ(), "IAM_U3_DIRECTORY_CHILD=1", "IAM_U3_DIRECTORY_SCHEMA="+schema, + "IAM_U3_DIRECTORY_CURSOR="+page.NextCursor) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("cross-process cursor failed: %v: %s", err, strings.ReplaceAll(string(out), baseDSN, "[REDACTED]")) + } +} diff --git a/internal/app/account_guard.go b/internal/app/account_guard.go new file mode 100644 index 0000000..46e2632 --- /dev/null +++ b/internal/app/account_guard.go @@ -0,0 +1,24 @@ +package app + +import ( + "context" + "errors" + + "agent-system-backend/internal/store" +) + +// accountEffectiveAuthorization is shared by runmgr and in-process Host +// capabilities. The implicit local singleton is the only auth-off missing-row +// exception; an existing disabled local account remains denied. +func accountEffectiveAuthorization(st *store.Store, authOn bool) func(context.Context, string) (bool, error) { + return func(_ context.Context, owner string) (bool, error) { + account, err := st.GetAccountByOwner(owner) + if errors.Is(err, store.ErrNotFound) && !authOn && owner == store.OwnerDefault { + return true, nil + } + if err != nil { + return false, err + } + return account.Enabled, nil + } +} diff --git a/internal/app/account_guard_test.go b/internal/app/account_guard_test.go new file mode 100644 index 0000000..0e24dc0 --- /dev/null +++ b/internal/app/account_guard_test.go @@ -0,0 +1,117 @@ +package app + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "agent-system-backend/internal/settings" + "agent-system-backend/internal/store" +) + +func TestInprocHostRechecksEffectiveAccountOnEveryCapability(t *testing.T) { + st, err := store.OpenSQLite(t.TempDir() + "/host.db") + if err != nil { + t.Fatal(err) + } + defer st.Close() + if err := st.CreateAccount("alice", "", "", false); err != nil { + t.Fatal(err) + } + check := accountEffectiveAuthorization(st, true) + h := inprocHost{caps: hostcaps{st: st, accountGuard: func(owner string) error { + valid, err := check(context.Background(), owner) + if err != nil { + return err + } + if !valid { + return store.ErrAccountForbidden + } + return nil + }}, owner: "alice", actor: "alice", pluginID: "rdm"} + if err := h.KVPut("before", "allowed"); err != nil { + t.Fatal(err) + } + if _, err := st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner=?`, "alice"); err != nil { + t.Fatal(err) + } + if err := h.KVPut("after", "denied"); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("disabled host wrote KV: %v", err) + } + if _, _, err := h.KVGet("before"); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("disabled host read KV: %v", err) + } + if _, err := h.TaskCreate("test", "denied", ""); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("disabled host created task: %v", err) + } + if _, err := h.ProjectNameAvailable("denied", ""); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("disabled host used project capability: %v", err) + } + if err := h.Audit("action", "target", ""); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("disabled host wrote audit: %v", err) + } + if _, err := h.Complete(context.Background(), "denied"); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("disabled host reached model completion: %v", err) + } + if _, err := h.KnowledgeSearch(context.Background(), "", "denied", 1); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("disabled host read knowledge: %v", err) + } +} + +func TestHostCompletionChecksAgainAfterModelSettingsRead(t *testing.T) { + st, err := store.OpenSQLite(t.TempDir() + "/host-complete.db") + if err != nil { + t.Fatal(err) + } + defer st.Close() + requests := 0 + model := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests++ + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"late"}}]}`)) + })) + defer model.Close() + set := settings.NewService(st) + set.SetSystemOverride(map[string]any{"models": map[string]any{"chat": map[string]any{"model": "test", "baseUrl": model.URL}}}) + checks := 0 + h := inprocHost{caps: hostcaps{st: st, set: set, accountGuard: func(string) error { + checks++ + if checks >= 3 { + return store.ErrAccountForbidden + } + return nil + }}, owner: "alice", actor: "alice", pluginID: "rdm"} + if _, err := h.Complete(context.Background(), "late"); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("completion after disable: %v", err) + } + if checks != 3 || requests != 0 { + t.Fatalf("late completion reached model: checks=%d requests=%d", checks, requests) + } +} + +func TestImplicitLocalAccountExceptionIsAuthOffOnly(t *testing.T) { + st, err := store.OpenSQLite(t.TempDir() + "/local.db") + if err != nil { + t.Fatal(err) + } + defer st.Close() + allowed, err := accountEffectiveAuthorization(st, false)(context.Background(), store.OwnerDefault) + if err != nil || !allowed { + t.Fatalf("implicit local denied: %v %v", allowed, err) + } + allowed, err = accountEffectiveAuthorization(st, true)(context.Background(), store.OwnerDefault) + if !errors.Is(err, store.ErrNotFound) || allowed { + t.Fatalf("auth-on missing account allowed: %v %v", allowed, err) + } + if err := st.EnsureUser(store.OwnerDefault, ""); err != nil { + t.Fatal(err) + } + if _, err := st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner=?`, store.OwnerDefault); err != nil { + t.Fatal(err) + } + allowed, err = accountEffectiveAuthorization(st, false)(context.Background(), store.OwnerDefault) + if err != nil || allowed { + t.Fatalf("disabled existing local account allowed: %v %v", allowed, err) + } +} diff --git a/internal/app/account_lister_test.go b/internal/app/account_lister_test.go index 6ee2898..014ff76 100644 --- a/internal/app/account_lister_test.go +++ b/internal/app/account_lister_test.go @@ -16,11 +16,20 @@ func TestInprocHostListAccounts(t *testing.T) { } t.Cleanup(func() { st.Close() }) for _, o := range []string{"carol", "alice", "bob", "dave"} { + if err := st.EnsureUser(o, ""); err != nil { + t.Fatal(err) + } if _, err := st.CreateAPIKey(o, "e2e"); err != nil { t.Fatal(err) } } - h := inprocHost{caps: hostcaps{st: st}} + if _, err := st.DB().Exec(`UPDATE users SET is_admin=TRUE WHERE owner='alice'`); err != nil { + t.Fatal(err) + } + h := inprocHost{caps: hostcaps{st: st}, owner: "alice", actor: "alice"} + if _, _, err := (inprocHost{caps: hostcaps{st: st}}).ListAccounts("", 2); err == nil { + t.Fatal("legacy directory allowed a host without a trusted dispatch actor") + } // 首页(limit=2):alice/bob,next=bob。 page, next, err := h.ListAccounts("", 2) @@ -50,6 +59,12 @@ func TestInprocHostListAccounts(t *testing.T) { if _, _, err := h.ListAccounts("", 999); err != nil { t.Fatalf("limit 超限应钳制不报错: %v", err) } + if _, err := st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner='alice'`); err != nil { + t.Fatal(err) + } + if _, _, err := h.ListAccounts("", 2); err == nil { + t.Fatal("disabled administrator retained legacy directory access") + } } // TestInprocHostSearchAccounts:EXT-19 扩展 SearchAccounts——账号/姓名子串 @@ -65,7 +80,7 @@ func TestInprocHostSearchAccounts(t *testing.T) { if err != nil { t.Fatal(err) } - if err := st.CreateAccount("alice", "王小明", hash, false); err != nil { + if err := st.CreateAccount("alice", "王小明", hash, true); err != nil { t.Fatal(err) } if err := st.CreateAccount("bob", "李大明", hash, false); err != nil { @@ -74,7 +89,10 @@ func TestInprocHostSearchAccounts(t *testing.T) { if err := st.CreateAccount("carol", "", hash, false); err != nil { t.Fatal(err) } - h := inprocHost{caps: hostcaps{st: st}} + h := inprocHost{caps: hostcaps{st: st}, owner: "alice", actor: "alice"} + if _, err := (inprocHost{caps: hostcaps{st: st}, owner: "bob", actor: "bob"}).SearchAccounts("alice", 50); err == nil { + t.Fatal("ordinary actor gained legacy system directory search") + } // 姓名子串(同名区分场景):两个"大明/小明"都以"明"命中。 got, err := h.SearchAccounts("明", 50) diff --git a/internal/app/account_ops_existing_test.go b/internal/app/account_ops_existing_test.go new file mode 100644 index 0000000..9580649 --- /dev/null +++ b/internal/app/account_ops_existing_test.go @@ -0,0 +1,58 @@ +package app + +import ( + "agent-system-backend/internal/store" + "os" + "path/filepath" + "testing" +) + +func TestAccountMaintenanceDefaultsToNonCreatingOpen(t *testing.T) { + t.Setenv("AGENT_SYSTEM_DSN", "") + for _, tc := range []struct { + name string + run func(string) error + }{ + {"user", func(dir string) error { + _, _, err := CreateUserAccount("", dir, "ada", "Ada", "", false, false, false) + return err + }}, + {"apikey", func(dir string) error { + _, err := CreateAPIKey("", dir, "ada", "test") + return err + }}, + {"identity verify", func(dir string) error { + _, err := VerifySystemIdentity("", dir, "fixture", "sqlite") + return err + }}, + } { + t.Run(tc.name, func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "absent") + if err := tc.run(dir); err == nil { + t.Error("maintenance created a missing database") + } + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Error("failed maintenance created a directory") + } + }) + } +} +func TestAccountMaintenanceCannotImplicitlyMigrate(t *testing.T) { + t.Setenv("AGENT_SYSTEM_DSN", "") + dir := t.TempDir() + st, err := store.OpenSQLite(filepath.Join(dir, "agent-system.db")) + if err != nil { + t.Fatal(err) + } + if err := st.EnsureSystemIdentity("test", "sqlite"); err != nil { + t.Fatal(err) + } + // Roll back the newest additive migration in this private fixture only. + if err := st.MigrateDown(store.SchemaTarget() - 1); err != nil { + t.Fatal(err) + } + st.Close() + if _, _, err := CreateUserAccount("", dir, "ada", "Ada", "", false, false, false); err == nil { + t.Error("account command implicitly migrated an older schema") + } +} diff --git a/internal/app/app.go b/internal/app/app.go index 0f9a111..175ae5c 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -5,6 +5,7 @@ package app import ( "context" "crypto/rand" + "crypto/sha256" "encoding/hex" "encoding/json" "fmt" @@ -30,7 +31,6 @@ import ( "agent-native/runtime/telemetry" agenttools "agent-native/runtime/tools" nativeadp "agent-squad/adapters/native" - "agent-system-backend/internal/blobstore" "agent-system-backend/internal/bus" "agent-system-backend/internal/cardsvc" "agent-system-backend/internal/cmdbus" @@ -87,6 +87,9 @@ type Options struct { // ExecDepsDir sandbox 档只读依赖层(site-packages 等,空=不挂):修复 // --tmpfs /home 遮蔽 ~/.local 导致交付物生成链路不可用(F3)。 ExecDepsDir string + // ExecEnvRoot 分层 Python 环境根(解释器、基础层、扩展层、白名单)。 + // 设置后,未显式给出 ExecDepsDir 时使用其中的 current 基础层。 + ExecEnvRoot string // ExecWorkspace = "overlay" 时 sandbox 档启用 S0 工作区(冻结基底+私有 // upper+CAS 回收);空 = EXT-16 现状直写会话目录。 ExecWorkspace string @@ -95,6 +98,11 @@ type Options struct { Tier string // PGPool PG 连接预算(KTD12;零值走默认)。仅 PG 档生效。 PGPool store.PoolOptions + // AdoptInstanceGuard 在数据库被替换或锁文件被重建后,显式重写守卫记录。 + AdoptInstanceGuard bool + // SuperAdmin 启动期超级管理员凭据(R1):开启认证时必填且启动即引导; + // 口令只留在进程内存,不落库、不进日志。 + SuperAdmin config.SuperAdmin } // instanceTag 稳定执行身份:tier=team 时强制 host:addr(多实例互异), @@ -173,48 +181,16 @@ func Run(opt Options) error { if opt.InsecureListen && !config.IsLoopbackListen(opt.Addr) && !resolved.AuthEnabled() { log.Printf("warning: listening on %s without authentication because insecureListen is set", opt.Addr) } - var st *store.Store - var err error - if opt.StorageDSN != "" { - artifacts := opt.ArtifactsDir - if artifacts == "" { - artifacts = filepath.Join(opt.DataDir, "artifacts") - } - // 共享工件(U5/KTD9):配置了 S3 兼容对象存储时注入(多实例共享); - // 未配置回落本地目录(单实例/共享卷)。凭据只经环境(不入库不入报告)。 - blobs, berr := blobstore.NewS3FromEnv() - if berr != nil { - return fmt.Errorf("blobstore s3: %w", berr) - } - if blobs != nil { - log.Printf("artifacts: shared via S3-compatible object storage (prefix %q)", blobs.Prefix) - } - st, err = store.OpenPostgresWithOptions(opt.StorageDSN, artifacts, opt.PGPool, blobs) - if err != nil && opt.Tier == "team" { - // team 档拒绝就绪(R2):PG 故障不回落 SQLite,错误必须明说。 - return fmt.Errorf("team 档打开 PostgreSQL 失败,拒绝就绪(不回落 SQLite): %w", err) - } - } else { - st, err = store.OpenSQLite(filepath.Join(opt.DataDir, "agent-system.db")) + if err := ValidateSuperAdmin(resolved.AuthEnabled(), opt.SuperAdmin); err != nil { + return err } + st, releaseStore, err := openServiceStore(opt) if err != nil { - return fmt.Errorf("open store: %w", err) + return err } - defer st.Close() - // U1 I3:svc 注入的服务实例身份(manifest.instance_id)与库内 marker 对齐; - // 未设置则跳过(兼容未接线旧调用方)。与 --instance(runs 进程标识)无关。 - if sysID := strings.TrimSpace(os.Getenv("AGENT_SYSTEM_INSTANCE_ID")); sysID != "" { - dbref := strings.TrimSpace(os.Getenv("AGENT_SYSTEM_DBREF")) - if dbref == "" { - if opt.StorageDSN != "" { - dbref = "pg" - } else { - dbref = "sqlite" - } - } - if err := st.VerifySystemIdentity(sysID, dbref); err != nil { - return fmt.Errorf("system identity: %w", err) - } + defer releaseStore() + if err := BootstrapSuperAdmin(st, resolved.AuthEnabled(), opt.SuperAdmin); err != nil { + return err } pool := st.Pool() if versions, verr := st.MigrationStatus(); verr == nil { @@ -250,9 +226,17 @@ func Run(opt Options) error { if tools.ExecBackendName() == "sandbox" { log.Printf("exec tools: backend=sandbox (bwrap: no network, ro root, session-only writes, proc limit)") } + broker, err := wireExecEnv(st, &opt) + if err != nil { + return fmt.Errorf("exec env: %w", err) + } if opt.ExecDepsDir != "" { tools.SetExecDepsDir(opt.ExecDepsDir) log.Printf("exec tools: sandbox deps layer=%s", opt.ExecDepsDir) + if err := tools.ExecDoctor(); err != nil { + return fmt.Errorf("exec doctor: %w", err) + } + log.Printf("exec tools: doctor ok (docx/openpyxl/pptx)") } if opt.ExecWorkspace == "overlay" { tools.SetWorkspaceOverlay(true) @@ -293,16 +277,19 @@ func Run(opt Options) error { if err := setSvc.SnapshotAndMigrateSecrets(); err != nil { return fmt.Errorf("migrate secrets: %w", err) } - runtime.SecretLookup = func(owner, service string) (string, bool) { + runtime.SecretLookup = func(owner, service string) (string, string, bool) { if owner == "" { owner = store.OwnerDefault } if v, ok := st.ActiveSecret(owner, service); ok { - return v, true + return v, "owner", true } // P2 受管:密钥统一迁入 secrets 表后,系统层受管密钥挂在系统行 //(owner=settings.OwnerSystemRow);租户未单独配置时回退系统密钥。 - return st.ActiveSecret(settings.OwnerSystemRow, service) + if v, ok := st.ActiveSecret(settings.OwnerSystemRow, service); ok { + return v, "system", true + } + return "", "", false } rag.SecretLookup = func(service, fallback string) string { return runtime.LookupSecret(store.OwnerDefault, service, fallback) @@ -312,11 +299,7 @@ func Run(opt Options) error { // 未配置时调用返回明确的「未配置」提示,不静默降级为空结果。 if err := tools.RegisterLazySearchTools(reg, func() agenttools.SearchProvider { effNow := setSvc.Effective(store.OwnerDefault, "") - tavilyNow := effNow.GetString("tools.tavily.apiKeys", "") - if active, ok := st.ActiveSecret(store.OwnerDefault, "tools.tavily"); ok { - tavilyNow = active - } - keys := splitKeyPool(tavilyNow) + keys := tavilyKeyPool(st, effNow.GetString("tools.tavily.apiKeys", "")) if len(keys) == 0 { return nil } @@ -382,11 +365,7 @@ func Run(opt Options) error { }, } cfg.Stream = runtime.GuardProjectStream(rc, "", cfg.Client.Stream) - tavilyNow := effNow.GetString("tools.tavily.apiKeys", "") - if active, ok := st.ActiveSecret(store.OwnerDefault, "tools.tavily"); ok { - tavilyNow = active - } - if keys := splitKeyPool(tavilyNow); len(keys) > 0 { + if keys := tavilyKeyPool(st, effNow.GetString("tools.tavily.apiKeys", "")); len(keys) > 0 { cfg.Search = agenttools.NewTavilyProvider(effNow.GetString("tools.tavily.endpoint", ""), keys) } if effNow.GetBool("runtime.exec", true) { @@ -402,7 +381,17 @@ func Run(opt Options) error { if mgr := mcpManagerFor(rawMCP, strings.TrimSpace(effNow.GetString("tools.mcp.overrides", ""))); mgr != nil { cfg.MCP = mgr } - cfg.CodeOrchestration = effNow.GetBool("runtime.codeOrchestration", false) + // Native squad tools do not yet expose a fail-closed result hook to the + // project observation outbox. Project runs therefore use the audited + // runCtx retrieval/tool path only; enabling these direct providers would + // let their results reach the next model turn without source analysis. + if rc.Run().ProjectID != "" { + cfg.Search = nil + cfg.Exec = nil + cfg.Fetch = nil + cfg.MCP = nil + } + cfg.CodeOrchestration = rc.Run().ProjectID == "" && effNow.GetBool("runtime.codeOrchestration", false) if ask, ok := rc.(interface { AskUser(card core.HITLCard) (core.HITLResponse, error) }); ok { @@ -452,6 +441,18 @@ func Run(opt Options) error { subRunner := &runtime.SubLLMRunner{Prompts: promptSvc, Trace: traceFn} mockRunner := &runtime.MockRunner{} mgr := runmgr.New(st, b, reg, profSvc, setSvc, convRunner, subRunner, mockRunner, &runtime.MockSubagent{}) + if broker != nil { + mgr.SetExecEnvDecider(func(actor, id string, allow, isolate bool) (string, error) { + out, err := broker.Decide(context.Background(), id, actor, false, allow, isolate) + if err != nil { + return "", err + } + return out.Detail, nil + }) + } + authOn := resolved.AuthEnabled() + effectiveAccount := accountEffectiveAuthorization(st, authOn) + mgr.SetAccountEffectiveAuthorization(effectiveAccount, !authOn) instanceID := opt.InstanceID if instanceID == "" { host, err := os.Hostname() @@ -462,11 +463,6 @@ func Run(opt Options) error { } st.SetInstanceID(instanceID) mgr.SetInstanceID(instanceID) - if err := mgr.ReconcileInterrupted(); err != nil { - return fmt.Errorf("reconcile interrupted runs: %w", err) - } - // 后台子代理通知重启重投(N2 WP5):未投递结果装回队列并按空闲语义投递。 - mgr.LoadPendingNotices() mgr.SetSquadRunner(&runtime.SquadRunner{}) // 意图闸门:插件意图 overlay → agent-intent 注册表(未装载意图 = 行为回退)。 @@ -525,15 +521,15 @@ func Run(opt Options) error { } // fs 预留能力沙箱根:/workspaces///。 mgr.SetWorkspaceRoot(filepath.Join(opt.DataDir, "workspaces")) - // 中断子代理续跑(N2 WP5):依赖转录目录(workspaceRoot)与派生请求。 - mgr.ResumeInterruptedChildren() // 收件箱任务卡:事件走会话事件管道(持久化/seq/重放);关闭即 T0 基线。 cardSvc := cardsvc.New(st, mgr.EmitCardEvent) + cardSvc.SetAccountCheckedEmitter(mgr.EmitCardEventAccountChecked) cardSvc.SetProjectSharedEmitter(mgr.EmitProjectSharedCardEvent) surfaceSvc := surfacesvc.New(st, mgr.EmitCardEvent) + surfaceSvc.SetAccountCheckedEmitter(mgr.EmitCardEventAccountChecked) go func() { // 启动恢复:重发「快照已落盘、事件未发出」的待恢复投影(KTD)。 - if n, err := surfaceSvc.RecoverPendingEvents(context.Background()); err == nil && n > 0 { + if n, err := surfaceSvc.RecoverPendingEventsAccountChecked(context.Background(), !authOn); err == nil && n > 0 { log.Printf("surface: recovered %d pending events", n) } }() @@ -541,8 +537,6 @@ func Run(opt Options) error { // 执行身份与命令消费(协作存储 U5):PG 档启动跨实例命令轮询; // SQLite 本地档无跨实例命令(StartCommandConsumer 自行短路)。 mgr.SetInstanceTag(instanceTag(opt), opt.Addr) - stopCommands := mgr.StartCommandConsumer(context.Background()) - defer stopCommands() // 执行预算(协作存储 U3,R8):runtime.execBudgetGlobal / runtime.execBudgetPerUser // 全零=不限(本地档默认,行为不变);开启后执行许可共享账本生效, // 待执行队列上限 runtime.execQueueMax(默认 200,容量合同数值待 U7 实测调整)。 @@ -556,20 +550,21 @@ func Run(opt Options) error { // 命令总线(提案 T3/D27/D30):四入口同源分发 + 审计 + 危险内联确认卡。 bus := cmdbus.New(st, cardSvc, func() time.Time { return time.Now().UTC() }) + bus.SetEffectiveAccountGuard(func(ctx context.Context, actor string) error { + valid, err := effectiveAccount(ctx, actor) + if err != nil { + return err + } + if !valid { + return store.ErrAccountForbidden + } + return nil + }) + bus.RequireAccountCheckedWrites(!authOn) // U5:页面动作分发口——surfacesvc.SubmitAction 经命令总线四入口同源分发 //(审计信封、参数校验、DangerHigh 内联确认卡全复用;Confirmed 不透传)。 surfaceSvc.SetDispatcher(func(ctx context.Context, d surfacesvc.ActionDispatch) (surfacesvc.ActionOutcome, error) { - resp := bus.Dispatch(ctx, cmdbus.Request{ID: d.CommandID, Params: d.Params, - Entrance: "button", Owner: d.Owner, ConversationID: d.ConversationID}) - state := "committed" - if resp.Receipt.State != "" { - state = string(resp.Receipt.State) - } - cardID := "" - if resp.Card != nil { - cardID = string(resp.Card.ID) - } - return surfacesvc.ActionOutcome{State: state, CardID: cardID, Receipt: resp.Receipt, Payload: resp.Payload}, nil + return dispatchSurfaceAction(ctx, bus, d) }) // U3:generative-ui 工具注入闸——activation 台账关闭时命令不投影给该 owner // 的模型(禁用即不注入能力;执行端守卫仍在 surfacesvc)。 @@ -597,10 +592,60 @@ func Run(opt Options) error { bus.SetPendingStore(st) pluginHost.OnCommands = bus.RegisterPluginCommands // hostcaps:hostapi 闭包与进程内插件句柄共用的 owner 作用域能力(EXT-1 批注 1)。 - authzSlot := &projectAuthz{} + authzSlot := &projectAuthz{enrich: func(q extension.ResourceAuthzQuery) (extension.ResourceAuthzQuery, error) { + account, err := st.GetAccountByOwner(q.Actor) + if err != nil { + return q, err + } + snapshot, err := rolePolicySnapshotForUser(st, account.ID) + if err != nil { + return q, err + } + q.AuthorityEpoch = snapshot.AuthorityEpoch + q.RolePolicy = &snapshot + return q, nil + }} projectSlot := &projectLifecycleSlot{} + workObserverSlot := &projectWorkObserverSlot{} + directorySlot, err := newAccountDirectorySlot(st) + if err != nil { + return err + } contextSlot := &projectContextSlot{} - caps := hostcaps{projects: projectSlot, contexts: contextSlot, st: st, pipe: pipe, set: setSvc, authz: authzSlot, surfaces: surfaceSvc} + caps := hostcaps{projects: projectSlot, workObserver: workObserverSlot, contexts: contextSlot, projectAudit: &projectAuditSlot{}, directory: directorySlot, regulations: ®ulationsSlot{}, st: st, pipe: pipe, set: setSvc, authz: authzSlot, surfaces: surfaceSvc, allowImplicitLocal: !authOn, + accountGuard: func(owner string) error { + valid, err := effectiveAccount(context.Background(), owner) + if err != nil { + return err + } + if !valid { + return store.ErrAccountForbidden + } + return nil + }} + bus.SetProjectWorkPreflight(func(ctx context.Context, req cmdbus.Request) error { + if req.Actor == "" || req.Actor != req.Owner || req.ConversationID == "" || req.AnchorMessageID == "" || req.HostProjectID == "" { + return fmt.Errorf("formal project command requires an authenticated conversation source") + } + conv, err := st.GetConversation(req.Owner, req.ConversationID) + if err != nil || string(conv.ProjectID) != req.HostProjectID { + return fmt.Errorf("formal project command conversation binding changed") + } + raw, err := json.Marshal(req.Params) + if err != nil { + return err + } + digest := sha256.Sum256(raw) + decision, err := preflightProjectMessage(ctx, st, workObserverSlot, projectSlot.work(), req.AnchorMessageID, + "project-command:"+req.ID, req.Owner, req.ConversationID, req.HostProjectID, req.ID, hex.EncodeToString(digest[:])) + if err != nil { + return err + } + if decision.State != extension.ProjectWorkProceed || decision.DecisionID == "" { + return fmt.Errorf("source analysis did not commit a proceed decision") + } + return nil + }) // EXT-13:回合内检索(knowledge_search / @knowledge 注入)与 HTTP/Host 检索面 // 同一适配器授权;槽为共享指针,扩展在 applyCompiled 期间注册后即生效。 mgr.SetSharedKnowledgeAllow(func(owner string, projectID core.ID) bool { @@ -613,13 +658,19 @@ func Run(opt Options) error { // EXT-11:公开受限 Host 工厂(下游恢复路径经 extension.NewScopedHost 重建句柄)。 wireScopedHostFactory(caps) compiledSummary := applyCompiled(skillsRoot, bus, caps) + // EXT-20:法规检索作为内置只读注册表工具暴露给模型(走 inprocHost 审计路径)。 + if err := registerRegulationsTools(reg, caps); err != nil { + return fmt.Errorf("regulations tools: %w", err) + } if err := requireProjectContextProvider(opt.RequireProjectContext, contextSlot); err != nil { return err } mgr.SetProjectWorkAuthorization(projectSlot.work()) + wireProjectWorkGate(mgr, st, caps, workObserverSlot, projectSlot.work()) if contextSlot.active() { mgr.SetProjectContextResolver(contextSlot.resolve) mgr.SetProjectEvidenceReader(contextSlot.readEvidence) + mgr.SetProjectContextSourceRechecker(contextSlot.recheck) } mgr.SetKnowledgeProjectScope(func(ctx context.Context, actor string, id core.ID) (core.ID, error) { return projectSlot.resolve(ctx, caps, actor, id) @@ -643,6 +694,9 @@ func Run(opt Options) error { } } hostSrv.Dispatch = func(ctx context.Context, pluginID, owner, commandID string, params map[string]any) error { + if err := caps.accountGuard(owner); err != nil { + return err + } resp := bus.Dispatch(ctx, cmdbus.Request{ID: commandID, Params: params, Entrance: "api", Actor: pluginID, Owner: owner}) if resp.Receipt.State == "rejected" || resp.Receipt.State == "unknown" { return fmt.Errorf("%s", resp.Receipt.Reason) @@ -662,6 +716,7 @@ func Run(opt Options) error { } srv := server.New(st, mgr, reg, profSvc, setSvc, pluginHost) + srv.ExecEnv = broker projectSlot.wire(srv, caps) // EXT-8:装载期注册的资源授权适配器注入读取入口(未注册=默认拒绝,行为不变)。 srv.SetProjectAuthorizer(authzSlot.Authorize) @@ -703,26 +758,15 @@ func Run(opt Options) error { return int(setSvc.Effective(store.OwnerDefault, "").GetNumber("runtime.rateLimitPerMin", 0)) }) // 认证策略(auto):PG 档默认开启(多用户面不能裸奔);嵌入式档默认关闭(本地单用户)。 - authOn := resolved.AuthEnabled() srv.Auth = server.NewAuthenticator(st, authOn) if authOn { + srv.Auth.SetSuperAdminFallback(opt.SuperAdmin.Owner, opt.SuperAdmin.Password()) log.Printf("auth: API key required (create with: agent-system apikey create --owner )") } srv.Subscribe = func(conversationID string) (<-chan core.StreamEvent, func()) { return b.Subscribe(conversationID) } - httpSrv := &http.Server{Addr: opt.Addr, ReadHeaderTimeout: 10 * time.Second, Handler: server.RequestGuard{ - AuthOn: authOn, AllowedHosts: opt.AllowedHosts, Next: srv.Handler(), - }} - - go func() { - log.Printf("agent-system serving on http://%s (data: %s, plugins: %s)", opt.Addr, opt.DataDir, opt.PluginsDir) - if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Fatalf("http: %v", err) - } - }() - // PG 档:事件跨实例扇出(NOTIFY/LISTEN;SQLite 档不需要)。 if st.Dialect() == "postgres" { mgr.SetEventNotifier(pgbus.NewNotifier(st).Notify) @@ -733,6 +777,35 @@ func Run(opt Options) error { defer listener.Close(context.Background()) log.Printf("pgbus: LISTEN agent_events(跨实例事件扇出)") } + // Recovery may execute model turns. All card, policy, command, budget and + // authorization ports must be ready before recovery or public intake begins. + if err := mgr.ReconcileInterrupted(); err != nil { + return fmt.Errorf("reconcile interrupted runs: %w", err) + } + mgr.LoadPendingNotices() + // 中断子代理续跑(N2 WP5):依赖转录目录(workspaceRoot)与派生请求。 + mgr.ResumeInterruptedChildrenChecked(context.Background(), func(ctx context.Context, owner string) error { + valid, err := effectiveAccount(ctx, owner) + if err != nil { + return err + } + if !valid { + return store.ErrAccountForbidden + } + return nil + }) + stopCommands := mgr.StartCommandConsumer(context.Background()) + defer stopCommands() + + httpSrv := &http.Server{Addr: opt.Addr, ReadHeaderTimeout: 10 * time.Second, Handler: server.RequestGuard{ + AuthOn: authOn, AllowedHosts: opt.AllowedHosts, Next: srv.Handler(), + }} + go func() { + log.Printf("agent-system serving on http://%s (data: %s, plugins: %s)", opt.Addr, opt.DataDir, opt.PluginsDir) + if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("http: %v", err) + } + }() stop := make(chan os.Signal, 1) signal.Notify(stop, os.Interrupt, syscall.SIGTERM) @@ -748,55 +821,72 @@ func Run(opt Options) error { return nil } -// CreateUserAccount CLI 建号。 -// password 空且 generatePassword=false:无初始密码,同事务签发 activate Key,返回明文 Key。 -// generatePassword=true 或显式 password:兼容旧路径(随机/给定密码 + must_change)。 -func CreateUserAccount(storageDSN, dataDir, owner, name, password string, isAdmin, openExisting, generatePassword bool) (string, error) { - var out string - err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error { - if password != "" || generatePassword { - pw := password - if pw == "" { - pw = server.RandomPassword() - } - hash, err := store.HashPassword(pw) - if err != nil { - return err - } - if err := st.CreateAccount(owner, name, hash, isAdmin); err != nil { - return err - } - out = pw - return nil +func assertLocalAccountOwner(st *store.Store, storageDSN, dataDir string) error { + if storageDSN == "" { + storageDSN = os.Getenv("AGENT_SYSTEM_DSN") + } + if storageDSN != "" { + var isOwner bool + err := st.DB().QueryRow(`SELECT current_user = pg_get_userbyid(c.relowner) FROM pg_class c WHERE c.oid='users'::regclass`).Scan(&isOwner) + if err != nil || !isOwner { + return store.ErrAccountForbidden } - if err := st.CreateAccount(owner, name, "", isAdmin); err != nil { + return nil + } + info, err := os.Stat(filepath.Join(dataDir, "agent-system.db")) + if err != nil { + return err + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok || (os.Geteuid() != 0 && stat.Uid != uint32(os.Geteuid())) { + return store.ErrAccountForbidden + } + return nil +} + +// CreateUserAccount is the local owner-only, audited pending-account path. +// Password-bearing creation is rejected before storage is opened. +func CreateUserAccount(storageDSN, dataDir, owner, name, password string, isAdmin, openExisting, generatePassword bool) (string, string, error) { + if password != "" || generatePassword { + return "", "", fmt.Errorf("RECOVERY_CONTRACT_REPLACED: local creation uses trusted activation only") + } + if isAdmin { + return "", "", ErrCLIAdminAppointment + } + var out store.AccountOutcome + err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error { + if err := assertLocalAccountOwner(st, storageDSN, dataDir); err != nil { return err } - plain, _, err := st.IssueLoginKey(owner, store.LoginKeyPurposeActivate, "cli", 0) + _, next, err := st.CreateLocalPendingAccountWithAudit(owner, name, isAdmin, accountInstanceID()) + out = next + return err + }) + return out.Plain, out.OperationID, err +} + +// IssueUserLoginKey is retained only as a rejecting compatibility entry. +func IssueUserLoginKey(storageDSN, dataDir, owner, purpose string, openExisting, revokeAllCreds bool, ttl time.Duration) (string, error) { + return "", fmt.Errorf("RECOVERY_CONTRACT_REPLACED: use owner initialization or owner-confirmed recovery") +} + +// InitializeAccount resumes a specific pending account. The operator identity +// comes from the local OS UID; the target is fixed by stable user ID and CAS. +func InitializeAccount(storageDSN, dataDir, userID string, expectedStateVersion, expectedGeneration int64) (string, string, error) { + var plain, owner string + err := withStoreMode(storageDSN, dataDir, true, func(st *store.Store) error { + if err := assertLocalAccountOwner(st, storageDSN, dataDir); err != nil { + return err + } + account, err := st.GetAccount(userID) if err != nil { return err } - out = plain - return nil - }) - return out, err -} - -// IssueUserLoginKey CLI 签发激活/恢复 Key。 -func IssueUserLoginKey(storageDSN, dataDir, owner, purpose string, openExisting, revokeAllCreds bool, ttl time.Duration) (string, error) { - var plain string - err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error { - if revokeAllCreds { - if err := st.RevokeAllAPIKeys(owner); err != nil { - return err - } - st.DeleteUserSessions(owner, "") - } - p, _, err := st.IssueLoginKey(owner, purpose, "cli", ttl) - plain = p + out, err := st.InitializePendingAccountWithAudit(userID, expectedStateVersion, expectedGeneration, accountInstanceID()) + plain, owner = out.Plain, account.Owner return err }) - return plain, err + return plain, owner, err } // RevokeUserLoginKey CLI 撤销未使用登录 Key。 @@ -808,32 +898,16 @@ func RevokeUserLoginKey(storageDSN, dataDir, id string, openExisting bool) error // BootstrapAdmin 首位管理员引导:单事务串行(KTD12——PG 咨询锁/SQLite 插入后复查), // 建号、激活 Key 与审计共同提交;库内已有用户即拒绝接管。 +// 现已停用:超级管理员只由服务启动按 SUPER_ADMIN_OWNER/SUPER_ADMIN_PASSWORD +// 引导,其余管理员只由超级管理员在服务端任命;此入口在打开存储前拒绝。 func BootstrapAdmin(storageDSN, dataDir, owner string, openExisting bool) (string, error) { - var plain string - err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error { - p, err := st.BootstrapFirstAdmin(owner, owner, "cli-bootstrap") - plain = p - return err - }) - return plain, err + return "", ErrCLIBootstrapAdminDisabled } // ResetUserPassword CLI 重置密码:单事务写 must_change=1、bump 认证版本、 // 作废未用登录 Key、吊销全部会话并审计。 func ResetUserPassword(storageDSN, dataDir, owner, password string, openExisting bool) (string, error) { - if password == "" { - password = server.RandomPassword() - } - hash, err := store.HashPassword(password) - if err != nil { - return "", err - } - if err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error { - return st.AdminResetUserPassword(owner, hash, "cli", "cli") - }); err != nil { - return "", err - } - return password, nil + return "", fmt.Errorf("RECOVERY_CONTRACT_REPLACED: administrators cannot set another user's password") } // ListUserAccounts CLI 用户列表。 @@ -861,83 +935,85 @@ func withIdentityStore(storageDSN, dataDir string, fn func(*store.Store) error) return fn(st) } -func withStoreMode(storageDSN, dataDir string, openExisting bool, fn func(*store.Store) error) error { +func withStoreMode(storageDSN, dataDir string, _ bool, fn func(*store.Store) error) error { var st *store.Store var err error // DSN 等价安全输入(KTD14):argv 缺省时回落环境变量——凭据不落 shell history。 if storageDSN == "" { storageDSN = os.Getenv("AGENT_SYSTEM_DSN") } + // Account maintenance never initializes or migrates storage; the retained + // CLI compatibility flag cannot opt this operation into service initialization. if storageDSN != "" { - if openExisting { - st, err = store.OpenExistingPostgres(storageDSN, filepath.Join(dataDir, "artifacts")) - } else { - st, err = store.OpenPostgres(storageDSN, filepath.Join(dataDir, "artifacts")) - } + st, err = store.OpenExistingPostgres(storageDSN, filepath.Join(dataDir, "artifacts")) } else { - dbPath := filepath.Join(dataDir, "agent-system.db") - if openExisting { - st, err = store.OpenExistingSQLite(dbPath) - } else { - st, err = store.OpenSQLite(dbPath) - } + st, err = store.OpenExistingSQLite(filepath.Join(dataDir, "agent-system.db")) } + if err != nil { return err } defer st.Close() + if id := strings.TrimSpace(os.Getenv("AGENT_SYSTEM_INSTANCE_ID")); id != "" { + dbref := strings.TrimSpace(os.Getenv("AGENT_SYSTEM_DBREF")) + if dbref == "" { + if storageDSN != "" { + dbref = "pg" + } else { + dbref = "sqlite" + } + } + if err := st.VerifySystemIdentity(id, dbref); err != nil { + return err + } + } return fn(st) } // CreateAPIKey 是 apikey 子命令的实现:在指定存储档上为 owner 创建密钥(明文仅打印一次)。 func CreateAPIKey(storageDSN, dataDir, owner, name string) (string, error) { - var st *store.Store - var err error - if storageDSN != "" { - st, err = store.OpenPostgres(storageDSN, filepath.Join(dataDir, "artifacts")) - } else { - st, err = store.OpenSQLite(filepath.Join(dataDir, "agent-system.db")) - } - if err != nil { - return "", err - } - defer st.Close() - return st.CreateAPIKey(owner, name) + var plain string + err := withStoreMode(storageDSN, dataDir, true, func(st *store.Store) error { + if err := assertLocalAccountOwner(st, storageDSN, dataDir); err != nil { + return err + } + account, err := st.GetAccountByOwner(owner) + if err != nil { + return err + } + if !account.Enabled || account.InitializationState == store.InitializationPending { + return store.ErrAccountForbidden + } + p, e := st.CreateAPIKey(owner, name) + plain = p + return e + }) + return plain, err } // ListAPIKeys 列出租户密钥(摘要视图)。 func ListAPIKeys(storageDSN, dataDir, owner string) ([]store.APIKeyInfo, error) { - var st *store.Store - var err error - if storageDSN != "" { - st, err = store.OpenPostgres(storageDSN, filepath.Join(dataDir, "artifacts")) - } else { - st, err = store.OpenSQLite(filepath.Join(dataDir, "agent-system.db")) - } - if err != nil { - return nil, err - } - defer st.Close() - if _, err := st.GetUser(owner); err != nil { - return nil, fmt.Errorf("owner %q 不存在(先 apikey create 创建)", owner) - } - return st.ListAPIKeys(owner) + var out []store.APIKeyInfo + err := withStoreMode(storageDSN, dataDir, true, func(st *store.Store) error { + if _, err := st.GetUser(owner); err != nil { + return fmt.Errorf("owner %q 不存在(先建立账号)", owner) + } + var e error + out, e = st.ListAPIKeys(owner) + return e + }) + return out, err } // RevokeAPIKey 撤销指定名称密钥。 func RevokeAPIKey(storageDSN, dataDir, owner, name string) (bool, error) { - var st *store.Store - var err error - if storageDSN != "" { - st, err = store.OpenPostgres(storageDSN, filepath.Join(dataDir, "artifacts")) - } else { - st, err = store.OpenSQLite(filepath.Join(dataDir, "agent-system.db")) - } - if err != nil { - return false, err - } - defer st.Close() - return st.RevokeAPIKeyByName(owner, name) + var ok bool + err := withStoreMode(storageDSN, dataDir, true, func(st *store.Store) error { + var e error + ok, e = st.RevokeAPIKeyByName(owner, name) + return e + }) + return ok, err } // EnsureSystemIdentity CLI:显式建立库内服务身份(svc init / I2→I3 引导)。 @@ -966,6 +1042,14 @@ func VerifySystemIdentity(storageDSN, dataDir, instanceID, dbref string) (int, e return schemaVersion, err } +func cliActor() string { + return fmt.Sprintf("%d", os.Getuid()) +} + +func accountInstanceID() string { + return strings.TrimSpace(os.Getenv("AGENT_SYSTEM_INSTANCE_ID")) +} + // cachedInt 带短 TTL 的整型缓存读(限流值热读取免打库)。 func cachedInt(ttl time.Duration, fn func() int) func() int { var mu sync.Mutex diff --git a/internal/app/compiled.go b/internal/app/compiled.go index f73b6f3..77dd1e4 100644 --- a/internal/app/compiled.go +++ b/internal/app/compiled.go @@ -64,11 +64,12 @@ func (r pluginRegistry) AddCommandHandler(spec json.RawMessage, h extension.Hand return fmt.Errorf("extension: command spec: %w", err) } return r.bus.RegisterPluginCommand(s, func(ctx context.Context, req cmdbus.Request) (any, error) { - host := inprocHost{caps: r.caps, pluginID: r.pluginID, owner: req.Owner, actor: req.Actor, - conversationID: core.ID(req.ConversationID), hostProjectID: core.ID(req.HostProjectID)} + host := requestBoundHost{inprocHost{caps: r.caps, pluginID: r.pluginID, owner: req.Owner, actor: req.Actor, + conversationID: core.ID(req.ConversationID), hostProjectID: core.ID(req.HostProjectID), + entrance: req.Entrance, humanGrant: req.HumanGrant}} return h(ctx, host, extension.Request{ ID: req.ID, Params: req.Params, Entrance: req.Entrance, - Actor: req.Actor, Owner: req.Owner, ConversationID: req.ConversationID, + Actor: req.Actor, Owner: req.Owner, ConversationID: req.ConversationID, OperationID: req.OperationID, HostProjectID: req.HostProjectID, }) }) @@ -117,6 +118,28 @@ func (r compiledRegistry) RegisterResourceAuthorizer(extension.ResourceAuthorize return errors.New("extension: declarative plugins cannot register resource authorizers") } +func (r compiledRegistry) RegisterAccountDirectoryScopeResolver(extension.AccountDirectoryScopeResolver) error { + return errors.New("extension: declarative plugins cannot register account directory authorization") +} + +func (r pluginRegistry) RegisterAccountDirectoryScopeResolver(fn extension.AccountDirectoryScopeResolver) error { + if r.pluginID != "rdm" || r.caps.directory == nil { + return errors.New("extension: account directory resolver requires trusted RDM registration") + } + return r.caps.directory.register(r.pluginID, fn) +} + +func (r compiledRegistry) RegisterRegulationsProvider(extension.RegulationsProvider) error { + return errors.New("extension: declarative plugins cannot register a regulations provider") +} + +func (r pluginRegistry) RegisterRegulationsProvider(p extension.RegulationsProvider) error { + if r.caps.regulations == nil { + return errors.New("extension: regulations provider registration unavailable") + } + return r.caps.regulations.register(r.pluginID, p) +} + // RegisterResourceAuthorizer 进程内扩展(EXT-1):填充 EXT-8 适配器槽 // (hostcaps.authz 共享指针;首个注册者生效,组合根已装配进 Server/runmgr)。 // 此前 pluginRegistry 未覆盖该方法——进程内插件注册适配器会命中声明式分支的 @@ -151,6 +174,22 @@ type recordRegistry struct { sum *server.CompiledPlugin } +func (r recordRegistry) RegisterAccountDirectoryScopeResolver(fn extension.AccountDirectoryScopeResolver) error { + registrar, ok := r.Registry.(extension.AccountDirectoryRegistrar) + if !ok { + return errors.New("extension: account directory registration unavailable") + } + return registrar.RegisterAccountDirectoryScopeResolver(fn) +} + +func (r recordRegistry) RegisterRegulationsProvider(p extension.RegulationsProvider) error { + registrar, ok := r.Registry.(extension.RegulationsRegistrar) + if !ok { + return errors.New("extension: regulations provider registration unavailable") + } + return registrar.RegisterRegulationsProvider(p) +} + func (r recordRegistry) AddCommand(spec json.RawMessage) error { if err := r.Registry.AddCommand(spec); err != nil { return err diff --git a/internal/app/delegated_project_projection.go b/internal/app/delegated_project_projection.go new file mode 100644 index 0000000..eecb699 --- /dev/null +++ b/internal/app/delegated_project_projection.go @@ -0,0 +1,122 @@ +package app + +import ( + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" + core "agent-system-core" +) + +// requestBoundHost is created only for a current lifecycle or command request. +// A reconstructed global ScopedHost is a plain inprocHost and cannot expose +// the delegated projection method by type assertion. +type requestBoundHost struct{ inprocHost } + +// ProjectProjectionForCreator only derives from a live request Host of the +// registered RDM provider. The operator is inherited from that Host, never +// accepted as a caller-supplied field. RDM's project.settings decision remains +// its own domain authority; this capability fences the Backend projection. +func (bound requestBoundHost) ProjectProjectionForCreator(creator, projectID string) (extension.ProjectHost, error) { + h := bound.inprocHost + if h.actor == "" || h.actor != h.owner || h.pluginID != "rdm" || + h.caps.projects == nil || h.caps.st == nil || creator == "" || projectID == "" { + return nil, store.ErrAccountForbidden + } + slot := h.caps.projects + slot.mu.Lock() + registered := slot.provider != nil && slot.holder == h.pluginID + slot.mu.Unlock() + if !registered { + return nil, store.ErrAccountForbidden + } + if err := h.requireEffective(); err != nil { + return nil, err + } + if h.caps.accountGuard != nil { + if err := h.caps.accountGuard(creator); err != nil { + return nil, err + } + } + project, err := h.caps.st.ProjectMetadata(projectID) + if err != nil { + return nil, err + } + if string(project.Owner) != creator || string(project.ID) != projectID { + return nil, store.ErrNotFound + } + return delegatedProjectProjection{caps: h.caps, pluginID: h.pluginID, + actor: h.actor, creator: creator, projectID: projectID}, nil +} + +// delegatedProjectProjection exposes only ProjectHost, and every method is +// confined to the bound project. It cannot be cast to the broader Host. +type delegatedProjectProjection struct { + caps hostcaps + pluginID string + actor string + creator string + projectID string +} + +var _ extension.ProjectHost = delegatedProjectProjection{} + +func (p delegatedProjectProjection) Owner() string { return p.creator } + +func (p delegatedProjectProjection) check() error { + if p.caps.accountGuard == nil { + return nil + } + if err := p.caps.accountGuard(p.actor); err != nil { + return err + } + return p.caps.accountGuard(p.creator) +} + +func (p delegatedProjectProjection) target(id string) error { + if id == "" || id != p.projectID { + return store.ErrNotFound + } + return nil +} + +func (p delegatedProjectProjection) OwnedProjects() ([]core.Project, error) { + return nil, store.ErrAccountForbidden +} + +func (p delegatedProjectProjection) OwnedProject(string) (core.Project, error) { + return core.Project{}, store.ErrAccountForbidden +} + +func (p delegatedProjectProjection) EnsureProject(string, string) (core.Project, error) { + return core.Project{}, store.ErrAccountForbidden +} + +func (p delegatedProjectProjection) ProjectMetadata(id string) (core.Project, error) { + if err := p.target(id); err != nil { + return core.Project{}, err + } + if err := p.check(); err != nil { + return core.Project{}, err + } + view := inprocHost{caps: p.caps, pluginID: p.pluginID, owner: p.creator, actor: p.actor} + return view.ProjectMetadata(id) +} + +func (p delegatedProjectProjection) ProjectNameAvailable(name, exceptID string) (bool, error) { + if err := p.target(exceptID); err != nil { + return false, err + } + if err := p.check(); err != nil { + return false, err + } + return p.caps.st.ProjectNameAvailable(name, exceptID) +} + +func (p delegatedProjectProjection) RenameOwnedProject(id, name string) (core.Project, error) { + if err := p.target(id); err != nil { + return core.Project{}, err + } + if err := p.check(); err != nil { + return core.Project{}, err + } + return p.caps.st.RenameOwnedProjectAccountChecked(p.actor, p.creator, p.projectID, name, p.caps.allowImplicitLocal) +} diff --git a/internal/app/delegated_project_projection_entry_test.go b/internal/app/delegated_project_projection_entry_test.go new file mode 100644 index 0000000..ddd9320 --- /dev/null +++ b/internal/app/delegated_project_projection_entry_test.go @@ -0,0 +1,108 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + agentcommand "agent-command" + "agent-system-backend/internal/cmdbus" + "agent-system-backend/internal/server" + "agent-system-backend/internal/settings" + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" + core "agent-system-core" +) + +func TestDelegatedProjectionIsBoundAtHTTPAndCommandEntries(t *testing.T) { + st, err := store.OpenSQLite(t.TempDir() + "/entries.db") + if err != nil { + t.Fatal(err) + } + defer st.Close() + for _, owner := range []string{"alice", "bob"} { + if err := st.EnsureUser(owner, ""); err != nil { + t.Fatal(err) + } + } + httpProject, err := st.CreateProject("alice", "HTTP original") + if err != nil { + t.Fatal(err) + } + commandProject, err := st.CreateProject("alice", "Command original") + if err != nil { + t.Fatal(err) + } + check := func(owner string) error { + state, err := st.GetUserAuthState(owner) + if err != nil { + return err + } + if !state.Enabled { + return store.ErrAccountForbidden + } + return nil + } + projectFromRequest := func(h extension.Host, id, name string) (core.Project, error) { + bound, ok := h.(extension.DelegatedProjectProjectionHost) + if !ok { + return core.Project{}, errors.New("request Host lost operator-bound projection") + } + projection, err := bound.ProjectProjectionForCreator("alice", id) + if err != nil { + return core.Project{}, err + } + return projection.RenameOwnedProject(id, name) + } + slot := &projectLifecycleSlot{} + caps := hostcaps{st: st, set: settings.NewService(st), projects: slot, accountGuard: check} + provider := completeLifecycle() + provider.Rename = func(_ context.Context, h extension.Host, id, name string) (core.Project, error) { + return projectFromRequest(h, id, name) + } + if err := (pluginRegistry{pluginID: "rdm", caps: caps}).RegisterProjectLifecycle(provider); err != nil { + t.Fatal(err) + } + srv := server.New(st, nil, nil, nil, caps.set, nil) + srv.Auth = server.NewAuthenticator(st, true) + slot.wire(srv, caps) + key, err := st.CreateAPIKey("bob", "delegate-entry") + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPatch, "/api/projects/"+string(httpProject.ID), strings.NewReader(`{"name":"via HTTP"}`)) + req.Header.Set("Authorization", "Bearer "+key) + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("HTTP lifecycle rename: status=%d body=%s", rec.Code, rec.Body.String()) + } + bus := cmdbus.New(st, nil, nil) + bus.SetEffectiveAccountGuard(func(_ context.Context, owner string) error { return check(owner) }) + bus.RequireAccountCheckedWrites(false) + reg := pluginRegistry{pluginID: "rdm", caps: caps, compiledRegistry: compiledRegistry{bus: bus}} + err = reg.AddCommandHandler(json.RawMessage(`{"id":"rdm.fixture.rename","title":"Rename"}`), + func(_ context.Context, h extension.Host, req extension.Request) (any, error) { + id, _ := req.Params["id"].(string) + name, _ := req.Params["name"].(string) + return projectFromRequest(h, id, name) + }) + if err != nil { + t.Fatal(err) + } + result := bus.Dispatch(context.Background(), cmdbus.Request{ID: "rdm.fixture.rename", Owner: "bob", Actor: "bob", + Params: map[string]any{"id": string(commandProject.ID), "name": "via command"}}) + if result.Receipt.State != agentcommand.StateCommitted { + t.Fatalf("command rename: %+v", result.Receipt) + } + for _, tc := range []struct{ id, want string }{{string(httpProject.ID), "via HTTP"}, {string(commandProject.ID), "via command"}} { + project, err := st.ProjectMetadata(tc.id) + if err != nil || project.Name != tc.want { + t.Fatalf("entry %s did not reach bound projection: %+v %v", tc.id, project, err) + } + } +} diff --git a/internal/app/delegated_project_projection_pg_test.go b/internal/app/delegated_project_projection_pg_test.go new file mode 100644 index 0000000..f1259c5 --- /dev/null +++ b/internal/app/delegated_project_projection_pg_test.go @@ -0,0 +1,135 @@ +package app + +import ( + "bytes" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "agent-system-backend/internal/store" +) + +// Two processes share one isolated PostgreSQL schema. The child has already +// accepted a stale early account read when the parent commits disable; only +// the final Backend transaction can stop the physical rename. +func TestPGDelegatedProjectionDisableAcrossProcesses(t *testing.T) { + baseDSN := os.Getenv("AGENT_PG_TEST_DSN") + if baseDSN == "" { + t.Skip("isolated PostgreSQL DSN required") + } + separator := "?" + if strings.Contains(baseDSN, "?") { + separator = "&" + } + if os.Getenv("IAM_U3_DELEGATE_CHILD") == "1" { + dsn := baseDSN + separator + "search_path=" + os.Getenv("IAM_U3_DELEGATE_SCHEMA") + ",public" + st, err := store.OpenExistingPostgres(dsn, filepath.Join(t.TempDir(), "artifacts")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + var bobChecks atomic.Int32 + check := func(owner string) error { + state, err := st.GetUserAuthState(owner) + if err != nil { + return err + } + if !state.Enabled { + return store.ErrAccountForbidden + } + if owner == "bob" && bobChecks.Add(1) == 2 { + if err := os.WriteFile(os.Getenv("IAM_U3_DELEGATE_READY"), []byte("ready"), 0o600); err != nil { + return err + } + deadline := time.Now().Add(5 * time.Second) + for { + if _, err := os.Stat(os.Getenv("IAM_U3_DELEGATE_RELEASE")); err == nil { + break + } + if time.Now().After(deadline) { + return errors.New("parent did not release paused child") + } + time.Sleep(10 * time.Millisecond) + } + } + return nil + } + slot := &projectLifecycleSlot{} + caps := hostcaps{st: st, projects: slot, accountGuard: check} + if err := (pluginRegistry{pluginID: "rdm", caps: caps}).RegisterProjectLifecycle(completeLifecycle()); err != nil { + t.Fatal(err) + } + host := requestBoundHost{inprocHost{caps: caps, pluginID: "rdm", owner: "bob", actor: "bob"}} + projection, err := host.ProjectProjectionForCreator("alice", os.Getenv("IAM_U3_DELEGATE_PROJECT")) + if err != nil { + t.Fatal(err) + } + if _, err := projection.RenameOwnedProject(os.Getenv("IAM_U3_DELEGATE_PROJECT"), "late"); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("late physical rename was not fenced: %v", err) + } + return + } + st := openDirectoryTestStore(t) + if err := st.EnsureSystemIdentity("u3-delegate-two-process", "pg"); err != nil { + t.Fatal(err) + } + for _, account := range []struct { + owner string + admin bool + }{{"admin", true}, {"alice", false}, {"bob", false}} { + if err := st.CreateAccount(account.owner, "", "", account.admin); err != nil { + t.Fatal(err) + } + } + project, err := st.CreateProject("alice", "before") + if err != nil { + t.Fatal(err) + } + var schema string + if err := st.DB().QueryRow(`SELECT current_schema()`).Scan(&schema); err != nil { + t.Fatal(err) + } + barrier := t.TempDir() + ready, release := filepath.Join(barrier, "ready"), filepath.Join(barrier, "release") + cmd := exec.Command(os.Args[0], "-test.run=^TestPGDelegatedProjectionDisableAcrossProcesses$") + cmd.Env = append(os.Environ(), "IAM_U3_DELEGATE_CHILD=1", "IAM_U3_DELEGATE_SCHEMA="+schema, + "IAM_U3_DELEGATE_PROJECT="+string(project.ID), "IAM_U3_DELEGATE_READY="+ready, "IAM_U3_DELEGATE_RELEASE="+release) + var output bytes.Buffer + cmd.Stdout, cmd.Stderr = &output, &output + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(5 * time.Second) + for { + if _, err := os.Stat(ready); err == nil { + break + } + if time.Now().After(deadline) { + _ = cmd.Process.Kill() + t.Fatalf("child did not reach stale early check: %s", strings.ReplaceAll(output.String(), baseDSN, "[REDACTED]")) + } + time.Sleep(10 * time.Millisecond) + } + bob, err := st.GetAccountByOwner("bob") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", bob.ID, bob.StateVersion, false); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(release, []byte("release"), 0o600); err != nil { + t.Fatal(err) + } + if err := cmd.Wait(); err != nil { + t.Fatalf("child failed: %v: %s", err, strings.ReplaceAll(output.String(), baseDSN, "[REDACTED]")) + } + got, err := st.ProjectMetadata(string(project.ID)) + if err != nil || got.Name != "before" { + t.Fatalf("disabled operator's project rename persisted: %+v %v", got, err) + } +} diff --git a/internal/app/execenv_wire.go b/internal/app/execenv_wire.go new file mode 100644 index 0000000..d25cd24 --- /dev/null +++ b/internal/app/execenv_wire.go @@ -0,0 +1,132 @@ +package app + +import ( + "agent-system-backend/internal/execenv" + "agent-system-backend/internal/store" + "agent-system-backend/internal/tools" + core "agent-system-core" +) + +// envStore 把 store 的行类型适配成 execenv.Store。 +type envStore struct{ *store.Store } + +func (s envStore) InsertExecEnvRequest(r execenv.Request) error { + return s.Store.InsertExecEnvRequest(store.ExecEnvRequest{ + ID: r.ID, ConversationID: r.ConversationID, Owner: r.Owner, Spec: r.Spec, + Reason: r.Reason, Status: r.Status, LayerID: r.LayerID, Detail: r.Detail, + Approver: r.Approver, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, + }) +} + +func (s envStore) GetExecEnvRequest(id string) (execenv.Request, error) { + r, err := s.Store.GetExecEnvRequest(id) + if err != nil { + return execenv.Request{}, err + } + return execenv.Request{ + ID: r.ID, ConversationID: r.ConversationID, Owner: r.Owner, Spec: r.Spec, + Reason: r.Reason, Status: r.Status, LayerID: r.LayerID, Detail: r.Detail, + Approver: r.Approver, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, + }, nil +} + +func (s envStore) ListExecEnvRequests(conversationID string, limit int) ([]execenv.Request, error) { + rows, err := s.Store.ListExecEnvRequests(conversationID, limit) + if err != nil { + return nil, err + } + out := make([]execenv.Request, 0, len(rows)) + for _, r := range rows { + out = append(out, execenv.Request{ + ID: r.ID, ConversationID: r.ConversationID, Owner: r.Owner, Spec: r.Spec, + Reason: r.Reason, Status: r.Status, LayerID: r.LayerID, Detail: r.Detail, + Approver: r.Approver, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, + }) + } + return out, nil +} + +func (s envStore) UpdateExecEnvRequest(id, status, layerID, detail, approver string) error { + return s.Store.UpdateExecEnvRequest(id, status, layerID, detail, approver) +} + +func (s envStore) GetExecEnvBinding(conversationID string) (execenv.Binding, error) { + b, err := s.Store.GetExecEnvBinding(conversationID) + if err != nil { + return execenv.Binding{}, err + } + return execenv.Binding{ + ConversationID: b.ConversationID, BaseID: b.BaseID, Extensions: b.Layers, + Isolated: b.Isolated, UpdatedAt: b.UpdatedAt, + }, nil +} + +func (s envStore) PutExecEnvBinding(b execenv.Binding) error { + return s.Store.PutExecEnvBinding(store.ExecEnvBinding{ + ConversationID: b.ConversationID, BaseID: b.BaseID, Layers: b.Extensions, + Isolated: b.Isolated, UpdatedAt: b.UpdatedAt, + }) +} + +func (s envStore) ListExecEnvBindings() ([]execenv.Binding, error) { + rows, err := s.Store.ListExecEnvBindings() + if err != nil { + return nil, err + } + out := make([]execenv.Binding, 0, len(rows)) + for _, b := range rows { + out = append(out, execenv.Binding{ + ConversationID: b.ConversationID, BaseID: b.BaseID, Extensions: b.Layers, + Isolated: b.Isolated, UpdatedAt: b.UpdatedAt, + }) + } + return out, nil +} + +func (s envStore) InsertExecEnvAudit(owner, action, actor, target, outcome, entry string) error { + return s.Store.InsertExecEnvAudit(owner, action, actor, target, outcome, entry) +} + +// wireExecEnv 打开环境根,把基础层交给执行器,并接上申请代理。 +// 两者都空时不改变现状(解释器仍固定为 /usr/bin/python3,避免 linuxbrew 路径)。 +func wireExecEnv(st *store.Store, opt *Options) (*execenv.Broker, error) { + if opt.ExecEnvRoot == "" { + return nil, nil + } + root, err := execenv.Open(opt.ExecEnvRoot) + if err != nil { + return nil, err + } + if bin, ok := root.PythonBin(); ok { + tools.SetExecPython(bin, root.PythonPrefix()) + } + tools.SetExecShimDir(root.BinDir()) + if opt.ExecDepsDir == "" { + if id, err := root.CurrentBase(); err != nil { + return nil, err + } else if id != "" { + opt.ExecDepsDir = root.LayerDir(id) + } + } + py, _ := root.PythonBin() + if py == "" { + py = "/usr/bin/python3" + } + broker := execenv.NewBroker(root, envStore{st}, execenv.Pip{Python: py}) + tools.SetEnvBroker(broker) + tools.SetExecLayers(broker.LayersFor) + tools.SetEnvApproval(func(owner, conv, requestID, detail string) error { + card := &core.HITLCard{ + ID: core.ID("hitl_" + requestID), RunID: "execenv", ConversationID: core.ID(conv), + Question: "批准为这个会话安装额外的 Python 包?", Kind: core.HITLKindChoice, + Detail: "exec-env:" + requestID + "\n" + detail, + Choices: []core.HITLChoice{ + {ID: "approve", Label: "批准", Description: detail, Impact: "只挂到这个会话,不改变基础层", Recommended: true}, + {ID: "isolate", Label: "批准并使用隔离层", Description: "与基础层版本冲突时用这份独立闭包", Impact: "这个会话不再叠基础层"}, + {ID: "reject", Label: "拒绝", Description: "不安装", Impact: "会话保持现状"}, + }, + } + return st.SaveHITLCard(owner, card) + }) + return broker, nil +} diff --git a/internal/app/ext9_publish_test.go b/internal/app/ext9_publish_test.go index 62b2ff6..8530069 100644 --- a/internal/app/ext9_publish_test.go +++ b/internal/app/ext9_publish_test.go @@ -23,6 +23,9 @@ func newExt9Host(t *testing.T) (interface { t.Fatal(err) } t.Cleanup(func() { st.Close() }) + if err := st.EnsureUser("alice", ""); err != nil { + t.Fatal(err) + } set := settings.NewService(st) pipe := rag.NewPipeline(st, set, nil, nil) h := inprocHost{caps: hostcaps{st: st, pipe: pipe, set: set}, pluginID: "rdm", owner: "alice"} diff --git a/internal/app/guard_test.go b/internal/app/guard_test.go new file mode 100644 index 0000000..743c07e --- /dev/null +++ b/internal/app/guard_test.go @@ -0,0 +1,201 @@ +//go:build unix + +package app + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "agent-system-backend/internal/instanceguard" + "agent-system-backend/internal/store" + core "agent-system-core" +) + +func TestSecondProcessRejectedBeforeOpen(t *testing.T) { + if os.Getenv("U8_APP_ROLE") == "hold" { + holdService(t) + return + } + dir := t.TempDir() + ready := filepath.Join(dir, "ready") + cmd := exec.Command(os.Args[0], "-test.run=TestSecondProcessRejectedBeforeOpen") + cmd.Env = append(os.Environ(), "U8_APP_ROLE=hold", "U8_APP_DIR="+dir, "U8_APP_READY="+ready) + cmd.Dir = t.TempDir() + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if cmd.Process != nil { + _ = cmd.Process.Kill() + _, _ = cmd.Process.Wait() + } + }) + waitReady(t, ready) + _, _, err := openServiceStore(Options{DataDir: dir, Addr: ":1"}) + if !errors.Is(err, instanceguard.ErrBusy) { + t.Fatalf("second open: %v", err) + } + if err := Migrate(Options{DataDir: dir}); !errors.Is(err, instanceguard.ErrBusy) { + t.Fatalf("migrate while held: %v", err) + } + cli, err := store.OpenExistingSQLite(filepath.Join(dir, "agent-system.db")) + if err != nil { + t.Fatal(err) + } + run, err := cli.GetRunByID("run_u8_hold") + if err != nil { + t.Fatal(err) + } + if run.Status != core.RunWaitingHITL { + t.Fatalf("run changed while second process was refused: %s", run.Status) + } + if _, err := cli.CreateProject("owner", "short"); err != nil { + t.Fatalf("account-style write while service holds the guard: %v", err) + } + if err := cli.Close(); err != nil { + t.Fatal(err) + } + if err := cmd.Process.Kill(); err != nil { + t.Fatal(err) + } + _, _ = cmd.Process.Wait() + st, release, err := openServiceStore(Options{DataDir: dir, Addr: ":2"}) + if err != nil { + t.Fatal(err) + } + defer release() + run, err = st.GetRunByID("run_u8_hold") + if err != nil { + t.Fatal(err) + } + if run.Status != core.RunWaitingHITL { + t.Fatalf("restart reconciled a run the second process never opened: %s", run.Status) + } +} + +func TestCopiedBinaryStillSharesGuard(t *testing.T) { + if os.Getenv("U8_APP_ROLE") == "copy-hold" { + holdService(t) + return + } + bin, err := os.Executable() + if err != nil { + t.Fatal(err) + } + copyPath := filepath.Join(t.TempDir(), "agent-system-test-copy") + raw, err := os.ReadFile(bin) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(copyPath, raw, 0o700); err != nil { + t.Fatal(err) + } + dir := t.TempDir() + ready := filepath.Join(dir, "ready") + cmd := exec.Command(copyPath, "-test.run=TestCopiedBinaryStillSharesGuard") + cmd.Env = append(os.Environ(), "U8_APP_ROLE=copy-hold", "U8_APP_DIR="+dir, "U8_APP_READY="+ready) + cmd.Dir = t.TempDir() + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if cmd.Process != nil { + _ = cmd.Process.Kill() + _, _ = cmd.Process.Wait() + } + }) + waitReady(t, ready) + if _, _, err := openServiceStore(Options{DataDir: dir}); !errors.Is(err, instanceguard.ErrBusy) { + t.Fatalf("copied binary: %v", err) + } +} + +func holdService(t *testing.T) { + t.Helper() + dir := os.Getenv("U8_APP_DIR") + st, _, err := openServiceStore(Options{DataDir: dir}) + if err != nil { + t.Fatal(err) + } + run := &core.Run{ + ID: "run_u8_hold", ConversationID: "c", ProjectID: "p", Owner: "o", + Kind: core.RunConversation, Status: core.RunWaitingHITL, + } + if err := st.CreateRun(run); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(os.Getenv("U8_APP_READY"), []byte("ready"), 0o600); err != nil { + t.Fatal(err) + } + time.Sleep(30 * time.Second) +} + +func waitReady(t *testing.T, ready string) { + t.Helper() + deadline := time.Now().Add(20 * time.Second) + for time.Now().Before(deadline) { + if _, err := os.Stat(ready); err == nil { + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatal("child did not become ready") +} + +func TestAdoptDoesNotCreateMissingDatabase(t *testing.T) { + dir := t.TempDir() + _, release, err := openServiceStore(Options{DataDir: dir}) + if err != nil { + t.Fatal(err) + } + release() + db := filepath.Join(dir, "agent-system.db") + if err := os.Remove(db); err != nil { + t.Fatal(err) + } + _, _, err = openServiceStore(Options{DataDir: dir, AdoptInstanceGuard: true}) + if !errors.Is(err, instanceguard.ErrDBMissing) { + t.Fatalf("adopt missing: %v", err) + } + if _, err := os.Stat(db); !os.IsNotExist(err) { + t.Fatalf("adopt created a database: %v", err) + } +} + +func TestIdentityFailureDoesNotStick(t *testing.T) { + dir := t.TempDir() + t.Setenv("AGENT_SYSTEM_INSTANCE_ID", "wrong") + if _, _, err := openServiceStore(Options{DataDir: dir}); err == nil { + t.Fatal("missing identity row was accepted") + } + t.Setenv("AGENT_SYSTEM_INSTANCE_ID", "") + st, err := store.OpenSQLite(filepath.Join(dir, "agent-system.db")) + if err != nil { + t.Fatal(err) + } + marker, ok, err := st.LoadInstanceGuard() + if err != nil || ok || marker.InstanceID != "" { + t.Fatalf("failed start wrote a guard id: ok=%v id=%q err=%v", ok, marker.InstanceID, err) + } + if err := st.EnsureSystemIdentity("right", "sqlite"); err != nil { + t.Fatal(err) + } + if err := st.Close(); err != nil { + t.Fatal(err) + } + t.Setenv("AGENT_SYSTEM_INSTANCE_ID", "right") + opened, release, err := openServiceStore(Options{DataDir: dir}) + if err != nil { + t.Fatal(err) + } + release() + _ = opened +} diff --git a/internal/app/hostcaps.go b/internal/app/hostcaps.go index 40025bb..5de0282 100644 --- a/internal/app/hostcaps.go +++ b/internal/app/hostcaps.go @@ -5,9 +5,14 @@ import ( "context" "fmt" "sort" + "strconv" "strings" "sync" + "time" + authzsvc "agent-system-backend/internal/authz" + "agent-system-backend/internal/authz/catalog" + "agent-system-backend/internal/cmdbus" "agent-system-backend/internal/ingest" "agent-system-backend/internal/rag" "agent-system-backend/internal/runtime" @@ -21,13 +26,20 @@ import ( // 同一实现同时服务 hostapi.Server 的 Search/Complete 闭包与进程内 // inprocHost(EXT-1 批注 1:复用现有 owner 作用域函数,不新造句柄)。 type hostcaps struct { - projects *projectLifecycleSlot - contexts *projectContextSlot - st *store.Store - pipe *rag.Pipeline - set *settings.Service - authz *projectAuthz // EXT-8 适配器槽(共享指针;首个注册者生效) - surfaces *surfacesvc.Service // EXT-17 Surface 能力槽(nil = 未装配,明确拒绝) + projects *projectLifecycleSlot + workObserver *projectWorkObserverSlot + directory *accountDirectorySlot + contexts *projectContextSlot + projectAudit *projectAuditSlot + regulations *regulationsSlot // EXT-20 法规提供方槽(共享指针;nil/未注册=不可用) + accountGuard func(string) error + allowImplicitLocal bool + st *store.Store + pipe *rag.Pipeline + set *settings.Service + authz *projectAuthz // EXT-8 适配器槽(共享指针;首个注册者生效) + surfaces *surfacesvc.Service // EXT-17 Surface 能力槽(nil = 未装配,明确拒绝) + now func() time.Time } // projectAuthz EXT-8 资源授权适配器槽:进程内扩展注册,组合根装配进 Server。 @@ -37,6 +49,7 @@ type projectAuthz struct { fn extension.ResourceAuthorizer holder2 string // EXT-8b 资源级持有者(首个注册者生效) fn2 extension.ResourceAuthorizerV2 + enrich func(extension.ResourceAuthzQuery) (extension.ResourceAuthzQuery, error) } func (a *projectAuthz) set(pluginID string, fn extension.ResourceAuthorizer) { @@ -62,12 +75,19 @@ func (a *projectAuthz) setV2(pluginID string, fn extension.ResourceAuthorizerV2) // 布尔(兼容现行行为)。查询字段由执行端受信构造。 func (a *projectAuthz) AuthorizeResource(q extension.ResourceAuthzQuery) (bool, error) { a.mu.Lock() - fn2, fn1 := a.fn2, a.fn + fn2, fn1, enrich := a.fn2, a.fn, a.enrich a.mu.Unlock() if fn2 != nil { if q.Actor == "" || q.Action == "" || q.ResourceType == "" || q.ResourceID == "" { return false, nil // 受信上下文字段缺失=不可判定=拒绝 } + if enrich != nil { + var err error + q, err = enrich(q) + if err != nil { + return false, err + } + } return fn2(q) } if fn1 == nil || q.Owner == "" || q.ProjectID == "" { @@ -110,6 +130,11 @@ type sharedRetriever interface { } func (c hostcaps) search(ctx context.Context, owner string, projectID core.ID, text string, topK int) ([]core.RetrievalHit, error) { + if c.accountGuard != nil { + if err := c.accountGuard(owner); err != nil { + return nil, err + } + } q := core.RetrievalQuery{ProjectID: projectID, Text: text, TopK: topK} if c.projects != nil && projectID != "" { id, err := c.projects.resolve(ctx, c, owner, projectID) @@ -144,10 +169,23 @@ func (c hostcaps) searchDefault(ctx context.Context, owner, text string) ([]core // complete 经基座 LLM 路由补全(owner 的模型配置生效;未配置报错)。 func (c hostcaps) complete(ctx context.Context, owner, prompt string) (string, error) { + if c.accountGuard != nil { + if err := c.accountGuard(owner); err != nil { + return "", err + } + } route := runtime.ResolveChatModel(core.AgentProfile{}, c.set.Effective(owner, "")) if route.Model == "" || route.Base == "" { return "", fmt.Errorf("llm route not configured") } + if err := ctx.Err(); err != nil { + return "", err + } + if c.accountGuard != nil { + if err := c.accountGuard(owner); err != nil { + return "", err + } + } return completeChat(ctx, route.Base, route.Key, route.Model, prompt) } @@ -177,6 +215,15 @@ type inprocHost struct { actor string // EXT-18:分发主体(空=非 dispatch 上下文) conversationID core.ID // dispatch-bound, never taken from completion arguments hostProjectID core.ID // dispatch project; rechecked against stored conversation + entrance string // dispatch entrance; model tools are "agent" + humanGrant *cmdbus.HumanGrant +} + +func (h inprocHost) requireEffective() error { + if h.caps.accountGuard != nil { + return h.caps.accountGuard(h.owner) + } + return nil } func (h inprocHost) PluginID() string { return h.pluginID } @@ -193,14 +240,26 @@ func (h inprocHost) IsAdmin() bool { if err != nil { return false } - return state.IsAdmin + return state.Enabled && state.IsAdmin } -// ListAccounts 实现 EXT-19 AccountLister(最小账号目录):仅授权管理读面用。 -// 有效态=账号存在且非停用(当前 users 表无停用位——must_change_password 不影响 -// 有效性;后续加停用位在此映射)。P3(2026-10-13):携带 Name 供同名区分。 -// 游标=owner 升序偏移。 +func (h inprocHost) legacySystemDirectoryAuthorized() bool { + if h.requireEffective() != nil { + return false + } + if h.actor == "" { + return false + } + state, err := h.caps.st.GetUserAuthState(h.actor) + return err == nil && state.Enabled && state.IsAdmin +} + +// ListAccounts 保留 EXT-19 旧签名,仅供有受信分发主体的系统管理上下文; +// 项目和文档候选必须走带 locator 的 AccountDirectoryProvider。 func (h inprocHost) ListAccounts(cursor string, limit int) ([]extension.AccountEntry, string, error) { + if !h.legacySystemDirectoryAuthorized() { + return nil, "", store.ErrAccountForbidden + } if limit <= 0 || limit > 200 { limit = 50 } @@ -210,7 +269,7 @@ func (h inprocHost) ListAccounts(cursor string, limit int) ([]extension.AccountE } entries := make([]extension.AccountEntry, 0, len(users)) for _, u := range users { - entries = append(entries, extension.AccountEntry{Owner: u.Owner, Name: u.Name, Valid: true}) + entries = append(entries, extension.AccountEntry{Owner: u.Owner, Name: u.Name, Valid: u.Enabled}) } // 排序+游标(owner 升序;cursor 为上一页末 owner,空=首页)。 for i := 0; i < len(entries); i++ { @@ -241,6 +300,9 @@ func (h inprocHost) ListAccounts(cursor string, limit int) ([]extension.AccountE // 姓名子串检索(大小写不敏感;空 query = 首页候选)。结果按 owner 升序, // 有效态口径与 ListAccounts 一致。 func (h inprocHost) SearchAccounts(query string, limit int) ([]extension.AccountEntry, error) { + if !h.legacySystemDirectoryAuthorized() { + return nil, store.ErrAccountForbidden + } if limit <= 0 || limit > 200 { limit = 50 } @@ -256,7 +318,7 @@ func (h inprocHost) SearchAccounts(query string, limit int) ([]extension.Account !strings.Contains(strings.ToLower(u.Name), q) { continue } - entries = append(entries, extension.AccountEntry{Owner: u.Owner, Name: u.Name, Valid: true}) + entries = append(entries, extension.AccountEntry{Owner: u.Owner, Name: u.Name, Valid: u.Enabled}) } sort.Slice(entries, func(i, j int) bool { return entries[i].Owner < entries[j].Owner }) if len(entries) > limit { @@ -265,29 +327,503 @@ func (h inprocHost) SearchAccounts(query string, limit int) ([]extension.Account return entries, nil } +func (h inprocHost) rolePolicyActor() (store.Account, error) { + if h.actor == "" { + return store.Account{}, store.ErrAccountForbidden + } + account, err := h.caps.st.GetAccountByOwner(h.actor) + if err != nil || !account.Enabled { + return store.Account{}, store.ErrAccountForbidden + } + return account, nil +} + +func (h inprocHost) CurrentRolePolicy(ctx context.Context) (extension.RolePolicySnapshot, error) { + if err := ctx.Err(); err != nil { + return extension.RolePolicySnapshot{}, err + } + actor, err := h.rolePolicyActor() + if err != nil { + return extension.RolePolicySnapshot{}, err + } + return h.RolePolicyForUser(ctx, actor.ID) +} + +func (h inprocHost) RolePolicyForUser(ctx context.Context, userID string) (extension.RolePolicySnapshot, error) { + if err := ctx.Err(); err != nil { + return extension.RolePolicySnapshot{}, err + } + if _, err := h.rolePolicyActor(); err != nil { + return extension.RolePolicySnapshot{}, err + } + return rolePolicySnapshotForUser(h.caps.st, userID) +} + +func rolePolicySnapshotForUser(st *store.Store, userID string) (extension.RolePolicySnapshot, error) { + account, err := st.GetAccount(userID) + if err != nil { + return extension.RolePolicySnapshot{}, err + } + epoch, err := st.AuthorityEpoch() + if err != nil { + return extension.RolePolicySnapshot{}, err + } + snapshot := extension.RolePolicySnapshot{SchemaVersion: extension.RolePolicySchemaVersion, + UserID: account.ID, Owner: account.Owner, AccountEnabled: account.Enabled, + AuthorityEpoch: epoch, Permissions: map[string]extension.RolePermission{}} + linked, err := authzsvc.NewService(st).EffectiveLinkedCapabilities(account.Owner) + if err != nil { + return extension.RolePolicySnapshot{}, err + } + snapshot.Linked = extension.LinkedCapabilities{ProjectCreate: linked.ProjectCreate, Source: linked.Source, + Actions: linked.LinkedActions, Conflicts: linked.Conflicts} + view, err := st.RoleForUser(userID) + if err != nil { + return extension.RolePolicySnapshot{}, err + } + if view.Binding == nil || view.Role == nil { + return snapshot, nil + } + snapshot.BindingID = view.Binding.BindingID + snapshot.RoleID = view.Role.RoleID + snapshot.RoleName = view.Role.Name + snapshot.RoleDescription = view.Role.Description + snapshot.RoleEnabled = view.Role.Enabled + snapshot.RoleVersion = view.Role.RoleVersion + permissions, err := st.RolePermissions(view.Role.RoleID) + if err != nil { + return extension.RolePolicySnapshot{}, err + } + for _, permission := range permissions { + snapshot.Permissions[permission.ActionID] = extension.RolePermission{Allowed: permission.Allowed, CapAllowed: permission.CapAllowed} + } + return snapshot, nil +} + +func (h inprocHost) RolePolicyForOwner(ctx context.Context, owner string) (extension.RolePolicySnapshot, error) { + if _, err := h.rolePolicyActor(); err != nil { + return extension.RolePolicySnapshot{}, err + } + account, err := h.caps.st.GetAccountByOwner(owner) + if err != nil { + return extension.RolePolicySnapshot{}, err + } + return h.RolePolicyForUser(ctx, account.ID) +} + +func (h inprocHost) CheckRoleAction(ctx context.Context, userID, actionID string) (extension.RoleActionDecision, error) { + decision := extension.RoleActionDecision{ActionID: actionID} + if _, ok := catalog.DescriptorByID(actionID); !ok { + decision.Reason = "unknown-action" + return decision, nil + } + snapshot, err := h.RolePolicyForUser(ctx, userID) + if err != nil { + return decision, err + } + decision.RoleID = snapshot.RoleID + decision.AuthorityEpoch = snapshot.AuthorityEpoch + if !snapshot.AccountEnabled { + decision.Reason = "account-disabled" + return decision, nil + } + if snapshot.RoleID == "" { + decision.Reason = "role-unassigned" + return decision, nil + } + if !snapshot.RoleEnabled { + decision.Reason = "role-disabled" + return decision, nil + } + permission, ok := snapshot.Permissions[actionID] + if !ok { + decision.Reason = "action-unconfigured" + return decision, nil + } + decision.Allowed = permission.Allowed + decision.CapAllowed = permission.CapAllowed + if !permission.CapAllowed { + decision.Reason = "role-cap" + } else if !permission.Allowed { + decision.Reason = "role-default-deny" + } + return decision, nil +} + +func (h inprocHost) CheckCurrentSystemAction(ctx context.Context, actionID string) (extension.SystemActionDecision, error) { + if err := ctx.Err(); err != nil { + return extension.SystemActionDecision{}, err + } + if _, err := h.rolePolicyActor(); err != nil { + return extension.SystemActionDecision{}, err + } + decision := authzsvc.NewService(h.caps.st).AuthorizeSystem(h.actor, actionID) + return extension.SystemActionDecision{ActionID: actionID, Allowed: decision.Allowed, + AuthorityEpoch: decision.AuthorityEpoch, Reason: decision.Reason}, nil +} + +func (h inprocHost) currentCapabilityExplanation(ctx context.Context) (store.CapabilityExplanation, extension.CapabilitySubject, error) { + if err := ctx.Err(); err != nil { + return store.CapabilityExplanation{}, extension.CapabilitySubject{}, err + } + if _, err := h.rolePolicyActor(); err != nil { + return store.CapabilityExplanation{}, extension.CapabilitySubject{}, err + } + explanation, err := authzsvc.NewService(h.caps.st).ExplainCapabilities(h.actor) + if err != nil { + return store.CapabilityExplanation{}, extension.CapabilitySubject{}, err + } + subject := extension.CapabilitySubject{SchemaVersion: extension.CapabilityExplainSchemaVersion, + UserID: explanation.UserID, Owner: explanation.Owner, AuthorityEpoch: explanation.AuthorityEpoch, + RoleID: explanation.RoleID, RoleName: explanation.RoleName, RoleEnabled: explanation.RoleEnabled} + return explanation, subject, nil +} + +func capabilityActionExplains(in []store.CapabilityActionExplain) []extension.CapabilityActionExplain { + out := make([]extension.CapabilityActionExplain, 0, len(in)) + for _, action := range in { + out = append(out, extension.CapabilityActionExplain(action)) + } + return out +} + +func (h inprocHost) CurrentCapabilityExplain(ctx context.Context) (extension.CapabilityExplain, error) { + explanation, subject, err := h.currentCapabilityExplanation(ctx) + if err != nil { + return extension.CapabilityExplain{}, err + } + capability := explanation.Capability + return extension.CapabilityExplain{CapabilitySubject: subject, + ProjectCreate: extension.CapabilityActionExplain(capability.ProjectCreate), + Linked: capabilityActionExplains(capability.Linked), + Conflicts: append([]string{}, capability.Conflicts...), MigrationNote: capability.MigrationNote}, nil +} + +func (h inprocHost) CurrentMemberBasicCollaborate(ctx context.Context) (extension.MemberBasicCollaborate, error) { + explanation, subject, err := h.currentCapabilityExplanation(ctx) + if err != nil { + return extension.MemberBasicCollaborate{}, err + } + return extension.MemberBasicCollaborate{CapabilitySubject: subject, + Actions: capabilityActionExplains(explanation.MemberBasicCollaborate.Actions)}, nil +} + +func (h inprocHost) CurrentDocumentQualify(ctx context.Context) (extension.DocumentQualify, error) { + explanation, subject, err := h.currentCapabilityExplanation(ctx) + if err != nil { + return extension.DocumentQualify{}, err + } + duties := make([]extension.DocumentDutyQualify, 0, len(explanation.DocumentQualify.Duties)) + for _, duty := range explanation.DocumentQualify.Duties { + duties = append(duties, extension.DocumentDutyQualify(duty)) + } + return extension.DocumentQualify{CapabilitySubject: subject, Duties: duties}, nil +} + +func (h inprocHost) requireRoleAdminAction(actionID string) error { + if h.actor == "" || h.owner != h.actor { + return store.ErrAccountForbidden + } + decision := authzsvc.NewService(h.caps.st).AuthorizeSystem(h.actor, actionID) + if !decision.Allowed { + return store.ErrAccountForbidden + } + return nil +} + +func roleAdminRecord(view store.RoleAdminView) extension.RoleAdminRecord { + out := extension.RoleAdminRecord{RoleID: view.RoleID, Name: view.Name, Description: view.Description, + Enabled: view.Enabled, RoleVersion: view.RoleVersion, BindingCount: view.BindingCount, + Permissions: map[string]extension.RolePermission{}} + for _, permission := range view.Permissions { + out.Permissions[permission.ActionID] = extension.RolePermission{Allowed: permission.Allowed, CapAllowed: permission.CapAllowed} + } + return out +} + +func (h inprocHost) ListRoles(ctx context.Context) ([]extension.RoleAdminRecord, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if err := h.requireRoleAdminAction("system.roles.view"); err != nil { + return nil, err + } + result := []extension.RoleAdminRecord{} + cursor := "" + for { + roles, next, err := h.caps.st.ListRoles("", "fuzzy", "all", "", cursor, 100) + if err != nil { + return nil, err + } + for _, role := range roles { + result = append(result, roleAdminRecord(role)) + } + if next == "" { + return result, nil + } + cursor = next + } +} + +// ListRoleOptions is the read-only role directory for non-admin dispatch +// actors: enabled roles only, projected as roleId+name, sorted by name then +// roleId. It never requires system.roles.view and never exposes permissions, +// bindings or counts; callers still authorize their own domain action. +func (h inprocHost) ListRoleOptions(ctx context.Context) ([]extension.RoleOption, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if err := h.requireEffective(); err != nil { + return nil, err + } + if _, err := h.rolePolicyActor(); err != nil { + return nil, err + } + result := []extension.RoleOption{} + cursor := "" + for { + roles, next, err := h.caps.st.ListRoles("", "fuzzy", "all", "", cursor, 100) + if err != nil { + return nil, err + } + for _, role := range roles { + if !role.Enabled { + continue + } + result = append(result, extension.RoleOption{RoleID: role.RoleID, Name: role.Name}) + } + if next == "" { + sort.Slice(result, func(i, j int) bool { + if result[i].Name != result[j].Name { + return result[i].Name < result[j].Name + } + return result[i].RoleID < result[j].RoleID + }) + return result, nil + } + cursor = next + } +} + +var _ extension.RoleOptionsProvider = inprocHost{} + +func (h inprocHost) CreateRole(ctx context.Context, name, description string) (extension.RoleAdminRecord, error) { + if err := ctx.Err(); err != nil { + return extension.RoleAdminRecord{}, err + } + if err := h.requireRoleAdminAction("system.roles.manage"); err != nil { + return extension.RoleAdminRecord{}, err + } + role, err := h.caps.st.CreateRole(h.actor, store.RoleCreate{Name: name, Description: description}) + if err != nil { + return extension.RoleAdminRecord{}, err + } + view, err := h.caps.st.RoleAdmin(role.RoleID) + return roleAdminRecord(view), err +} + +func (h inprocHost) SetRolePermission(ctx context.Context, roleID, actionID string, allowed, capAllowed bool, key string) (extension.RoleAdminRecord, error) { + if err := ctx.Err(); err != nil { + return extension.RoleAdminRecord{}, err + } + if err := h.requireRoleAdminAction("system.permissions.manage"); err != nil { + return extension.RoleAdminRecord{}, err + } + view, err := h.caps.st.RoleAdmin(roleID) + if err != nil { + return extension.RoleAdminRecord{}, err + } + changes := make([]store.RolePermissionChange, 0, len(view.Permissions)+1) + found := false + for _, permission := range view.Permissions { + if permission.ActionID == actionID { + found = true + if permission.Allowed == allowed && permission.CapAllowed == capAllowed { + return roleAdminRecord(view), nil + } + permission.Allowed, permission.CapAllowed = allowed, capAllowed + } + changes = append(changes, store.RolePermissionChange{ActionID: permission.ActionID, Allowed: permission.Allowed, CapAllowed: permission.CapAllowed}) + } + if !found { + changes = append(changes, store.RolePermissionChange{ActionID: actionID, Allowed: allowed, CapAllowed: capAllowed}) + } + if key == "" { + return extension.RoleAdminRecord{}, store.ErrInvalidRolePolicy + } + if _, err := h.caps.st.ReplaceRolePermissions(h.actor, roleID, view.RoleVersion, changes, key+":"+strconv.FormatInt(view.RoleVersion, 10)); err != nil { + return extension.RoleAdminRecord{}, err + } + view, err = h.caps.st.RoleAdmin(roleID) + return roleAdminRecord(view), err +} + +func (h inprocHost) AssignRoleByOwner(ctx context.Context, owner, roleID, key string) (extension.RoleBindingResult, error) { + if err := ctx.Err(); err != nil { + return extension.RoleBindingResult{}, err + } + if err := h.requireRoleAdminAction("system.roles.assign"); err != nil { + return extension.RoleBindingResult{}, err + } + target, err := h.caps.st.GetAccountByOwner(owner) + if err != nil { + return extension.RoleBindingResult{}, err + } + current, err := h.caps.st.RoleForUser(target.ID) + if err != nil { + return extension.RoleBindingResult{}, err + } + if current.Binding != nil && current.Binding.RoleID == roleID { + return extension.RoleBindingResult{BindingID: current.Binding.BindingID, UserID: target.ID, RoleID: roleID, + UserStateVersion: current.Binding.UserStateVersion, AuthorityEpoch: current.Binding.AuthorityEpoch}, nil + } + if key == "" { + return extension.RoleBindingResult{}, store.ErrInvalidRolePolicy + } + binding, err := h.caps.st.AssignUserRole(h.actor, target.ID, roleID, target.StateVersion, key+":"+strconv.FormatInt(target.StateVersion, 10)) + if err != nil { + return extension.RoleBindingResult{}, err + } + return extension.RoleBindingResult{BindingID: binding.BindingID, UserID: binding.UserID, RoleID: binding.RoleID, + UserStateVersion: binding.UserStateVersion, AuthorityEpoch: binding.AuthorityEpoch}, nil +} + +func (h inprocHost) SetRoleEnabled(ctx context.Context, roleID string, enabled bool) (extension.RoleAdminRecord, error) { + if err := ctx.Err(); err != nil { + return extension.RoleAdminRecord{}, err + } + if err := h.requireRoleAdminAction("system.roles.manage"); err != nil { + return extension.RoleAdminRecord{}, err + } + view, err := h.caps.st.RoleAdmin(roleID) + if err != nil { + return extension.RoleAdminRecord{}, err + } + if view.Enabled == enabled { + return roleAdminRecord(view), nil + } + role, err := h.caps.st.PatchRole(h.actor, roleID, view.RoleVersion, store.RolePatch{Enabled: &enabled}) + if err != nil { + return extension.RoleAdminRecord{}, err + } + view, err = h.caps.st.RoleAdmin(role.RoleID) + return roleAdminRecord(view), err +} + +func (h inprocHost) ImportRoleAuthority(ctx context.Context, in extension.RoleAuthorityImport, key string) (extension.RoleAuthorityImportResult, error) { + if err := ctx.Err(); err != nil { + return extension.RoleAuthorityImportResult{}, err + } + if err := h.requireRoleAdminAction("system.permissions.manage"); err != nil { + return extension.RoleAuthorityImportResult{}, err + } + roles := make([]store.RoleAuthorityImportRole, 0, len(in.Roles)) + for _, role := range in.Roles { + permissions := make([]store.RolePermissionChange, 0, len(role.Permissions)) + for actionID, permission := range role.Permissions { + permissions = append(permissions, store.RolePermissionChange{ActionID: actionID, + Allowed: permission.Allowed, CapAllowed: permission.CapAllowed}) + } + sort.Slice(permissions, func(i, j int) bool { return permissions[i].ActionID < permissions[j].ActionID }) + roles = append(roles, store.RoleAuthorityImportRole{RoleID: role.RoleID, Name: role.Name, + Description: role.Description, Enabled: role.Enabled, RoleVersion: role.RoleVersion, + Permissions: permissions, Owners: append([]string(nil), role.Owners...)}) + } + result, err := h.caps.st.ImportRoleAuthority(h.actor, store.RoleAuthorityImport{ + SourceSchemaVersion: in.SourceSchemaVersion, SourceSHA256: in.SourceSHA256, Roles: roles}, key) + if err != nil { + return extension.RoleAuthorityImportResult{}, err + } + return extension.RoleAuthorityImportResult{AuthorityEpoch: result.AuthorityEpoch, + ImportedRoles: result.ImportedRoles, ImportedUsers: result.ImportedUsers}, nil +} + +func (h inprocHost) ReadRoleAuthorityImport(ctx context.Context, sourceSHA string) (extension.RoleAuthorityImportProjection, error) { + if err := ctx.Err(); err != nil { + return extension.RoleAuthorityImportProjection{}, err + } + if err := h.requireRoleAdminAction("system.permissions.manage"); err != nil { + return extension.RoleAuthorityImportProjection{}, err + } + staged, err := h.caps.st.ReadRoleAuthorityImport(h.actor, sourceSHA) + if err != nil { + return extension.RoleAuthorityImportProjection{}, err + } + roles := make([]extension.RoleAdminRecord, 0, len(staged.Roles)) + bindings := map[string]string{} + for _, role := range staged.Roles { + permissions := map[string]extension.RolePermission{} + for _, permission := range role.Permissions { + permissions[permission.ActionID] = extension.RolePermission{Allowed: permission.Allowed, CapAllowed: permission.CapAllowed} + } + roles = append(roles, extension.RoleAdminRecord{RoleID: role.RoleID, Name: role.Name, + Description: role.Description, Enabled: role.Enabled, RoleVersion: role.RoleVersion, + BindingCount: len(role.Owners), Permissions: permissions}) + for _, owner := range role.Owners { + bindings[owner] = role.RoleID + } + } + epoch, err := h.caps.st.AuthorityEpoch() + if err != nil { + return extension.RoleAuthorityImportProjection{}, err + } + return extension.RoleAuthorityImportProjection{SourceSHA256: staged.SourceSHA256, + AuthorityEpoch: epoch, Roles: roles, Bindings: bindings}, nil +} + +func (h inprocHost) ActivateRoleAuthorityImport(ctx context.Context, sourceSHA string, expectedEpoch int64) (extension.RoleAuthorityImportResult, error) { + if err := ctx.Err(); err != nil { + return extension.RoleAuthorityImportResult{}, err + } + if err := h.requireRoleAdminAction("system.permissions.manage"); err != nil { + return extension.RoleAuthorityImportResult{}, err + } + result, err := h.caps.st.ActivateRoleAuthorityImport(h.actor, sourceSHA, expectedEpoch) + if err != nil { + return extension.RoleAuthorityImportResult{}, err + } + return extension.RoleAuthorityImportResult{AuthorityEpoch: result.AuthorityEpoch, + ImportedRoles: result.ImportedRoles, ImportedUsers: result.ImportedUsers}, nil +} + func (h inprocHost) KVPut(key, value string) error { - return h.caps.st.PutPluginKV(h.pluginID, h.owner, key, value) + if err := h.requireEffective(); err != nil { + return err + } + return h.caps.st.PutPluginKVAccountChecked(h.owner, h.pluginID, key, value, h.caps.allowImplicitLocal) } func (h inprocHost) KVGet(key string) (string, bool, error) { + if err := h.requireEffective(); err != nil { + return "", false, err + } return h.caps.st.GetPluginKV(h.pluginID, h.owner, key) } func (h inprocHost) Audit(action, target, evidence string) error { + if err := h.requireEffective(); err != nil { + return err + } if strings.HasPrefix(action, "model.context_manifest") { return fmt.Errorf("extension: model context manifest is a reserved host receipt") } - return h.caps.st.AppendAuditEvent(store.AuditEvent{ + return h.caps.st.AppendAuditEventAccountChecked(h.owner, store.AuditEvent{ Owner: h.owner, Actor: "plugin:" + h.pluginID, Action: action, Target: target, Evidence: evidence, - }) + }, h.caps.allowImplicitLocal) } func (h inprocHost) Complete(ctx context.Context, prompt string) (string, error) { + if err := h.requireEffective(); err != nil { + return "", err + } return h.caps.complete(ctx, h.owner, prompt) } func (h inprocHost) KnowledgeSearch(ctx context.Context, projectID core.ID, text string, topK int) ([]core.RetrievalHit, error) { + if err := h.requireEffective(); err != nil { + return nil, err + } return h.caps.search(ctx, h.owner, projectID, text, topK) } @@ -309,6 +845,9 @@ func (h inprocHost) knowledgeSource(source string) error { // "plugin:";embed=false 只写全文行,embed=true 走 IndexChunks 建向量 // (embedding 失败降级纯全文,不阻断写入)。 func (h inprocHost) KnowledgeWrite(ctx context.Context, docs []core.KnowledgeDoc, embed bool) error { + if err := h.requireEffective(); err != nil { + return err + } if len(docs) == 0 { return nil } @@ -323,20 +862,21 @@ func (h inprocHost) KnowledgeWrite(ctx context.Context, docs []core.KnowledgeDoc } } if !embed { - return h.caps.st.AddKnowledge(docs...) + return h.caps.st.AddKnowledgeAccountChecked(h.owner, h.caps.allowImplicitLocal, docs...) } - return h.caps.pipe.IndexChunks(ctx, core.ID(h.owner), docs) + return h.caps.pipe.IndexChunksAccountChecked(ctx, core.ID(h.owner), docs, h.caps.allowImplicitLocal) } // KnowledgePublish replaces refs and their vectors within the publication scope. // A registered lifecycle scopes replacement to the exact owner/project/plugin; // ordinary unregistered Hosts retain the legacy cross-source ref semantics. func (h inprocHost) KnowledgePublish(ctx context.Context, docs []core.KnowledgeDoc, embed bool) error { + if err := h.requireEffective(); err != nil { + return err + } if len(docs) == 0 { return nil } - type publicationRef struct{ projectID, ref string } - refs := map[publicationRef]bool{} for i := range docs { docs[i].Owner = core.ID(h.owner) if docs[i].Source == "" { @@ -345,39 +885,33 @@ func (h inprocHost) KnowledgePublish(ctx context.Context, docs []core.KnowledgeD if err := h.knowledgeSource(docs[i].Source); err != nil { return err } - if docs[i].Ref != "" { - refs[publicationRef{docs[i].ProjectID, docs[i].Ref}] = true - } } scoped := h.caps.projects != nil && h.caps.projects.active() - for ref := range refs { - var err error - if scoped { - err = h.caps.st.DeletePluginProjectKnowledgeByRef(ctx, h.owner, ref.projectID, ref.ref, h.pluginID) - } else { - err = h.caps.st.DeleteProjectKnowledgeByRef(ctx, h.owner, ref.projectID, ref.ref) - } - if err != nil { - return err - } + if embed { + return h.caps.pipe.PublishChunksAccountChecked(ctx, core.ID(h.owner), h.pluginID, scoped, docs, h.caps.allowImplicitLocal) } - - return h.KnowledgeWrite(ctx, docs, embed) + return h.caps.st.ReplaceKnowledgeAccountChecked(ctx, h.owner, h.pluginID, scoped, h.caps.allowImplicitLocal, docs...) } // KnowledgeDelete 撤销本插件来源(EXT-9):前缀强制 plugin:<自身ID>——manual // 或他人来源拒绝(fail-closed);删行+向量,幂等。 func (h inprocHost) KnowledgeDelete(ctx context.Context, sourcePrefix string) error { + if err := h.requireEffective(); err != nil { + return err + } if sourcePrefix != "plugin:"+h.pluginID { return fmt.Errorf("ext-9: plugins may only delete their own source prefix, want %q", "plugin:"+h.pluginID) } - _, err := h.caps.st.DeleteKnowledgeBySource(ctx, h.owner, sourcePrefix) + _, err := h.caps.st.DeleteKnowledgeBySourceAccountChecked(ctx, h.owner, sourcePrefix, h.caps.allowImplicitLocal) return err } // TaskCreate/TaskUpdate 后台任务台账:基座只记进度/状态,执行体由下游自跑(H5)。 func (h inprocHost) TaskCreate(kind, title, conversationID string) (string, error) { - t, err := h.caps.st.CreateBackgroundTask(h.owner, kind, title, conversationID) + if err := h.requireEffective(); err != nil { + return "", err + } + t, err := h.caps.st.CreateBackgroundTaskAccountChecked(h.owner, kind, title, conversationID, h.caps.allowImplicitLocal) if err != nil { return "", err } @@ -385,27 +919,42 @@ func (h inprocHost) TaskCreate(kind, title, conversationID string) (string, erro } func (h inprocHost) TaskUpdate(taskID, status string, progress int, errText string) error { - return h.caps.st.UpdateBackgroundTask(h.owner, taskID, status, progress, errText) + if err := h.requireEffective(); err != nil { + return err + } + return h.caps.st.UpdateBackgroundTaskAccountChecked(h.owner, taskID, status, progress, errText, h.caps.allowImplicitLocal) } // ArtifactGet/ArtifactContent 工件只读(R1-3 ingest 读原件;写入属完整集)。 func (h inprocHost) ArtifactGet(artifactID string) (core.Artifact, error) { + if err := h.requireEffective(); err != nil { + return core.Artifact{}, err + } return h.caps.st.GetArtifact(h.owner, artifactID) } func (h inprocHost) ArtifactContent(artifactID string) ([]byte, error) { + if err := h.requireEffective(); err != nil { + return nil, err + } return h.caps.st.ArtifactContent(h.owner, artifactID) } // ParseDocument 内置文档解析(EXT-5):委托 hostcaps.parse;纯计算不经存储, // owner 作用域由调用方经 ArtifactContent 取字节时保证。 func (h inprocHost) ParseDocument(ctx context.Context, filename, mime string, content []byte) (core.ParsedDocument, error) { + if err := h.requireEffective(); err != nil { + return core.ParsedDocument{}, err + } return h.caps.parse(ctx, filename, mime, content) } // ---- EXT-17 Surface 能力(可选):nil 槽=能力缺失,明确拒绝不降级 ---- func (h inprocHost) surface() (*surfacesvc.Service, error) { + if err := h.requireEffective(); err != nil { + return nil, err + } if h.caps.surfaces == nil { return nil, surfacesvc.ErrUnavailable } @@ -417,11 +966,11 @@ func (h inprocHost) SurfaceSave(ctx context.Context, req extension.SurfaceSaveRe if err != nil { return core.SurfaceSnapshot{}, err } - return svc.Submit(ctx, h.owner, h.pluginID, surfacesvc.SubmitRequest{ + return svc.SubmitAccountChecked(ctx, h.owner, h.pluginID, surfacesvc.SubmitRequest{ SurfaceID: req.SurfaceID, ConversationID: req.ConversationID, SourceRunID: req.SourceRunID, BaseRevision: req.BaseRevision, ObservedEpoch: req.ObservedEpoch, ContextVersion: req.ContextVersion, SemanticText: req.SemanticText, Spec: req.Spec, - }) + }, h.caps.allowImplicitLocal) } func (h inprocHost) SurfaceGet(ctx context.Context, surfaceID core.ID) (core.SurfaceSnapshot, error) { diff --git a/internal/app/hostcaps_test.go b/internal/app/hostcaps_test.go new file mode 100644 index 0000000..ee94b3f --- /dev/null +++ b/internal/app/hostcaps_test.go @@ -0,0 +1,97 @@ +package app + +import ( + "context" + "errors" + "testing" + + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" +) + +func TestCapabilityExplainProviderExplainsBoundActorOnly(t *testing.T) { + st, err := store.OpenSQLite(t.TempDir() + "/capability-explain.db") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + if err := st.CreateAccount("admin", "Admin", "hash", true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("alice", "Alice", "hash", false); err != nil { + t.Fatal(err) + } + alice, _ := st.GetAccountByOwner("alice") + role, err := st.CreateRole("admin", store.RoleCreate{Name: "项目负责人"}) + if err != nil { + t.Fatal(err) + } + if _, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, []store.RolePermissionChange{ + {ActionID: "project.create", Allowed: true, CapAllowed: true}, + {ActionID: "document.read", Allowed: true, CapAllowed: true}, + {ActionID: "document.review", Allowed: false, CapAllowed: true}, + }, "explain-provider"); err != nil { + t.Fatal(err) + } + if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "explain-provider-assign"); err != nil { + t.Fatal(err) + } + h := inprocHost{caps: hostcaps{st: st}, pluginID: "rdm", owner: "alice", actor: "alice"} + provider, ok := any(h).(extension.CapabilityExplainProvider) + if !ok { + t.Fatal("dispatch host missing CapabilityExplainProvider") + } + ctx := context.Background() + capability, err := provider.CurrentCapabilityExplain(ctx) + if err != nil { + t.Fatal(err) + } + if capability.SchemaVersion != extension.CapabilityExplainSchemaVersion || capability.UserID != alice.ID || capability.Owner != "alice" || + capability.RoleID != role.RoleID || !capability.ProjectCreate.Allowed || capability.ProjectCreate.Source != extension.CapabilitySourceRole || + len(capability.Linked) != 4 || len(capability.Conflicts) != 0 { + t.Fatalf("capability=%+v", capability) + } + snapshot, err := any(h).(extension.RolePolicyProvider).CurrentRolePolicy(ctx) + if err != nil || snapshot.Linked.Source != capability.ProjectCreate.Source || len(snapshot.Linked.Actions) != len(capability.Linked) { + t.Fatalf("snapshot linked=%+v err=%v", snapshot.Linked, err) + } + member, err := provider.CurrentMemberBasicCollaborate(ctx) + if err != nil || member.UserID != alice.ID || len(member.Actions) == 0 { + t.Fatalf("member=%+v err=%v", member, err) + } + for _, action := range member.Actions { + if action.ActionID == "document.read" && (!action.Allowed || action.Source != extension.CapabilitySourceRole) { + t.Fatalf("document.read=%+v", action) + } + } + document, err := provider.CurrentDocumentQualify(ctx) + if err != nil || document.UserID != alice.ID || len(document.Duties) != 3 { + t.Fatalf("document=%+v err=%v", document, err) + } + for _, duty := range document.Duties { + if duty.ActionID == "document.review" && (!duty.Qualified || duty.DefaultAllowed) { + t.Fatalf("document.review=%+v", duty) + } + } + + // A scoped Host without a dispatch actor cannot explain anyone. + scoped := any(inprocHost{caps: hostcaps{st: st}, pluginID: "rdm", owner: "alice"}).(extension.CapabilityExplainProvider) + if _, err := scoped.CurrentCapabilityExplain(ctx); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("scoped capability: %v", err) + } + if _, err := scoped.CurrentMemberBasicCollaborate(ctx); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("scoped member: %v", err) + } + if _, err := scoped.CurrentDocumentQualify(ctx); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("scoped document: %v", err) + } + ghost := any(inprocHost{caps: hostcaps{st: st}, pluginID: "rdm", owner: "ghost", actor: "ghost"}).(extension.CapabilityExplainProvider) + if _, err := ghost.CurrentCapabilityExplain(ctx); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("unknown actor: %v", err) + } + canceled, cancel := context.WithCancel(ctx) + cancel() + if _, err := provider.CurrentCapabilityExplain(canceled); !errors.Is(err, context.Canceled) { + t.Fatalf("canceled: %v", err) + } +} diff --git a/internal/app/human_decision.go b/internal/app/human_decision.go new file mode 100644 index 0000000..22e01a4 --- /dev/null +++ b/internal/app/human_decision.go @@ -0,0 +1,104 @@ +package app + +import ( + "fmt" + "time" + + "agent-system-backend/internal/cmdbus" + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" +) + +func (h inprocHost) clock() time.Time { + if h.caps.now != nil { + return h.caps.now() + } + return time.Now() +} + +func humanCommand(audience string) (string, error) { + if audience == extension.HumanDecisionAudienceStructure { + return "rdm.structure.decide", nil + } + return "", fmt.Errorf("extension: human decision audience is not issued for this host") +} + +func humanEntranceAllowed(entrance string) bool { + switch entrance { + case "button", "card", "api": + return true + default: + return false + } +} + +// IssueHumanDecision opens a pending card for the authenticated person. +// Model-tool, chat, and slash entrances cannot open one. +func (h inprocHost) IssueHumanDecision(binding extension.HumanDecisionBinding, question string) (extension.HumanDecisionCard, error) { + if h.caps.st == nil || h.actor == "" || h.actor != binding.Actor || !humanEntranceAllowed(h.entrance) { + return extension.HumanDecisionCard{}, fmt.Errorf("extension: human decision cannot be issued on this dispatch") + } + commandID, err := humanCommand(binding.Audience) + if err != nil { + return extension.HumanDecisionCard{}, err + } + if binding.ProjectID == "" || binding.ProposalID == "" || binding.ProposalRevision < 1 || binding.PayloadHash == "" { + return extension.HumanDecisionCard{}, fmt.Errorf("extension: human decision binding is incomplete") + } + rec, err := h.caps.st.IssueHumanDecision(store.HumanDecision{ + PluginID: h.pluginID, Audience: binding.Audience, CommandID: commandID, + HumanActor: binding.Actor, Owner: h.owner, ProjectID: binding.ProjectID, + ProposalID: binding.ProposalID, ProposalRevision: binding.ProposalRevision, + PayloadHash: binding.PayloadHash, Question: question, ConversationID: string(h.conversationID), + }, h.clock()) + if err != nil { + return extension.HumanDecisionCard{}, err + } + return extension.HumanDecisionCard{CardID: rec.ID, Question: rec.Question, ExpiresAt: rec.ExpiresAt}, nil +} + +// VerifyHumanDecision reports the persisted human response for this dispatch. +// A model tool, a forged entrance, a mismatched binding, an expired card, or +// a revoked card fails closed. The record stays answered; it is not consumed. +func (h inprocHost) VerifyHumanDecision(expected extension.HumanDecisionBinding) (extension.HumanDecisionProof, error) { + if h.caps.st == nil || h.humanGrant == nil || h.entrance == "agent" || h.actor == "" || h.actor != h.humanGrant.Actor { + return extension.HumanDecisionProof{}, fmt.Errorf("extension: this dispatch has no authenticated human decision") + } + rec, err := h.caps.st.HumanDecision(h.humanGrant.CardID) + if err != nil { + return extension.HumanDecisionProof{}, fmt.Errorf("extension: human decision card is not pending for this dispatch") + } + now := h.clock() + if rec.Status != "responded" || rec.PluginID != h.pluginID || rec.ProofID == "" || rec.ProofID != h.humanGrant.ProofID || + rec.HumanActor != expected.Actor || rec.HumanActor != h.actor || rec.Decision != expected.Decision || + rec.Audience != expected.Audience || rec.ProjectID != expected.ProjectID || rec.ProposalID != expected.ProposalID || + rec.ProposalRevision != expected.ProposalRevision || rec.PayloadHash != expected.PayloadHash || + !now.Before(rec.ExpiresAt) { + return extension.HumanDecisionProof{}, fmt.Errorf("extension: human decision does not match this dispatch") + } + return extension.HumanDecisionProof{ + ProofID: rec.ProofID, Issuer: "agent-system-backend", HumanActor: rec.HumanActor, + Audience: rec.Audience, ProjectID: rec.ProjectID, ProposalID: rec.ProposalID, + Revision: rec.ProposalRevision, PayloadHash: rec.PayloadHash, Decision: rec.Decision, + IssuedAt: rec.IssuedAt, ExpiresAt: rec.ExpiresAt, ConversationID: rec.ConversationID, + HITLResponseRef: rec.ID, + }, nil +} + +// RevokeHumanDecision invalidates a still-pending card. Answered proofs stay +// available until they expire; consumption is not a host concern. +func (h inprocHost) RevokeHumanDecision(cardID string) error { + if h.caps.st == nil || h.actor == "" || !humanEntranceAllowed(h.entrance) { + return fmt.Errorf("extension: human decision cannot be revoked on this dispatch") + } + return h.caps.st.RevokeHumanDecision(h.pluginID, h.actor, cardID) +} + +// HumanGrantFromRecord copies the persisted response onto a dispatch. +// Callers must set it only after an authenticated response or session retry. +func HumanGrantFromRecord(rec store.HumanDecision) *cmdbus.HumanGrant { + if rec.ProofID == "" || rec.Status != "responded" { + return nil + } + return &cmdbus.HumanGrant{CardID: rec.ID, ProofID: rec.ProofID, Actor: rec.HumanActor, Decision: rec.Decision} +} diff --git a/internal/app/human_decision_test.go b/internal/app/human_decision_test.go new file mode 100644 index 0000000..064fc88 --- /dev/null +++ b/internal/app/human_decision_test.go @@ -0,0 +1,170 @@ +package app + +import ( + "testing" + "time" + + "agent-system-backend/internal/cmdbus" + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" +) + +func openDecisionStore(t *testing.T) *store.Store { + t.Helper() + st, err := store.OpenSQLite(t.TempDir() + "/decision.db") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + return st +} + +func decisionHost(st *store.Store, plugin, actor, entrance string, now time.Time, grant *cmdbus.HumanGrant) inprocHost { + return inprocHost{ + caps: hostcaps{st: st, now: func() time.Time { return now }}, + pluginID: plugin, owner: actor, actor: actor, entrance: entrance, humanGrant: grant, + } +} + +func binding(decision, hash string, revision int) extension.HumanDecisionBinding { + return extension.HumanDecisionBinding{ + Audience: extension.HumanDecisionAudienceStructure, Actor: "alice", ProjectID: "p1", + ProposalID: "prop-1", ProposalRevision: revision, PayloadHash: hash, Decision: decision, + } +} + +func TestHumanDecisionAuthenticatedResponse(t *testing.T) { + st := openDecisionStore(t) + now := time.Date(2026, 10, 15, 1, 0, 0, 0, time.UTC) + host := decisionHost(st, "rdm", "alice", "button", now, nil) + card, err := host.IssueHumanDecision(binding("", "hash-1", 1), "确认结构变更") + if err != nil { + t.Fatal(err) + } + answered, err := st.RespondHumanDecision("alice", card.CardID, "accept", now) + if err != nil { + t.Fatal(err) + } + host.humanGrant = &cmdbus.HumanGrant{CardID: answered.ID, ProofID: answered.ProofID, Actor: "alice", Decision: "accept"} + host.entrance = "card" + proof, err := host.VerifyHumanDecision(binding("accept", "hash-1", 1)) + if err != nil { + t.Fatal(err) + } + if proof.ProofID == "" || proof.Issuer != "agent-system-backend" || proof.HumanActor != "alice" || + proof.HITLResponseRef != card.CardID || proof.PayloadHash != "hash-1" { + t.Fatalf("proof = %+v", proof) + } + again, err := host.VerifyHumanDecision(binding("accept", "hash-1", 1)) + if err != nil || again.ProofID != proof.ProofID { + t.Fatalf("second verify consumed or changed the proof: %v %+v", err, again) + } + stored, err := st.HumanDecision(card.CardID) + if err != nil || stored.Status != "responded" { + t.Fatalf("host consumed the proof, status=%s err=%v", stored.Status, err) + } +} + +func TestHumanDecisionRejectsUntrustedDispatch(t *testing.T) { + st := openDecisionStore(t) + now := time.Date(2026, 10, 15, 1, 0, 0, 0, time.UTC) + host := decisionHost(st, "rdm", "alice", "button", now, nil) + card, err := host.IssueHumanDecision(binding("", "hash-1", 1), "确认") + if err != nil { + t.Fatal(err) + } + answered, err := st.RespondHumanDecision("alice", card.CardID, "accept", now) + if err != nil { + t.Fatal(err) + } + grant := &cmdbus.HumanGrant{CardID: answered.ID, ProofID: answered.ProofID, Actor: "alice", Decision: "accept"} + expect := binding("accept", "hash-1", 1) + + agent := decisionHost(st, "rdm", "alice", "agent", now, grant) + if _, err := agent.VerifyHumanDecision(expect); err == nil { + t.Fatal("model tool dispatch verified a human decision") + } + forged := decisionHost(st, "rdm", "alice", "card", now, nil) + if _, err := forged.VerifyHumanDecision(expect); err == nil { + t.Fatal("forged entrance verified without a host grant") + } + if _, err := decisionHost(st, "rdm", "alice", "agent", now, nil).IssueHumanDecision(binding("", "hash-2", 1), "x"); err == nil { + t.Fatal("model tool issued a human decision card") + } + if _, err := decisionHost(st, "other", "alice", "card", now, grant).VerifyHumanDecision(expect); err == nil { + t.Fatal("another plugin reused the proof") + } + other := expect + other.ProposalID = "prop-2" + if _, err := decisionHost(st, "rdm", "alice", "card", now, grant).VerifyHumanDecision(other); err == nil { + t.Fatal("proof authorized a second proposal") + } + wrongActor := expect + wrongActor.Actor = "bob" + if _, err := decisionHost(st, "rdm", "bob", "card", now, grant).VerifyHumanDecision(wrongActor); err == nil { + t.Fatal("wrong actor verified") + } + wrongProject := expect + wrongProject.ProjectID = "p2" + if _, err := decisionHost(st, "rdm", "alice", "card", now, grant).VerifyHumanDecision(wrongProject); err == nil { + t.Fatal("wrong project verified") + } + wrongRev := expect + wrongRev.ProposalRevision = 2 + if _, err := decisionHost(st, "rdm", "alice", "card", now, grant).VerifyHumanDecision(wrongRev); err == nil { + t.Fatal("wrong revision verified") + } + wrongHash := expect + wrongHash.PayloadHash = "hash-changed" + if _, err := decisionHost(st, "rdm", "alice", "card", now, grant).VerifyHumanDecision(wrongHash); err == nil { + t.Fatal("wrong payload hash verified") + } + if _, err := decisionHost(st, "rdm", "alice", "button", now, nil).IssueHumanDecision( + extension.HumanDecisionBinding{Audience: "other.plugin", Actor: "alice", ProjectID: "p1", ProposalID: "prop-1", ProposalRevision: 1, PayloadHash: "h"}, + "x"); err == nil { + t.Fatal("foreign audience was issued") + } +} + +func TestHumanDecisionCardChangeExpiryAndRevoke(t *testing.T) { + st := openDecisionStore(t) + now := time.Date(2026, 10, 15, 1, 0, 0, 0, time.UTC) + host := decisionHost(st, "rdm", "alice", "api", now, nil) + first, err := host.IssueHumanDecision(binding("", "hash-1", 1), "旧卡") + if err != nil { + t.Fatal(err) + } + if _, err := host.IssueHumanDecision(binding("", "hash-2", 2), "新卡"); err != nil { + t.Fatal(err) + } + if _, err := st.RespondHumanDecision("alice", first.CardID, "accept", now); err == nil { + t.Fatal("changed card still accepted the old confirmation") + } + current, err := host.IssueHumanDecision(binding("", "hash-3", 3), "待撤销") + if err != nil { + t.Fatal(err) + } + if err := host.RevokeHumanDecision(current.CardID); err != nil { + t.Fatal(err) + } + if _, err := st.RespondHumanDecision("alice", current.CardID, "accept", now); err == nil { + t.Fatal("revoked card was answered") + } + live, err := host.IssueHumanDecision(binding("", "hash-4", 4), "会过期") + if err != nil { + t.Fatal(err) + } + if _, err := st.RespondHumanDecision("alice", live.CardID, "accept", now.Add(16*time.Minute)); err == nil { + t.Fatal("expired card was answered") + } +} + +func TestMissingHumanDecisionStoreFailsClosed(t *testing.T) { + host := decisionHost(nil, "rdm", "alice", "button", time.Now(), nil) + if _, err := host.IssueHumanDecision(binding("", "hash-1", 1), "x"); err == nil { + t.Fatal("missing store issued a card") + } + if _, err := host.VerifyHumanDecision(binding("accept", "hash-1", 1)); err == nil { + t.Fatal("missing store verified a decision") + } +} diff --git a/internal/app/inproc_host_test.go b/internal/app/inproc_host_test.go index acb6a39..853b42a 100644 --- a/internal/app/inproc_host_test.go +++ b/internal/app/inproc_host_test.go @@ -78,6 +78,9 @@ func TestInprocHostOwnerScope(t *testing.T) { bus, st := newInprocTestApp(t, nil) // embedder nil:embed=true 仍只落全文(IndexChunks 降级路径) for _, owner := range []string{"alice", "bob"} { + if err := st.EnsureUser(owner, ""); err != nil { + t.Fatal(err) + } resp := bus.Dispatch(context.Background(), cmdbus.Request{ ID: "tst.record", Params: map[string]any{"ref": owner + "-ref"}, Owner: owner, Actor: "ui", Entrance: "chat", }) @@ -157,6 +160,9 @@ func TestInprocHostKnowledgeWriteEmbedHash(t *testing.T) { }, }) bus, st := newInprocTestApp(t, func() string { return "hash" }) + if err := st.EnsureUser("alice", ""); err != nil { + t.Fatal(err) + } resp := bus.Dispatch(context.Background(), cmdbus.Request{ ID: "tst.embed", Params: map[string]any{"text": "Hash 向量内容"}, Owner: "alice", Entrance: "api", diff --git a/internal/app/knowledge_authorization_test.go b/internal/app/knowledge_authorization_test.go index e28dff3..967e667 100644 --- a/internal/app/knowledge_authorization_test.go +++ b/internal/app/knowledge_authorization_test.go @@ -16,6 +16,11 @@ func TestHostKnowledgeV2AndProjectReplacement(t *testing.T) { t.Fatal(err) } defer st.Close() + for _, owner := range []string{"alice", "bob"} { + if err := st.EnsureUser(owner, ""); err != nil { + t.Fatal(err) + } + } pipe := rag.NewPipeline(st, settings.NewService(st), nil, nil) auth := &projectAuthz{} alice := inprocHost{owner: "alice", pluginID: "rdm", caps: hostcaps{st: st, pipe: pipe, authz: auth}} @@ -76,6 +81,9 @@ func TestRegisteredProjectHostRejectsForeignKnowledgeSource(t *testing.T) { t.Fatal(err) } defer st.Close() + if err := st.EnsureUser("alice", ""); err != nil { + t.Fatal(err) + } slot := &projectLifecycleSlot{} caps := hostcaps{st: st, projects: slot} if err := (pluginRegistry{pluginID: "rdm", caps: caps}).RegisterProjectLifecycle(completeLifecycle()); err != nil { @@ -110,6 +118,9 @@ func TestProjectPublishPreservesForeignPluginAndScope(t *testing.T) { t.Fatal(err) } defer st.Close() + if err := st.EnsureUser("alice", ""); err != nil { + t.Fatal(err) + } slot := &projectLifecycleSlot{} caps := hostcaps{st: st, projects: slot} if err := (pluginRegistry{pluginID: "rdm", caps: caps}).RegisterProjectLifecycle(completeLifecycle()); err != nil { @@ -150,6 +161,9 @@ func TestUnregisteredPublishRetainsLegacyRefScope(t *testing.T) { t.Fatal(err) } defer st.Close() + if err := st.EnsureUser("alice", ""); err != nil { + t.Fatal(err) + } if err := st.AddKnowledge(core.KnowledgeDoc{ID: "legacy", Owner: "alice", ProjectID: "old-project", Source: "manual", Ref: "same", Chunk: "legacyneedle"}); err != nil { t.Fatal(err) } diff --git a/internal/app/local_account_test.go b/internal/app/local_account_test.go new file mode 100644 index 0000000..51da6d4 --- /dev/null +++ b/internal/app/local_account_test.go @@ -0,0 +1,98 @@ +package app + +import ( + "errors" + "path/filepath" + "testing" + + "agent-system-backend/internal/store" +) + +func TestLocalOwnerAccountCreationAndReissuePreserveIdentity(t *testing.T) { + dir := t.TempDir() + seed, err := store.OpenSQLite(filepath.Join(dir, "agent-system.db")) + if err != nil { + t.Fatal(err) + } + if err := seed.EnsureSystemIdentity("test-local-owner", "sqlite"); err != nil { + t.Fatal(err) + } + bootstrap, err := seed.BootstrapFirstAdmin("root", "root", "test") + if err != nil || bootstrap == "" { + t.Fatalf("bootstrap: %v", err) + } + seed.Close() + first, _, err := CreateUserAccount("", dir, "alice", "", "", false, true, false) + if err != nil || first == "" { + t.Fatalf("local pending create: %v", err) + } + st, err := store.OpenExistingSQLite(filepath.Join(dir, "agent-system.db")) + if err != nil { + t.Fatal(err) + } + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + st.Close() + second, owner, err := InitializeAccount("", dir, account.ID, account.StateVersion, account.InitializationGeneration) + if err != nil || owner != "alice" || second == first { + t.Fatalf("owner reissue: owner=%s err=%v", owner, err) + } + st, err = store.OpenExistingSQLite(filepath.Join(dir, "agent-system.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + current, err := st.GetAccount(account.ID) + if err != nil || current.Owner != account.Owner || current.ID != account.ID || current.InitializationGeneration != account.InitializationGeneration+1 { + t.Fatalf("reissue changed account identity: %+v %v", current, err) + } + if _, _, err := st.RedeemLoginKey(first); err == nil { + t.Fatal("old activation grant survived reissue") + } + if _, _, err := st.RedeemLoginKey(second); err != nil { + t.Fatalf("new activation grant failed: %v", err) + } +} + +func TestAPIKeyCLIRequiresExistingInitializedAccount(t *testing.T) { + dir := t.TempDir() + st, err := store.OpenSQLite(filepath.Join(dir, "agent-system.db")) + if err != nil { + t.Fatal(err) + } + if err := st.EnsureSystemIdentity("test-api-key-admission", "sqlite"); err != nil { + t.Fatal(err) + } + if _, err := CreateAPIKey("", dir, "alice", "integration"); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("missing account was implicitly created: %v", err) + } + grant, err := st.BootstrapFirstAdmin("alice", "alice", "test") + if err != nil { + t.Fatal(err) + } + st.Close() + if _, err := CreateAPIKey("", dir, "alice", "integration"); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("pending account received a full API key: %v", err) + } + st, err = store.OpenExistingSQLite(filepath.Join(dir, "agent-system.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + token, _, err := st.RedeemLoginKey(grant) + if err != nil { + t.Fatal(err) + } + hash, err := store.HashPassword("trusted-owner-password") + if err != nil { + t.Fatal(err) + } + if _, err := st.CompleteSetPasswordChecked(token, hash); err != nil { + t.Fatal(err) + } + if key, err := CreateAPIKey("", dir, "alice", "integration"); err != nil || key == "" { + t.Fatalf("initialized account could not receive its API key: %v", err) + } +} diff --git a/internal/app/project_audit.go b/internal/app/project_audit.go new file mode 100644 index 0000000..43a7183 --- /dev/null +++ b/internal/app/project_audit.go @@ -0,0 +1,336 @@ +package app + +import ( + "context" + "errors" + "sync" + + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" + core "agent-system-core" +) + +// projectAuditSlot binds the audit.read policy to its registering domain plugin. +// A different plugin cannot borrow that policy through its own Host handle. +type projectAuditSlot struct { + mu sync.RWMutex + holder string + check extension.ProjectAuditAuthorizer + verifier extension.ProjectAuditLinkVerifier + disclose extension.ProjectAuditDisclosureAuthorizer + publisherToken *auditPublisherToken +} + +// This identity is available only to the publisher factory returned at +// registration. A caller-provided pluginID cannot manufacture the pointer. +type auditPublisherToken struct{ marker byte } + +type projectAuditPublisherFactory struct { + caps hostcaps + pluginID string + token *auditPublisherToken +} + +func (f projectAuditPublisherFactory) ForOwner(owner string) (extension.ProjectAuditPublisher, error) { + if owner == "" || f.token == nil { + return nil, extension.ErrProjectAuditDenied + } + return projectAuditPublisher{caps: f.caps, owner: owner, pluginID: f.pluginID, token: f.token}, nil +} + +type projectAuditPublisher struct { + caps hostcaps + owner string + pluginID string + token *auditPublisherToken +} + +func (p projectAuditPublisher) PublishProjectAuditLink(ctx context.Context, req extension.ProjectAuditLinkRequest) (extension.ProjectAuditLinkReceipt, error) { + h := inprocHost{caps: p.caps, owner: p.owner, pluginID: p.pluginID} + return h.publishProjectAuditLink(ctx, req, p.token) +} + +func (s *projectAuditSlot) register(pluginID string, check extension.ProjectAuditAuthorizer) error { + if pluginID == "" || check == nil { + return extension.ErrProjectAuditUnsupported + } + s.mu.Lock() + defer s.mu.Unlock() + if s.check != nil || (s.holder != "" && s.holder != pluginID) { + return errors.New("extension: project audit authorizer already registered") + } + s.holder, s.check = pluginID, check + return nil +} + +func (s *projectAuditSlot) registerVerifier(pluginID string, verify extension.ProjectAuditLinkVerifier) (*auditPublisherToken, error) { + if pluginID == "" || verify == nil { + return nil, extension.ErrProjectAuditUnsupported + } + s.mu.Lock() + defer s.mu.Unlock() + if s.verifier != nil || (s.holder != "" && s.holder != pluginID) { + return nil, errors.New("extension: project audit verifier already registered") + } + s.holder, s.verifier = pluginID, verify + s.publisherToken = &auditPublisherToken{marker: 1} + return s.publisherToken, nil +} + +func (s *projectAuditSlot) registerDisclosure(pluginID string, disclose extension.ProjectAuditDisclosureAuthorizer) error { + if pluginID == "" || disclose == nil { + return extension.ErrProjectAuditUnsupported + } + s.mu.Lock() + defer s.mu.Unlock() + if s.disclose != nil || (s.holder != "" && s.holder != pluginID) { + return errors.New("extension: project audit disclosure authorizer already registered") + } + s.holder, s.disclose = pluginID, disclose + return nil +} + +func (r compiledRegistry) RegisterProjectAuditAuthorizer(extension.ProjectAuditAuthorizer) error { + return errors.New("extension: declarative plugins cannot register project audit authorizers") +} + +func (r pluginRegistry) RegisterProjectAuditAuthorizer(check extension.ProjectAuditAuthorizer) error { + if r.caps.projectAudit == nil { + return extension.ErrProjectAuditUnsupported + } + return r.caps.projectAudit.register(r.pluginID, check) +} + +func (r recordRegistry) RegisterProjectAuditAuthorizer(check extension.ProjectAuditAuthorizer) error { + reg, ok := r.Registry.(extension.ProjectAuditRegistry) + if !ok { + return extension.ErrProjectAuditUnsupported + } + return reg.RegisterProjectAuditAuthorizer(check) +} + +func (r compiledRegistry) RegisterProjectAuditLinkVerifier(extension.ProjectAuditLinkVerifier) (extension.ProjectAuditPublisherFactory, error) { + return nil, errors.New("extension: declarative plugins cannot register project audit link verifiers") +} +func (r compiledRegistry) RegisterProjectAuditDisclosureAuthorizer(extension.ProjectAuditDisclosureAuthorizer) error { + return errors.New("extension: declarative plugins cannot register project audit disclosure authorizers") +} +func (r pluginRegistry) RegisterProjectAuditLinkVerifier(verify extension.ProjectAuditLinkVerifier) (extension.ProjectAuditPublisherFactory, error) { + if r.caps.projectAudit == nil { + return nil, extension.ErrProjectAuditUnsupported + } + token, err := r.caps.projectAudit.registerVerifier(r.pluginID, verify) + if err != nil { + return nil, err + } + return projectAuditPublisherFactory{caps: r.caps, pluginID: r.pluginID, token: token}, nil +} +func (r pluginRegistry) RegisterProjectAuditDisclosureAuthorizer(disclose extension.ProjectAuditDisclosureAuthorizer) error { + if r.caps.projectAudit == nil { + return extension.ErrProjectAuditUnsupported + } + return r.caps.projectAudit.registerDisclosure(r.pluginID, disclose) +} +func (r recordRegistry) RegisterProjectAuditLinkVerifier(verify extension.ProjectAuditLinkVerifier) (extension.ProjectAuditPublisherFactory, error) { + reg, ok := r.Registry.(extension.ProjectAuditLinkRegistry) + if !ok { + return nil, extension.ErrProjectAuditUnsupported + } + return reg.RegisterProjectAuditLinkVerifier(verify) +} +func (r recordRegistry) RegisterProjectAuditDisclosureAuthorizer(disclose extension.ProjectAuditDisclosureAuthorizer) error { + reg, ok := r.Registry.(extension.ProjectAuditLinkRegistry) + if !ok { + return extension.ErrProjectAuditUnsupported + } + return reg.RegisterProjectAuditDisclosureAuthorizer(disclose) +} + +var _ extension.ProjectAuditHost = inprocHost{} +var _ extension.ProjectAuditPublisher = projectAuditPublisher{} +var _ extension.ProjectAuditPublisherFactory = projectAuditPublisherFactory{} + +func (h inprocHost) publishProjectAuditLink(ctx context.Context, req extension.ProjectAuditLinkRequest, capability *auditPublisherToken) (extension.ProjectAuditLinkReceipt, error) { + if err := ctx.Err(); err != nil { + return extension.ProjectAuditLinkReceipt{}, err + } + if h.caps.projectAudit == nil || h.caps.st == nil { + return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditUnsupported + } + slot := h.caps.projectAudit + slot.mu.RLock() + holder, verify, registeredToken := slot.holder, slot.verifier, slot.publisherToken + slot.mu.RUnlock() + if holder != h.pluginID || verify == nil || registeredToken == nil || capability != registeredToken { + return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditUnsupported + } + if h.owner == "" || (h.actor != "" && h.actor != h.owner) || req.HostAuditOffset < 0 || req.ProjectID == "" || req.SourceKind != "domain-commit" || req.SourceID == "" || req.OperationID == "" || req.ResourceKind == "" || req.ResourceID == "" || req.DisclosureClass != "metadata" || req.IdempotencyKey == "" { + return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditDenied + } + // An explicit offset is only a locator. A historical collision or a forged + // offset must not turn a private command with the same operation ID into a + // published project receipt. + resolved, err := h.caps.st.CommittedAuditOffset(h.owner, string(req.ProjectID), req.OperationID) + if errors.Is(err, store.ErrNotFound) { + if req.HostAuditOffset == 0 { + return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditPending + } + return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditDenied + } + if errors.Is(err, store.ErrConflict) { + return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditLinkConflict + } + if err != nil { + return extension.ProjectAuditLinkReceipt{}, err + } + if req.HostAuditOffset != 0 && req.HostAuditOffset != resolved { + return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditLinkConflict + } + req.HostAuditOffset = resolved + row, projectID, err := h.caps.st.CommandAuditForLink(h.owner, req.HostAuditOffset) + if errors.Is(err, store.ErrNotFound) { + return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditDenied + } + if err != nil { + return extension.ProjectAuditLinkReceipt{}, err + } + if projectID != string(req.ProjectID) || row.Envelope.Actor != h.owner || row.Envelope.OperationID != req.OperationID || string(row.Envelope.State) != "committed" || row.Envelope.RunID != req.RunID { + return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditDenied + } + if req.RunID != "" { + bound, err := h.caps.st.RunBoundToAudit(h.owner, projectID, row.Envelope.ConversationID, req.RunID) + if err != nil { + return extension.ProjectAuditLinkReceipt{}, err + } + if !bound { + return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditDenied + } + } + previous, lookupErr := h.caps.st.ProjectAuditLinkByKey(h.owner, req.IdempotencyKey) + if lookupErr == nil { + if previous.HostAuditOffset != req.HostAuditOffset || previous.ProjectID != string(req.ProjectID) || previous.SourceKind != req.SourceKind || previous.SourceID != req.SourceID || previous.OperationID != req.OperationID || previous.ResourceKind != req.ResourceKind || previous.ResourceID != req.ResourceID || previous.RunID != req.RunID || previous.DisclosureClass != req.DisclosureClass { + return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditLinkConflict + } + return extension.ProjectAuditLinkReceipt{LinkID: previous.ID, HostAuditOffset: previous.HostAuditOffset}, nil + } + if !errors.Is(lookupErr, store.ErrNotFound) { + return extension.ProjectAuditLinkReceipt{}, lookupErr + } + proof := extension.ProjectAuditLinkProof{ProjectAuditLinkRequest: req, Owner: h.owner} + ok, err := verify(ctx, proof) + if err != nil { + return extension.ProjectAuditLinkReceipt{}, err + } + if !ok { + return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditDenied + } + if err := ctx.Err(); err != nil { + return extension.ProjectAuditLinkReceipt{}, err + } + link := store.ProjectAuditLink{HostAuditOffset: req.HostAuditOffset, ProjectID: projectID, Owner: h.owner, + SourceKind: req.SourceKind, SourceID: req.SourceID, OperationID: req.OperationID, ResourceKind: req.ResourceKind, + ResourceID: req.ResourceID, RunID: req.RunID, DisclosureClass: req.DisclosureClass, IdempotencyKey: req.IdempotencyKey} + saved, err := h.caps.st.SaveProjectAuditLink(link) + if errors.Is(err, store.ErrConflict) { + return extension.ProjectAuditLinkReceipt{}, extension.ErrProjectAuditLinkConflict + } + if err != nil { + return extension.ProjectAuditLinkReceipt{}, err + } + return extension.ProjectAuditLinkReceipt{LinkID: saved.ID, HostAuditOffset: saved.HostAuditOffset}, nil +} + +func (h inprocHost) QueryProjectAudit(ctx context.Context, q extension.ProjectAuditQuery) (extension.ProjectAuditPage, error) { + if err := ctx.Err(); err != nil { + return extension.ProjectAuditPage{}, err + } + if h.actor == "" || h.actor != h.owner || q.ProjectID == "" { + return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied + } + if h.caps.projectAudit == nil || h.caps.st == nil { + return extension.ProjectAuditPage{}, extension.ErrProjectAuditUnsupported + } + slot := h.caps.projectAudit + slot.mu.RLock() + holder, check := slot.holder, slot.check + slot.mu.RUnlock() + if holder != h.pluginID || check == nil { + return extension.ProjectAuditPage{}, extension.ErrProjectAuditUnsupported + } + ok, err := check(h.actor, core.ID(q.ProjectID)) + if err != nil { + return extension.ProjectAuditPage{}, err + } + if !ok { + return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied + } + if err := ctx.Err(); err != nil { + return extension.ProjectAuditPage{}, err + } + if q.LinkedSourceKind != "" || q.LinkedSourceID != "" || q.LinkedHostOffset != 0 { + return h.queryLinkedProjectAudit(ctx, q, slot) + } + rows, cutoff, next, err := h.caps.st.ListProjectCommandAudits(h.owner, string(q.ProjectID), q.BeforeOffset, q.CutoffOffset, q.Limit) + if err != nil { + return extension.ProjectAuditPage{}, err + } + page := extension.ProjectAuditPage{ + Receipts: make([]extension.HostAuditReceipt, 0, len(rows)), + NextOffset: next, CutoffOffset: cutoff, + Coverage: extension.ProjectAuditCoverage{ + CommandAudit: "actor-owned-project-bound-only", CutoffConsistency: "committed-prefix", CrossMember: "unsupported", LegacyProjectBinding: "not-recorded", RunRelations: "unsupported", ModelReceipts: "unsupported", + BackgroundReceipts: "unsupported", ResourceBodies: "unsupported", + }, + } + for _, row := range rows { + nameStatus := "not-recorded" + if row.ActorName != "" { + nameStatus = "recorded" + } + page.Receipts = append(page.Receipts, extension.HostAuditReceipt{ + SourceOffset: row.Offset, Time: row.Envelope.Time, ActorID: row.Envelope.Actor, + ActorName: row.ActorName, ActorNameStatus: nameStatus, + CommandID: row.Envelope.CommandID, State: string(row.Envelope.State), + OperationID: row.Envelope.OperationID, ConversationID: row.Envelope.ConversationID, + }) + } + return page, nil +} + +func (h inprocHost) queryLinkedProjectAudit(ctx context.Context, q extension.ProjectAuditQuery, slot *projectAuditSlot) (extension.ProjectAuditPage, error) { + if q.LinkedSourceKind != "domain-commit" || q.LinkedSourceID == "" || q.LinkedHostOffset <= 0 || q.BeforeOffset != 0 || q.CutoffOffset != 0 { + return extension.ProjectAuditPage{}, extension.ErrProjectAuditUnsupported + } + slot.mu.RLock() + disclose := slot.disclose + slot.mu.RUnlock() + if disclose == nil { + return extension.ProjectAuditPage{}, extension.ErrProjectAuditUnsupported + } + link, row, err := h.caps.st.ProjectAuditLinkedReceipt(string(q.ProjectID), q.LinkedSourceKind, q.LinkedSourceID, q.LinkedHostOffset) + if errors.Is(err, store.ErrNotFound) { + return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied + } + if err != nil { + return extension.ProjectAuditPage{}, err + } + resource := extension.ProjectAuditLinkedResource{ProjectID: q.ProjectID, Owner: link.Owner, SourceKind: link.SourceKind, SourceID: link.SourceID, + OperationID: link.OperationID, ResourceKind: link.ResourceKind, ResourceID: link.ResourceID, RunID: link.RunID, DisclosureClass: link.DisclosureClass} + allowed, err := disclose(ctx, h.actor, resource) + if err != nil { + return extension.ProjectAuditPage{}, err + } + if !allowed.Metadata { + return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied + } + nameStatus := "not-recorded" + if row.ActorName != "" { + nameStatus = "recorded" + } + return extension.ProjectAuditPage{Receipts: []extension.HostAuditReceipt{{SourceOffset: row.Offset, Time: row.Envelope.Time, + ActorID: row.Envelope.Actor, ActorName: row.ActorName, ActorNameStatus: nameStatus, CommandID: row.Envelope.CommandID, + State: string(row.Envelope.State), OperationID: row.Envelope.OperationID}}, + Coverage: extension.ProjectAuditCoverage{CommandAudit: "verified-exact-link-only", CrossMember: "verified-exact-link-only", + CutoffConsistency: "exact-link", LegacyProjectBinding: "not-recorded", RunRelations: "unsupported", + ModelReceipts: "unsupported", BackgroundReceipts: "unsupported", ResourceBodies: "unsupported"}}, nil +} diff --git a/internal/app/project_audit_link_test.go b/internal/app/project_audit_link_test.go new file mode 100644 index 0000000..8668fed --- /dev/null +++ b/internal/app/project_audit_link_test.go @@ -0,0 +1,184 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "path/filepath" + "strings" + "testing" + "time" + + agentcommand "agent-command" + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" + core "agent-system-core" +) + +func TestProjectAuditLinkSharesOnlyVerifiedCommittedMetadata(t *testing.T) { + st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "audit.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if err := st.EnsureUser("alice", "Alice at action"); err != nil { + t.Fatal(err) + } + if err := st.EnsureUser("bob", "Bob"); err != nil { + t.Fatal(err) + } + p, err := st.CreateProject("alice", "A") + if err != nil { + t.Fatal(err) + } + otherProject, err := st.CreateProject("alice", "B") + if err != nil { + t.Fatal(err) + } + c, err := st.CreateConversation("alice", string(p.ID), "private", "") + if err != nil { + t.Fatal(err) + } + if err := st.AppendAudit("alice", agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: "rdm.confirm", OperationID: "op-1", ConversationID: string(c.ID), State: agentcommand.StateCommitted, ParamsDigest: "secret-digest"}); err != nil { + t.Fatal(err) + } + private, _, _, err := st.ListProjectCommandAudits("alice", string(p.ID), 0, 0, 10) + if err != nil { + t.Fatal(err) + } + offset := private[0].Offset + slot := &projectAuditSlot{} + caps := hostcaps{st: st, projectAudit: slot} + reg := pluginRegistry{pluginID: "rdm", caps: caps} + auditAllowed := true + if err := reg.RegisterProjectAuditAuthorizer(func(actor string, projectID core.ID) (bool, error) { + return auditAllowed && actor == "bob" && projectID == p.ID, nil + }); err != nil { + t.Fatal(err) + } + publisherFactory, err := (recordRegistry{Registry: reg}).RegisterProjectAuditLinkVerifier(func(_ context.Context, proof extension.ProjectAuditLinkProof) (bool, error) { + return proof.Owner == "alice" && proof.ProjectID == p.ID && proof.SourceKind == "domain-commit" && proof.SourceID == "commit-1" && proof.OperationID == "op-1" && proof.ResourceKind == "document" && proof.ResourceID == "doc-1", nil + }) + if err != nil { + t.Fatal(err) + } + metadataAllowed := true + if err := reg.RegisterProjectAuditDisclosureAuthorizer(func(_ context.Context, actor string, resource extension.ProjectAuditLinkedResource) (extension.ProjectAuditDisclosure, error) { + return extension.ProjectAuditDisclosure{Metadata: metadataAllowed && actor == "bob" && resource.ResourceID == "doc-1"}, nil + }); err != nil { + t.Fatal(err) + } + bob := inprocHost{caps: caps, pluginID: "rdm", owner: "bob", actor: "bob"} + q := extension.ProjectAuditQuery{ProjectID: p.ID, LinkedSourceKind: "domain-commit", LinkedSourceID: "commit-1", LinkedHostOffset: offset} + if _, err := bob.QueryProjectAudit(context.Background(), q); !errors.Is(err, extension.ErrProjectAuditDenied) { + t.Fatalf("unlinked private audit: %v", err) + } + alicePublisher, err := publisherFactory.ForOwner("alice") // capability retained by trusted RDM outbox + if err != nil { + t.Fatal(err) + } + if _, ok := alicePublisher.(extension.Host); ok { + t.Fatal("publisher factory exposed a full owner-scoped Host") + } + linkReq := extension.ProjectAuditLinkRequest{ProjectID: p.ID, SourceKind: "domain-commit", SourceID: "commit-1", OperationID: "op-1", ResourceKind: "document", ResourceID: "doc-1", DisclosureClass: "metadata", IdempotencyKey: "outbox-1"} + // A different compiled plugin can call the public scoped-host factory with + // "rdm" as text. That must not mint RDM's audit publication authority. + wireScopedHostFactory(caps) + t.Cleanup(func() { extension.SetScopedHostFactory(nil) }) + forged, err := extension.NewScopedHost("alice", "rdm") + if err != nil { + t.Fatal(err) + } + if publisher, ok := forged.(extension.ProjectAuditPublisher); ok { + if _, err := publisher.PublishProjectAuditLink(context.Background(), linkReq); !errors.Is(err, extension.ErrProjectAuditUnsupported) { + t.Fatalf("self-reported plugin ID minted audit publisher: %v", err) + } + } + if rows, err := bob.QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: p.ID}); err != nil || len(rows.Receipts) != 0 { + t.Fatalf("private owner row visible without exact link: %+v %v", rows, err) + } + wrongProject := linkReq + wrongProject.ProjectID = otherProject.ID + wrongProject.HostAuditOffset = offset + if _, err := alicePublisher.PublishProjectAuditLink(context.Background(), wrongProject); !errors.Is(err, extension.ErrProjectAuditDenied) { + t.Fatalf("wrong host project: %v", err) + } + uncommitted := linkReq + uncommitted.SourceID, uncommitted.IdempotencyKey = "uncommitted", "outbox-uncommitted" + if _, err := alicePublisher.PublishProjectAuditLink(context.Background(), uncommitted); !errors.Is(err, extension.ErrProjectAuditDenied) { + t.Fatalf("uncommitted source: %v", err) + } + if _, err := (pluginRegistry{pluginID: "other", caps: caps}).RegisterProjectAuditLinkVerifier(func(context.Context, extension.ProjectAuditLinkProof) (bool, error) { return true, nil }); err == nil { + t.Fatal("other plugin replaced the registered verifier") + } + pending := linkReq + pending.OperationID, pending.IdempotencyKey = "op-not-yet-recorded", "outbox-pending" + if _, err := alicePublisher.PublishProjectAuditLink(context.Background(), pending); !errors.Is(err, extension.ErrProjectAuditPending) { + t.Fatalf("host receipt race must be retryable: %v", err) + } + first, err := alicePublisher.PublishProjectAuditLink(context.Background(), linkReq) + if err != nil { + t.Fatal(err) + } + if first.HostAuditOffset != offset { + t.Fatalf("trusted resolved offset: got %d want %d", first.HostAuditOffset, offset) + } + retry, err := alicePublisher.PublishProjectAuditLink(context.Background(), linkReq) + if err != nil || retry.LinkID != first.LinkID { + t.Fatalf("idempotent retry: first=%+v retry=%+v err=%v", first, retry, err) + } + page, err := bob.QueryProjectAudit(context.Background(), q) + if err != nil { + t.Fatal(err) + } + if len(page.Receipts) != 1 || page.Receipts[0].CommandID != "rdm.confirm" || page.Receipts[0].ActorName != "Alice at action" || page.Receipts[0].ConversationID != "" || page.Coverage.CrossMember != "verified-exact-link-only" { + t.Fatalf("linked page: %+v", page) + } + raw, err := json.Marshal(page) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), "secret-digest") || strings.Contains(string(raw), string(c.ID)) { + t.Fatalf("private receipt fields leaked: %s", raw) + } + if err := st.AppendAudit("alice", agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: "private.note", OperationID: "op-1", ConversationID: string(c.ID), State: agentcommand.StateCommitted}); err != nil { + t.Fatal(err) + } + duplicates, _, _, err := st.ListProjectCommandAudits("alice", string(p.ID), 0, 0, 10) + if err != nil { + t.Fatal(err) + } + privateOffset := duplicates[0].Offset + mislink := linkReq + mislink.HostAuditOffset, mislink.IdempotencyKey = privateOffset, "outbox-private" + if _, err := alicePublisher.PublishProjectAuditLink(context.Background(), mislink); !errors.Is(err, extension.ErrProjectAuditLinkConflict) { + t.Fatalf("ambiguous operation linked a private command: %v", err) + } + privateQuery := q + privateQuery.LinkedHostOffset = privateOffset + if _, err := bob.QueryProjectAudit(context.Background(), privateQuery); !errors.Is(err, extension.ErrProjectAuditDenied) { + t.Fatalf("private command disclosed through ambiguous operation: %v", err) + } + metadataAllowed = false + if _, err := bob.QueryProjectAudit(context.Background(), q); !errors.Is(err, extension.ErrProjectAuditDenied) { + t.Fatalf("revoked object metadata: %v", err) + } + metadataAllowed, auditAllowed = true, false + if _, err := bob.QueryProjectAudit(context.Background(), q); !errors.Is(err, extension.ErrProjectAuditDenied) { + t.Fatalf("revoked audit.read: %v", err) + } + linkReq.ResourceID = "doc-2" + if _, err := alicePublisher.PublishProjectAuditLink(context.Background(), linkReq); !errors.Is(err, extension.ErrProjectAuditLinkConflict) { + t.Fatalf("same key different link: %v", err) + } + metadataAllowed, auditAllowed = true, true + if err := st.Close(); err != nil { + t.Fatal(err) + } + if _, err := alicePublisher.PublishProjectAuditLink(context.Background(), linkReq); err == nil || errors.Is(err, extension.ErrProjectAuditDenied) { + t.Fatalf("storage outage during publish must remain retryable: %v", err) + } + if _, err := bob.QueryProjectAudit(context.Background(), q); err == nil || errors.Is(err, extension.ErrProjectAuditDenied) { + t.Fatalf("storage outage during linked read must be explicit: %v", err) + } +} diff --git a/internal/app/project_audit_test.go b/internal/app/project_audit_test.go new file mode 100644 index 0000000..77a5e18 --- /dev/null +++ b/internal/app/project_audit_test.go @@ -0,0 +1,238 @@ +package app + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + agentcommand "agent-command" + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" + core "agent-system-core" +) + +func TestProjectAuditHostChecksDomainReadAndProjectsStoredReceipts(t *testing.T) { + st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "audit.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + for _, owner := range []string{"alice", "bob"} { + if err := st.EnsureUser(owner, strings.ToUpper(owner)); err != nil { + t.Fatal(err) + } + } + a, err := st.CreateProject("alice", "A") + if err != nil { + t.Fatal(err) + } + b, err := st.CreateProject("alice", "B") + if err != nil { + t.Fatal(err) + } + ac, err := st.CreateConversation("alice", string(a.ID), "A", "") + if err != nil { + t.Fatal(err) + } + bc, err := st.CreateConversation("alice", string(b.ID), "B", "") + if err != nil { + t.Fatal(err) + } + shared, err := st.CreateConversationInProject("bob", string(a.ID), "A shared", "") + if err != nil { + t.Fatal(err) + } + for _, item := range []struct{ owner, conversation, command string }{ + {"alice", string(ac.ID), "a.command"}, + {"alice", string(bc.ID), "b.command"}, + {"bob", string(shared.ID), "shared.command"}, + } { + if err := st.AppendAudit(item.owner, agentcommand.AuditEnvelope{Time: time.Now(), Actor: item.owner, CommandID: item.command, ConversationID: item.conversation, State: agentcommand.StateCommitted, ParamsDigest: "sensitive-digest"}); err != nil { + t.Fatal(err) + } + } + slot := &projectAuditSlot{} + caps := hostcaps{st: st, projectAudit: slot} + reg := pluginRegistry{pluginID: "rdm", caps: caps} + allowed := true + if err := reg.RegisterProjectAuditAuthorizer(func(actor string, projectID core.ID) (bool, error) { + return allowed && actor == "bob" && projectID == a.ID, nil + }); err != nil { + t.Fatal(err) + } + h := inprocHost{caps: caps, owner: "bob", actor: "bob", pluginID: "rdm"} + page, err := h.QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: a.ID, Limit: 10}) + if err != nil { + t.Fatal(err) + } + if len(page.Receipts) != 1 || page.Receipts[0].CommandID != "shared.command" { + t.Fatalf("A page: %+v", page) + } + if page.Coverage.CommandAudit != "actor-owned-project-bound-only" || page.Coverage.CrossMember != "unsupported" || page.Coverage.LegacyProjectBinding != "not-recorded" || page.Coverage.RunRelations != "unsupported" || page.Coverage.ModelReceipts != "unsupported" || page.Coverage.CutoffConsistency != "committed-prefix" { + t.Fatalf("coverage: %+v", page.Coverage) + } + if page.Receipts[0].ActorName != "BOB" { + t.Fatalf("actor names: %+v", page.Receipts) + } + raw, err := json.Marshal(page) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), "sensitive-digest") || strings.Contains(string(raw), "paramsDigest") || strings.Contains(string(raw), "payload") { + t.Fatalf("raw audit payload leaked: %s", raw) + } + if _, err := h.QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: b.ID}); !errors.Is(err, extension.ErrProjectAuditDenied) { + t.Fatalf("project B: %v", err) + } + if _, err := (inprocHost{caps: caps, owner: "bob", actor: "bob", pluginID: "other"}).QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: a.ID}); !errors.Is(err, extension.ErrProjectAuditUnsupported) { + t.Fatalf("other plugin: %v", err) + } + if _, err := (inprocHost{caps: caps, owner: "bob", pluginID: "rdm"}).QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: a.ID}); !errors.Is(err, extension.ErrProjectAuditDenied) { + t.Fatalf("unbound actor: %v", err) + } + allowed = false + if _, err := h.QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: a.ID, BeforeOffset: page.NextOffset, CutoffOffset: page.CutoffOffset}); !errors.Is(err, extension.ErrProjectAuditDenied) { + t.Fatalf("revoked audit.read: %v", err) + } +} + +// A direct explicit ID bypasses the v39 default allocator. This must never +// yield a committed-prefix page that permanently skips the late lower row. +func TestProjectAuditPostgresExplicitLowIDCannotHideAcrossPages(t *testing.T) { + dsn := os.Getenv("AGENT_PG_TEST_DSN") + if dsn == "" { + t.Skip("AGENT_PG_TEST_DSN unset: isolated PostgreSQL check NOT_RUN") + } + admin, err := sql.Open("pgx", dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { admin.Close() }) + if _, err := admin.Exec(`CREATE EXTENSION IF NOT EXISTS pg_trgm`); err != nil { + t.Fatal(err) + } + schema := "w9_" + strings.ToLower(string(store.NewID("audit"))) + if _, err := admin.Exec(`CREATE SCHEMA ` + schema); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { admin.Exec(`DROP SCHEMA IF EXISTS ` + schema + ` CASCADE`) }) + st, err := store.OpenPostgres(store.AppendPGRuntimeParam(dsn, "search_path", schema+",public"), filepath.Join(t.TempDir(), "artifacts")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if err := st.EnsureUser("alice", "Alice"); err != nil { + t.Fatal(err) + } + project, err := st.CreateProject("alice", "A") + if err != nil { + t.Fatal(err) + } + conversation, err := st.CreateConversation("alice", string(project.ID), "work", "") + if err != nil { + t.Fatal(err) + } + for _, command := range []string{"first", "second"} { + if err := st.AppendAudit("alice", agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: command, ConversationID: string(conversation.ID), State: agentcommand.StateCommitted}); err != nil { + t.Fatal(err) + } + } + insert := `INSERT INTO command_audit(time,command_id,source,entrance,state,operation_id,conversation_id,owner,payload,actor_name,project_id) + VALUES ($1,$2,'','','committed','',$3,'alice',$4,'Alice',$5) RETURNING id` + gap, err := st.DB().Begin() + if err != nil { + t.Fatal(err) + } + defer gap.Rollback() + var gapID int64 + if err := gap.QueryRow(insert, time.Now().UTC().Format(time.RFC3339Nano), "rolled-back-gap", string(conversation.ID), `{}`, string(project.ID)).Scan(&gapID); err != nil { + t.Fatal(err) + } + if gapID != 3 { + t.Fatalf("rollback gap ID=%d want=3", gapID) + } + if err := gap.Rollback(); err != nil { + t.Fatal(err) + } + a, err := st.DB().Begin() + if err != nil { + t.Fatal(err) + } + defer a.Rollback() + env := agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: "explicit-late", ConversationID: string(conversation.ID), State: agentcommand.StateCommitted} + raw, err := json.Marshal(env) + if err != nil { + t.Fatal(err) + } + _, err = a.Exec(`INSERT INTO command_audit(id,time,command_id,source,entrance,state,operation_id,conversation_id,owner,payload,actor_name,project_id) + VALUES (3,$1,$2,'','','committed','',$3,'alice',$4,'Alice',$5)`, env.Time.UTC().Format(time.RFC3339Nano), env.CommandID, string(conversation.ID), string(raw), string(project.ID)) + if err != nil { + // Rejecting the online explicit ID is also a safe outcome. + t.Logf("online explicit low ID rejected: %v", err) + return + } + slot := &projectAuditSlot{} + caps := hostcaps{st: st, projectAudit: slot} + reg := pluginRegistry{pluginID: "rdm", caps: caps} + if err := reg.RegisterProjectAuditAuthorizer(func(actor string, projectID core.ID) (bool, error) { + return actor == "alice" && projectID == project.ID, nil + }); err != nil { + t.Fatal(err) + } + h := inprocHost{caps: caps, owner: "alice", actor: "alice", pluginID: "rdm"} + committed := make(chan error, 1) + started := make(chan struct{}) + go func() { + close(started) + committed <- st.AppendAudit("alice", agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: "implicit-high", ConversationID: string(conversation.ID), State: agentcommand.StateCommitted}) + }() + <-started + var first extension.ProjectAuditPage + highCommittedEarly := false + select { + case err := <-committed: + if err != nil { + t.Fatal(err) + } + highCommittedEarly = true + case <-time.After(500 * time.Millisecond): + } + if highCommittedEarly { + first, err = h.QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: project.ID, Limit: 2}) + if err != nil { + t.Fatal(err) + } + if len(first.Receipts) != 2 || first.Receipts[0].SourceOffset != 4 || first.Receipts[1].SourceOffset != 2 || first.CutoffOffset != 4 || first.NextOffset != 2 || first.Coverage.CutoffConsistency != "committed-prefix" { + t.Fatalf("counterexample first page changed: %+v", first) + } + } + if err := a.Commit(); err != nil { + t.Fatal(err) + } + if !highCommittedEarly { + if err := <-committed; err != nil { + t.Fatal(err) + } + first, err = h.QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: project.ID, Limit: 2}) + if err != nil { + t.Fatal(err) + } + } + second, err := h.QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: project.ID, BeforeOffset: first.NextOffset, CutoffOffset: first.CutoffOffset, Limit: 2}) + if err != nil { + t.Fatal(err) + } + seen := map[int64]bool{} + for _, receipt := range append(first.Receipts, second.Receipts...) { + seen[receipt.SourceOffset] = true + } + if !seen[3] || len(seen) != 4 { + t.Fatalf("committed-prefix omitted explicit low ID 3: early=%v first=%+v second=%+v", highCommittedEarly, first, second) + } +} diff --git a/internal/app/project_context.go b/internal/app/project_context.go index fb056d3..1f1aeeb 100644 --- a/internal/app/project_context.go +++ b/internal/app/project_context.go @@ -118,6 +118,33 @@ func (s *projectContextSlot) readEvidence(ctx context.Context, req extension.Pro return evidence, nil } +func (s *projectContextSlot) recheck(ctx context.Context, req extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) { + if s == nil || req.Scope.Actor == "" || req.Scope.ProjectID == "" || req.Scope.Goal != "" { + return extension.ProjectContextSourceRecheckResult{}, errors.New("extension: trusted source recheck scope missing") + } + s.mu.RLock() + p, holder := s.provider, s.holder + s.mu.RUnlock() + rechecker, ok := p.(extension.ProjectContextSourceRechecker) + if !ok { + return extension.ProjectContextSourceRecheckResult{}, errors.New("extension: project source rechecker unavailable") + } + req.Scope.PluginID = holder + bounded, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + result, err := rechecker.Recheck(bounded, req) + if err != nil { + return extension.ProjectContextSourceRecheckResult{}, err + } + if err := bounded.Err(); err != nil { + return extension.ProjectContextSourceRecheckResult{}, err + } + if result.Bundle.ProjectID != req.Scope.ProjectID || result.Bundle.Status != extension.ProjectContextReady { + return extension.ProjectContextSourceRecheckResult{}, errors.New("extension: project sources no longer authorized") + } + return result, nil +} + const workContextBudgetChars = 12000 // TaskCreateForWork fixes the project binding while the authenticated command diff --git a/internal/app/project_context_test.go b/internal/app/project_context_test.go index 4b40b36..58dbfae 100644 --- a/internal/app/project_context_test.go +++ b/internal/app/project_context_test.go @@ -22,6 +22,46 @@ type testProjectContextProvider struct { read func(extension.ProjectEvidenceRequest) (extension.ProjectEvidence, error) } +type testSourceRecheckProvider struct { + testProjectContextProvider + called *bool +} + +func (p testSourceRecheckProvider) Recheck(ctx context.Context, req extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) { + if _, ok := ctx.Deadline(); !ok || req.Scope.PluginID != "rdm" || req.Scope.Actor != "alice" || req.Scope.ProjectID != "p1" || req.Scope.Goal != "" { + return extension.ProjectContextSourceRecheckResult{}, fmt.Errorf("untrusted or unbounded recheck scope") + } + *p.called = true + return extension.ProjectContextSourceRecheckResult{Bundle: extension.ProjectContextBundle{Status: extension.ProjectContextReady, ProjectID: req.Scope.ProjectID}}, nil +} + +func TestProjectSourceRecheckUsesSameRegisteredProviderAndDeadline(t *testing.T) { + called := false + slot := &projectContextSlot{} + provider := testSourceRecheckProvider{called: &called} + if err := (pluginRegistry{pluginID: "rdm", caps: hostcaps{contexts: slot}}).RegisterProjectContextProvider(provider); err != nil { + t.Fatal(err) + } + if _, err := slot.recheck(context.Background(), extension.ProjectContextSourceRecheckRequest{Scope: extension.ProjectContextRequest{Actor: "alice", ProjectID: "p1"}}); err != nil || !called { + t.Fatalf("registered source rechecker unavailable: called=%v err=%v", called, err) + } + if _, err := slot.recheck(context.Background(), extension.ProjectContextSourceRecheckRequest{Scope: extension.ProjectContextRequest{Actor: "alice", ProjectID: "p1", Goal: "OLD_RESTRICTED_BODY"}}); err == nil { + t.Fatal("raw Goal entered source recheck provider") + } + cancelled, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := slot.recheck(cancelled, extension.ProjectContextSourceRecheckRequest{Scope: extension.ProjectContextRequest{Actor: "alice", ProjectID: "p1"}}); err == nil { + t.Fatal("cancelled source recheck returned authorized context") + } + legacy := &projectContextSlot{} + if err := (pluginRegistry{pluginID: "rdm", caps: hostcaps{contexts: legacy}}).RegisterProjectContextProvider(testProjectContextProvider{}); err != nil { + t.Fatal(err) + } + if _, err := legacy.recheck(context.Background(), extension.ProjectContextSourceRecheckRequest{Scope: extension.ProjectContextRequest{Actor: "alice", ProjectID: "p1"}}); err == nil { + t.Fatal("provider without optional rechecker silently accepted old sources") + } +} + type deadlineContextProvider struct{ sawDeadline *bool } func (p deadlineContextProvider) Resolve(ctx context.Context, req extension.ProjectContextRequest) (extension.ProjectContextBundle, error) { @@ -105,6 +145,9 @@ func TestCompleteForWorkUsesStoredScopeAndActualInputManifest(t *testing.T) { t.Fatal(err) } defer st.Close() + if err := st.EnsureUser("alice", "Alice"); err != nil { + t.Fatal(err) + } p, err := st.CreateProject("alice", "Project") if err != nil { t.Fatal(err) @@ -266,8 +309,8 @@ func TestCompleteForWorkUsesStoredScopeAndActualInputManifest(t *testing.T) { func TestWorkContextBudgetKeepsRequiredEvidenceFirst(t *testing.T) { bundle := extension.ProjectContextBundle{Status: extension.ProjectContextReady, ProjectID: "project-1", Items: []extension.ProjectContextItem{ - {SourceID: "optional", Text: "12345678"}, - {SourceID: "required", Text: "must keep", Required: true}, + {SourceID: "optional", Revision: "r1", Hash: "h1", Text: "12345678"}, + {SourceID: "required", Revision: "r1", Hash: "h2", Text: "must keep", Required: true}, }} prompt, manifest, err := assembleWorkCompletion("goal", bundle, 140) if err != nil { diff --git a/internal/app/project_lifecycle.go b/internal/app/project_lifecycle.go index 78ccf95..cc79eeb 100644 --- a/internal/app/project_lifecycle.go +++ b/internal/app/project_lifecycle.go @@ -51,7 +51,7 @@ func (slot *projectLifecycleSlot) wire(srv *server.Server, caps hostcaps) { } holder := slot.holder srv.SetProjectLifecycle(*slot.provider, func(owner string) extension.Host { - return inprocHost{caps: caps, owner: owner, actor: owner, pluginID: holder} + return requestBoundHost{inprocHost{caps: caps, owner: owner, actor: owner, pluginID: holder}} }) } @@ -84,6 +84,9 @@ func (slot *projectLifecycleSlot) resolve(ctx context.Context, caps hostcaps, ow var _ extension.ProjectHost = inprocHost{} func (h inprocHost) OwnedProjects() ([]core.Project, error) { + if err := h.requireEffective(); err != nil { + return nil, err + } ps, err := h.caps.st.ListProjects(h.owner) for i := range ps { ps[i].Owner = h.owner @@ -91,9 +94,15 @@ func (h inprocHost) OwnedProjects() ([]core.Project, error) { return ps, err } func (h inprocHost) OwnedProject(id string) (core.Project, error) { + if err := h.requireEffective(); err != nil { + return core.Project{}, err + } return h.caps.st.GetProject(h.owner, id) } func (h inprocHost) ProjectMetadata(id string) (core.Project, error) { + if err := h.requireEffective(); err != nil { + return core.Project{}, err + } p, err := h.caps.st.ProjectMetadata(id) if err != nil { return core.Project{}, err @@ -114,11 +123,29 @@ func (h inprocHost) ProjectMetadata(id string) (core.Project, error) { return p, nil } func (h inprocHost) EnsureProject(id, name string) (core.Project, error) { - return h.caps.st.EnsureProject(h.owner, id, name) + if err := h.requireEffective(); err != nil { + return core.Project{}, err + } + if h.actor != "" && h.actor != h.owner { + return core.Project{}, store.ErrAccountForbidden + } + // A creator-scoped Host is the approved I1 projection capability. RDM + // authorizes a delegated operator before invoking it; cross-database + // commit fencing remains outside U3's guarantee. + return h.caps.st.EnsureProjectAccountChecked(h.owner, h.owner, id, name, h.caps.allowImplicitLocal) } func (h inprocHost) RenameOwnedProject(id, name string) (core.Project, error) { - return h.caps.st.RenameOwnedProject(h.owner, id, name) + if err := h.requireEffective(); err != nil { + return core.Project{}, err + } + if h.actor != "" && h.actor != h.owner { + return core.Project{}, store.ErrAccountForbidden + } + return h.caps.st.RenameOwnedProjectAccountChecked(h.owner, h.owner, id, name, h.caps.allowImplicitLocal) } func (h inprocHost) ProjectNameAvailable(name, exceptID string) (bool, error) { + if err := h.requireEffective(); err != nil { + return false, err + } return h.caps.st.ProjectNameAvailable(name, exceptID) } diff --git a/internal/app/project_lifecycle_test.go b/internal/app/project_lifecycle_test.go index 1650ff9..f23d3ee 100644 --- a/internal/app/project_lifecycle_test.go +++ b/internal/app/project_lifecycle_test.go @@ -11,7 +11,12 @@ import ( "encoding/json" "errors" "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" "testing" + "time" ) func completeLifecycle() extension.ProjectLifecycle { @@ -55,6 +60,11 @@ func TestProjectHostMetadataDoesNotGrantMutation(t *testing.T) { t.Fatal(err) } defer st.Close() + for _, owner := range []string{"alice", "bob"} { + if err := st.EnsureUser(owner, ""); err != nil { + t.Fatal(err) + } + } alice := inprocHost{owner: "alice", caps: hostcaps{st: st}} p, err := alice.EnsureProject("stable", "Shared") if err != nil { @@ -131,6 +141,9 @@ func TestProjectHostMetadataMasksSharedSecretsWithoutMutation(t *testing.T) { srv := server.New(st, nil, nil, nil, svc, nil) srv.Auth = server.NewAuthenticator(st, true) slot.wire(srv, caps) + if err := st.EnsureUser("bob", ""); err != nil { + t.Fatal(err) + } key, err := st.CreateAPIKey("bob", "test") if err != nil { t.Fatal(err) @@ -161,3 +174,180 @@ func TestProjectHostMetadataMasksSharedSecretsWithoutMutation(t *testing.T) { t.Fatal("metadata read mutated persisted owner settings") } } + +func TestRequestBoundDelegatedProjectionFencesRealOperator(t *testing.T) { + st, err := store.OpenSQLite(t.TempDir() + "/delegated-project.db") + if err != nil { + t.Fatal(err) + } + defer st.Close() + for _, account := range []struct { + owner string + admin bool + }{{"admin", true}, {"alice", false}, {"bob", false}} { + if err := st.CreateAccount(account.owner, "", "", account.admin); err != nil { + t.Fatal(err) + } + } + project, err := st.CreateProject("alice", "original") + if err != nil { + t.Fatal(err) + } + guard := accountEffectiveAuthorization(st, true) + check := func(owner string) error { + valid, err := guard(context.Background(), owner) + if err != nil { + return err + } + if !valid { + return store.ErrAccountForbidden + } + return nil + } + slot := &projectLifecycleSlot{} + caps := hostcaps{st: st, projects: slot, accountGuard: check} + if err := (pluginRegistry{pluginID: "rdm", caps: caps}).RegisterProjectLifecycle(completeLifecycle()); err != nil { + t.Fatal(err) + } + request := requestBoundHost{inprocHost{caps: caps, pluginID: "rdm", owner: "bob", actor: "bob"}} + delegator, ok := any(request).(interface { + ProjectProjectionForCreator(creator, projectID string) (extension.ProjectHost, error) + }) + if !ok { + t.Fatal("request-bound delegated projection capability missing") + } + projection, err := delegator.ProjectProjectionForCreator("alice", string(project.ID)) + if err != nil { + t.Fatal(err) + } + if projection.Owner() != "alice" { + t.Fatalf("trusted projection owner=%s", projection.Owner()) + } + if _, ok := projection.(extension.Host); ok { + t.Fatal("single-project projection exposed broad creator Host capabilities") + } + if _, err := projection.RenameOwnedProject(string(project.ID), "delegated"); err != nil { + t.Fatalf("active delegated rename: %v", err) + } + if _, err := projection.RenameOwnedProject("another-project", "hijack"); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("projection escaped bound project: %v", err) + } + if _, err := projection.EnsureProject("another-project", "hijack"); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("projection created another project: %v", err) + } + bob, err := st.GetAccountByOwner("bob") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", bob.ID, bob.StateVersion, false); err != nil { + t.Fatal(err) + } + if _, err := projection.RenameOwnedProject(string(project.ID), "late"); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("disabled real operator renamed creator project: %v", err) + } + got, err := st.ProjectMetadata(string(project.ID)) + if err != nil || got.Name != "delegated" { + t.Fatalf("late rename changed project: %+v %v", got, err) + } +} + +func TestDelegatedProjectionRejectsDisableBetweenEarlyCheckAndWrite(t *testing.T) { + var st, other *store.Store + var err error + if baseDSN := os.Getenv("AGENT_PG_TEST_DSN"); baseDSN != "" { + st = openDirectoryTestStore(t) + if err := st.EnsureSystemIdentity("u3-delegated-projection", "pg"); err != nil { + t.Fatal(err) + } + var schema string + if err := st.DB().QueryRow(`SELECT current_schema()`).Scan(&schema); err != nil { + t.Fatal(err) + } + separator := "?" + if strings.Contains(baseDSN, "?") { + separator = "&" + } + other, err = store.OpenExistingPostgres(baseDSN+separator+"search_path="+schema+",public", filepath.Join(t.TempDir(), "other-artifacts")) + } else { + db := filepath.Join(t.TempDir(), "shared.db") + st, err = store.OpenSQLite(db) + if err == nil { + other, err = store.OpenExistingSQLite(db) + } + if st != nil { + defer st.Close() + } + } + if err != nil { + t.Fatal(err) + } + defer other.Close() + for _, account := range []struct { + owner string + admin bool + }{{"admin", true}, {"alice", false}, {"bob", false}} { + if err := st.CreateAccount(account.owner, "", "", account.admin); err != nil { + t.Fatal(err) + } + } + project, err := st.CreateProject("alice", "before") + if err != nil { + t.Fatal(err) + } + entered, release := make(chan struct{}), make(chan struct{}) + var bobChecks atomic.Int32 + check := func(owner string) error { + state, err := st.GetUserAuthState(owner) + if err != nil { + return err + } + if !state.Enabled { + return store.ErrAccountForbidden + } + if owner == "bob" && bobChecks.Add(1) == 2 { + close(entered) + <-release + } + return nil + } + slot := &projectLifecycleSlot{} + caps := hostcaps{st: st, projects: slot, accountGuard: check} + if err := (pluginRegistry{pluginID: "rdm", caps: caps}).RegisterProjectLifecycle(completeLifecycle()); err != nil { + t.Fatal(err) + } + host := requestBoundHost{inprocHost{caps: caps, pluginID: "rdm", owner: "bob", actor: "bob"}} + projection, err := host.ProjectProjectionForCreator("alice", string(project.ID)) + if err != nil { + t.Fatal(err) + } + written := make(chan error, 1) + go func() { + _, err := projection.RenameOwnedProject(string(project.ID), "late") + written <- err + }() + select { + case <-entered: + case <-time.After(3 * time.Second): + t.Fatal("rename did not reach paused early account check") + } + bob, err := st.GetAccountByOwner("bob") + if err != nil { + t.Fatal(err) + } + if _, err := other.SetAccountEnabled("admin", bob.ID, bob.StateVersion, false); err != nil { + t.Fatal(err) + } + close(release) + select { + case err := <-written: + if !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("late delegated rename committed: %v", err) + } + case <-time.After(3 * time.Second): + t.Fatal("late rename did not return") + } + got, err := st.ProjectMetadata(string(project.ID)) + if err != nil || got.Name != "before" { + t.Fatalf("project changed after disable: %+v %v", got, err) + } +} diff --git a/internal/app/project_work_observation.go b/internal/app/project_work_observation.go new file mode 100644 index 0000000..37dca04 --- /dev/null +++ b/internal/app/project_work_observation.go @@ -0,0 +1,381 @@ +package app + +import ( + "context" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "sync" + + "agent-system-backend/internal/runmgr" + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" + core "agent-system-core" +) + +type projectWorkObserverSlot struct { + mu sync.RWMutex + provider *extension.ProjectWorkObserver +} + +func (s *projectWorkObserverSlot) get() *extension.ProjectWorkObserver { + if s == nil { + return nil + } + s.mu.RLock() + defer s.mu.RUnlock() + if s.provider == nil { + return nil + } + copy := *s.provider + return © +} + +func (s *projectWorkObserverSlot) active() bool { return s.get() != nil } + +func wireProjectWorkGate(mgr *runmgr.Manager, st *store.Store, caps hostcaps, slot *projectWorkObserverSlot, + work func(context.Context, string, core.ID) (bool, error), +) { + provider := slot.get() + if provider == nil || work == nil { + return + } + verify := func(ctx context.Context, row store.ProjectWorkObservationRow) error { + switch row.SourceKind { + case "retrieval-hit": + return verifyProjectKnowledgeObservation(ctx, caps, row) + case "tool-result", "visible-analysis": + return verifyProjectTransientObservation(ctx, st, row) + default: + return fmt.Errorf("project work source %s: unsupported run source kind %q", row.EventID, row.SourceKind) + } + } + mgr.SetProjectWorkGate( + func(ctx context.Context, runID, action string) (extension.ProjectWorkDecision, error) { + return preflightProjectRun(ctx, st, slot, work, verify, runID, action) + }, + provider.Respond, + ) +} + +func (r compiledRegistry) RegisterProjectWorkObserver(extension.ProjectWorkObserver) error { + return errors.New("extension: declarative plugins cannot register project work observers") +} + +func (r pluginRegistry) RegisterProjectWorkObserver(p extension.ProjectWorkObserver) error { + if r.pluginID != "rdm" || r.caps.workObserver == nil { + return errors.New("extension: project work observer requires trusted RDM registration") + } + if p.Observe == nil || p.Preflight == nil || p.Respond == nil { + return errors.New("extension: project work observer requires all callbacks") + } + s := r.caps.workObserver + s.mu.Lock() + defer s.mu.Unlock() + if s.provider != nil { + return errors.New("extension: project work observer already registered") + } + copy := p + s.provider = © + return nil +} + +func (r recordRegistry) RegisterProjectWorkObserver(p extension.ProjectWorkObserver) error { + reg, ok := r.Registry.(extension.ProjectWorkObservationRegistry) + if !ok { + return errors.New("extension: project work observation registration unavailable") + } + return reg.RegisterProjectWorkObserver(p) +} + +// deliverProjectWorkObservations replays only committed source parts, rechecking +// their owner and current project work authority before disclosing text to RDM. +// An Observe ACK is an intake receipt, not a preflight or publication decision. +// Failures leave the row pending for a later idempotent retry. +func deliverProjectWorkObservations(ctx context.Context, st *store.Store, slot *projectWorkObserverSlot, + work func(context.Context, string, core.ID) (bool, error), limit int, verify ...func(context.Context, store.ProjectWorkObservationRow) error, +) (int, error) { + provider := slot.get() + if provider == nil || work == nil { + return 0, errors.New("extension: trusted project work observer unavailable") + } + rows, err := st.PendingProjectWorkObservations(limit) + if err != nil { + return 0, err + } + return deliverProjectWorkRows(ctx, st, provider, work, rows, verify...) +} + +func deliverProjectWorkRows(ctx context.Context, st *store.Store, provider *extension.ProjectWorkObserver, + work func(context.Context, string, core.ID) (bool, error), rows []store.ProjectWorkObservationRow, + verify ...func(context.Context, store.ProjectWorkObservationRow) error, +) (int, error) { + completed := 0 + for _, row := range rows { + if row.State != "pending" { + continue + } + if row.SourceKind != "user-message" && row.SourceKind != "assistant-message" { + if len(verify) != 1 || verify[0] == nil { + return completed, fmt.Errorf("project work source %s: current resource status verifier unavailable", row.EventID) + } + if err := verify[0](ctx, row); err != nil { + return completed, err + } + } + if err := ctx.Err(); err != nil { + return completed, err + } + conv, err := st.GetConversation(row.Owner, row.ConversationID) + if err != nil || string(conv.ProjectID) != row.ProjectID { + return completed, fmt.Errorf("project work source %s: current conversation binding unavailable", row.EventID) + } + allowed, err := work(ctx, row.Owner, core.ID(row.ProjectID)) + if err != nil || !allowed { + return completed, fmt.Errorf("project work source %s: current project authorization denied", row.EventID) + } + account, err := st.GetAccountByOwner(row.Owner) + authorizationEpoch := "" + if err == nil && account.Enabled { + authorizationEpoch = fmt.Sprintf("account:%d:project:%s", account.AuthVersion, row.ProjectID) + } else if row.Owner == store.OwnerDefault { + // Auth-off local mode has no enabled account model. The project-work + // callback above is still authoritative for this process. + authorizationEpoch = "auth-off-local:project:" + row.ProjectID + } else { + return completed, fmt.Errorf("project work source %s: current account authorization unavailable", row.EventID) + } + observation := extension.WorkObservation{ + EventID: row.EventID, Phase: "source-committed", Actor: row.Owner, Owner: row.Owner, + ProjectID: core.ID(row.ProjectID), ConversationID: core.ID(row.ConversationID), RunID: core.ID(row.RunID), + SourceKind: row.SourceKind, SourceID: row.SourceID, SourceVersion: row.SourceVersion, + SourceSHA256: row.SourceSHA256, PartIndex: row.PartIndex, SpanStart: row.SpanStart, SpanEnd: row.SpanEnd, + Text: row.Text, Scope: row.Scope, Delivery: row.Delivery, DeliveryReason: row.Reason, Metadata: row.Metadata, + AuthorizationEpoch: authorizationEpoch, ObservedAt: row.ObservedAt, + } + result, err := provider.Observe(ctx, observation) + if err != nil { + return completed, err + } + switch result.State { + case extension.WorkObservationPending: + continue + case extension.WorkObservationAccepted, extension.WorkObservationPartial, extension.WorkObservationDenied: + if err := st.FinishProjectWorkObservation(row.EventID, result.ReceiptID, string(result.State)); err != nil { + return completed, err + } + completed++ + default: + return completed, fmt.Errorf("project work source %s: invalid observer result %q", row.EventID, result.State) + } + } + return completed, nil +} + +func verifyProjectKnowledgeObservation(_ context.Context, caps hostcaps, row store.ProjectWorkObservationRow) error { + if row.SourceKind != "retrieval-hit" || caps.st == nil { + return errors.New("project work source: unsupported resource verification") + } + doc, err := caps.st.ProjectWorkKnowledgeSource(row.SourceID) + if err != nil { + return errors.New("project work source: current knowledge source unavailable") + } + hash := sha256.Sum256([]byte(doc.Chunk)) + actualHash := fmt.Sprintf("%x", hash) + if actualHash != row.SourceVersion || actualHash != row.SourceSHA256 || doc.Chunk != row.Text || + (row.Metadata["ref"] != "" && row.Metadata["ref"] != doc.Ref) || + (row.Metadata["source"] != "" && row.Metadata["source"] != doc.Source) || + (doc.ProjectID != "" && string(doc.ProjectID) != row.ProjectID) { + return errors.New("project work source: knowledge version or scope changed") + } + if caps.authz != nil && caps.authz.hasV2() { + allowed, err := caps.authz.AuthorizeResource(extension.ResourceAuthzQuery{ + Actor: row.Owner, Action: "model-context", ResourceType: "knowledge", ResourceID: doc.ID, + Owner: string(doc.Owner), ProjectID: doc.ProjectID, Source: doc.Source, + }) + if err != nil || !allowed { + return errors.New("project work source: current knowledge authorization denied") + } + } else if string(doc.Owner) != row.Owner { + return errors.New("project work source: shared knowledge authorization unavailable") + } + return nil +} + +// A transient tool result has no external source authority to recheck. Its +// committed private outbox bytes and stored run are the only reliable source; +// RDM must keep it a candidate until a separately verifiable evidence source +// or human confirmation supports a formal project claim. +func verifyProjectTransientObservation(_ context.Context, st *store.Store, row store.ProjectWorkObservationRow) error { + toolSource := row.SourceKind == "tool-result" && row.Metadata["sourceStatus"] == "transient" && row.Metadata["toolName"] != "" + visibleAnalysis := row.SourceKind == "visible-analysis" && row.Metadata["stage"] == "assistant-final" && row.Metadata["partType"] != "" + if (!toolSource && !visibleAnalysis) || row.RunID == "" { + return errors.New("project work source: transient result lacks trusted provenance") + } + run, err := st.GetRunByID(core.ID(row.RunID)) + if err != nil || string(run.Owner) != row.Owner || string(run.ProjectID) != row.ProjectID || string(run.ConversationID) != row.ConversationID { + return errors.New("project work source: transient run binding unavailable") + } + return nil +} + +// preflightProjectMessage delivers this committed message's pending parts +// inline, then asks RDM to prove that every exact source part was analyzed for +// the pending action. A durable intake ACK alone never permits the action. +func preflightProjectMessage(ctx context.Context, st *store.Store, slot *projectWorkObserverSlot, + work func(context.Context, string, core.ID) (bool, error), messageID, action, expectedOwner, expectedConversation, expectedProject, targetID, targetVersion string, +) (extension.ProjectWorkDecision, error) { + pending := extension.ProjectWorkDecision{State: extension.ProjectWorkPending, Reason: "project source awaiting analysis"} + provider := slot.get() + if provider == nil || work == nil { + return pending, errors.New("extension: trusted project work observer unavailable") + } + rows, err := st.ProjectMessageObservations(messageID) + if err != nil { + return pending, err + } + if len(rows) == 0 { + return pending, errors.New("extension: committed project message has no source manifest") + } + if _, err := deliverProjectWorkRows(ctx, st, provider, work, rows); err != nil { + return pending, err + } + rows, err = st.ProjectMessageObservations(messageID) + if err != nil { + return pending, err + } + manifest := make([]extension.ProjectWorkSource, 0, len(rows)) + for _, row := range rows { + if row.State != "accepted" || row.Delivery != "complete" { + return pending, nil + } + manifest = append(manifest, extension.ProjectWorkSource{ + EventID: row.EventID, SourceKind: row.SourceKind, SourceID: row.SourceID, + SourceVersion: row.SourceVersion, SourceSHA256: row.SourceSHA256, PartIndex: row.PartIndex, + }) + } + first := rows[0] + if first.Owner != expectedOwner || first.ConversationID != expectedConversation || first.ProjectID != expectedProject { + return pending, errors.New("extension: source message is not bound to this actor, conversation, and project") + } + for _, row := range rows[1:] { + if row.Owner != first.Owner || row.ProjectID != first.ProjectID || row.ConversationID != first.ConversationID { + return pending, errors.New("extension: mixed project source manifest") + } + } + conv, err := st.GetConversation(first.Owner, first.ConversationID) + if err != nil || string(conv.ProjectID) != first.ProjectID { + return pending, errors.New("extension: project binding changed before preflight") + } + allowed, err := work(ctx, first.Owner, conv.ProjectID) + if err != nil || !allowed { + return pending, errors.New("extension: current project work denied before preflight") + } + raw, err := json.Marshal(manifest) + if err != nil { + return pending, err + } + watermark := fmt.Sprintf("%x", sha256.Sum256(raw)) + decision, err := provider.Preflight(ctx, extension.ProjectWorkAction{ + Actor: first.Owner, Owner: first.Owner, ProjectID: conv.ProjectID, + ConversationID: conv.ID, RunID: core.ID(first.RunID), Action: action, TargetID: targetID, TargetVersion: targetVersion, + Manifest: manifest, SourceWatermark: watermark, + }) + if err != nil { + return pending, err + } + switch decision.State { + case extension.ProjectWorkProceed: + if decision.DecisionID == "" || decision.SourceWatermark != watermark { + return pending, errors.New("extension: project preflight decision did not bind current sources") + } + case extension.ProjectWorkClarify, extension.ProjectWorkPending, extension.ProjectWorkDenied: + // A non-proceed decision is a real result, never an implied approval. + default: + return pending, errors.New("extension: invalid project preflight decision") + } + return decision, nil +} + +// preflightProjectRun binds all committed user, retrieval, tool, and visible +// analysis parts of one run before a later model or business action consumes +// them. Missing/partial parts keep the action pending. The domain provider +// decides whether the complete source set contains a blocking risk. +func preflightProjectRun(ctx context.Context, st *store.Store, slot *projectWorkObserverSlot, + work func(context.Context, string, core.ID) (bool, error), verify func(context.Context, store.ProjectWorkObservationRow) error, + runID, action string, +) (extension.ProjectWorkDecision, error) { + pending := extension.ProjectWorkDecision{State: extension.ProjectWorkPending, Reason: "run sources awaiting analysis"} + provider := slot.get() + if provider == nil || work == nil || verify == nil { + return pending, errors.New("extension: trusted project run observer unavailable") + } + run, err := st.GetRunByID(core.ID(runID)) + if err != nil || run.ProjectID == "" { + return pending, errors.New("extension: trusted project run unavailable") + } + rows, err := st.ProjectRunObservations(runID) + if err != nil { + return pending, err + } + if len(rows) == 0 { + return pending, errors.New("extension: project run has no trusted source manifest") + } + if _, err := deliverProjectWorkRows(ctx, st, provider, work, rows, verify); err != nil { + return pending, err + } + rows, err = st.ProjectRunObservations(runID) + if err != nil { + return pending, err + } + manifest := make([]extension.ProjectWorkSource, 0, len(rows)) + for _, row := range rows { + if row.State != "accepted" || row.Delivery != "complete" { + return pending, nil + } + if row.RunID != runID || row.Owner != string(run.Owner) || row.ProjectID != string(run.ProjectID) || row.ConversationID != string(run.ConversationID) { + return pending, errors.New("extension: mixed run source manifest") + } + if row.SourceKind != "user-message" && row.SourceKind != "assistant-message" { + if err := verify(ctx, row); err != nil { + return pending, err + } + } + manifest = append(manifest, extension.ProjectWorkSource{ + EventID: row.EventID, SourceKind: row.SourceKind, SourceID: row.SourceID, + SourceVersion: row.SourceVersion, SourceSHA256: row.SourceSHA256, PartIndex: row.PartIndex, + }) + } + conv, err := st.GetConversation(string(run.Owner), string(run.ConversationID)) + if err != nil || conv.ProjectID != run.ProjectID { + return pending, errors.New("extension: run project binding changed before preflight") + } + allowed, err := work(ctx, string(run.Owner), run.ProjectID) + if err != nil || !allowed { + return pending, errors.New("extension: current run project work denied") + } + raw, err := json.Marshal(manifest) + if err != nil { + return pending, err + } + watermark := fmt.Sprintf("%x", sha256.Sum256(raw)) + decision, err := provider.Preflight(ctx, extension.ProjectWorkAction{ + Actor: string(run.Owner), Owner: string(run.Owner), ProjectID: run.ProjectID, + ConversationID: run.ConversationID, RunID: run.ID, Action: action, + Manifest: manifest, SourceWatermark: watermark, + }) + if err != nil { + return pending, err + } + switch decision.State { + case extension.ProjectWorkProceed: + if decision.DecisionID == "" || decision.SourceWatermark != watermark { + return pending, errors.New("extension: run preflight decision did not bind current sources") + } + case extension.ProjectWorkClarify, extension.ProjectWorkPending, extension.ProjectWorkDenied: + default: + return pending, errors.New("extension: invalid project run preflight decision") + } + return decision, nil +} diff --git a/internal/app/project_work_observation_test.go b/internal/app/project_work_observation_test.go new file mode 100644 index 0000000..bac2f51 --- /dev/null +++ b/internal/app/project_work_observation_test.go @@ -0,0 +1,332 @@ +package app + +import ( + "context" + "crypto/sha256" + "fmt" + "path/filepath" + "testing" + + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" + core "agent-system-core" +) + +func workObserverForTest() extension.ProjectWorkObserver { + return extension.ProjectWorkObserver{ + Observe: func(context.Context, extension.WorkObservation) (extension.WorkObservationResult, error) { + return extension.WorkObservationResult{State: extension.WorkObservationPending}, nil + }, + Preflight: func(context.Context, extension.ProjectWorkAction) (extension.ProjectWorkDecision, error) { + return extension.ProjectWorkDecision{State: extension.ProjectWorkPending}, nil + }, + Respond: func(context.Context, extension.ProjectRiskAnswer) (extension.ProjectWorkDecision, error) { + return extension.ProjectWorkDecision{State: extension.ProjectWorkPending}, nil + }, + } +} + +func TestProjectWorkDeliveryRechecksCurrentAuthorityAndACKsOnce(t *testing.T) { + st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "work.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if err := st.EnsureUser("alice", "Alice"); err != nil { + t.Fatal(err) + } + project, err := st.CreateProject("alice", "P") + if err != nil { + t.Fatal(err) + } + conv, err := st.CreateConversation("alice", string(project.ID), "Work", "") + if err != nil { + t.Fatal(err) + } + msg := core.Message{ConversationID: conv.ID, Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "要求效率和成本都低于人工"}}} + if err := st.AppendMessageWithRequestAccountChecked("alice", false, &msg, "req-1"); err != nil { + t.Fatal(err) + } + called := 0 + slot := &projectWorkObserverSlot{} + observer := workObserverForTest() + observer.Observe = func(_ context.Context, source extension.WorkObservation) (extension.WorkObservationResult, error) { + called++ + if source.Owner != "alice" || source.Actor != "alice" || source.ProjectID != project.ID || source.SourceID != string(msg.ID) || source.Text != msg.Parts[0].Text || source.Scope != "private" { + t.Fatalf("provider received forged or wrong source: %+v", source) + } + return extension.WorkObservationResult{State: extension.WorkObservationAccepted, ReceiptID: "rdm-receipt-1"}, nil + } + if err := (pluginRegistry{pluginID: "rdm", caps: hostcaps{workObserver: slot}}).RegisterProjectWorkObserver(observer); err != nil { + t.Fatal(err) + } + denied := func(context.Context, string, core.ID) (bool, error) { return false, nil } + if _, err := deliverProjectWorkObservations(context.Background(), st, slot, denied, 10); err == nil || called != 0 { + t.Fatalf("revoked source was disclosed: calls=%d err=%v", called, err) + } + allowed := func(_ context.Context, actor string, id core.ID) (bool, error) { + return actor == "alice" && id == project.ID, nil + } + if delivered, err := deliverProjectWorkObservations(context.Background(), st, slot, allowed, 10); err != nil || delivered != 1 || called != 1 { + t.Fatalf("authorized source delivery failed: delivered=%d calls=%d err=%v", delivered, called, err) + } + if delivered, err := deliverProjectWorkObservations(context.Background(), st, slot, allowed, 10); err != nil || delivered != 0 || called != 1 { + t.Fatalf("delivered source was replayed without need: delivered=%d calls=%d err=%v", delivered, called, err) + } +} + +func TestProjectMessagePreflightRequiresAnalysisAndExactManifest(t *testing.T) { + st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "preflight.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if err := st.EnsureUser("alice", "Alice"); err != nil { + t.Fatal(err) + } + project, err := st.CreateProject("alice", "P") + if err != nil { + t.Fatal(err) + } + conv, err := st.CreateConversation("alice", string(project.ID), "Work", "") + if err != nil { + t.Fatal(err) + } + msg := core.Message{ConversationID: conv.ID, Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "要求效率和成本都低于人工"}}} + if err := st.AppendMessageWithRequestAccountChecked("alice", false, &msg, "req-1"); err != nil { + t.Fatal(err) + } + ready, observations, decisions := false, 0, 0 + observer := workObserverForTest() + observer.Observe = func(context.Context, extension.WorkObservation) (extension.WorkObservationResult, error) { + observations++ + return extension.WorkObservationResult{State: extension.WorkObservationAccepted, ReceiptID: "intake-1"}, nil + } + observer.Preflight = func(_ context.Context, action extension.ProjectWorkAction) (extension.ProjectWorkDecision, error) { + decisions++ + if action.Actor != "alice" || action.ProjectID != project.ID || action.ConversationID != conv.ID || action.RunID != "" || action.Action != "conversation.run" || len(action.Manifest) != 1 { + t.Fatalf("preflight did not receive exact trusted source manifest: %+v", action) + } + if !ready { + return extension.ProjectWorkDecision{State: extension.ProjectWorkPending}, nil + } + return extension.ProjectWorkDecision{State: extension.ProjectWorkProceed, DecisionID: "decision-1", SourceWatermark: action.SourceWatermark}, nil + } + slot := &projectWorkObserverSlot{provider: &observer} + work := func(_ context.Context, actor string, id core.ID) (bool, error) { + return actor == "alice" && id == project.ID, nil + } + decision, err := preflightProjectMessage(context.Background(), st, slot, work, string(msg.ID), "conversation.run", "alice", string(conv.ID), string(project.ID), "conversation.run", "v1") + if err != nil || decision.State != extension.ProjectWorkPending || observations != 1 || decisions != 1 { + t.Fatalf("durable intake was confused with completed analysis: %+v observations=%d decisions=%d err=%v", decision, observations, decisions, err) + } + ready = true + decision, err = preflightProjectMessage(context.Background(), st, slot, work, string(msg.ID), "conversation.run", "alice", string(conv.ID), string(project.ID), "conversation.run", "v1") + if err != nil || decision.State != extension.ProjectWorkProceed || observations != 1 || decisions != 2 { + t.Fatalf("same source did not proceed after domain analysis: %+v observations=%d decisions=%d err=%v", decision, observations, decisions, err) + } + if _, err := st.DB().Exec(`UPDATE messages SET parts=? WHERE id=?`, `[{"type":"text","text":"changed"}]`, msg.ID); err != nil { + t.Fatal(err) + } + decision, err = preflightProjectMessage(context.Background(), st, slot, work, string(msg.ID), "conversation.run", "alice", string(conv.ID), string(project.ID), "conversation.run", "v1") + if err != nil || decision.State != extension.ProjectWorkPending || decisions != 2 { + t.Fatalf("source changed after ACK but stale decision still passed: %+v decisions=%d err=%v", decision, decisions, err) + } + partial := core.Message{ConversationID: conv.ID, Role: core.RoleUser, Parts: []core.Part{{Type: core.PartArtifactRef, Artifact: &core.ArtifactRef{ArtifactID: "file-1"}}}} + if err := st.AppendMessageWithRequestAccountChecked("alice", false, &partial, "req-2"); err != nil { + t.Fatal(err) + } + decision, err = preflightProjectMessage(context.Background(), st, slot, work, string(partial.ID), "conversation.run", "alice", string(conv.ID), string(project.ID), "conversation.run", "v1") + if err != nil || decision.State != extension.ProjectWorkPending || decisions != 2 { + t.Fatalf("partial attachment reached project action preflight: %+v decisions=%d err=%v", decision, decisions, err) + } +} + +func TestRetrievalObservationRechecksCurrentSourceBeforeDisclosure(t *testing.T) { + st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "source.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if err := st.EnsureUser("alice", "Alice"); err != nil { + t.Fatal(err) + } + project, err := st.CreateProject("alice", "P") + if err != nil { + t.Fatal(err) + } + conv, err := st.CreateConversation("alice", string(project.ID), "Work", "") + if err != nil { + t.Fatal(err) + } + run := core.Run{ConversationID: conv.ID, ProjectID: project.ID, Owner: "alice", Kind: core.RunConversation, Status: core.RunRunning, Phase: core.PhaseThinking} + if err := st.CreateRun(&run); err != nil { + t.Fatal(err) + } + doc := core.KnowledgeDoc{ID: "knowledge-1", Owner: "alice", ProjectID: project.ID, Source: "upload", Ref: "https://example.test/a", Chunk: "actual source"} + if err := st.AddKnowledge(doc); err != nil { + t.Fatal(err) + } + observation := store.RunWorkObservationInput{RunID: string(run.ID), SourceKind: "retrieval-hit", SourceID: string(doc.ID), + SourceVersion: fmt.Sprintf("%x", sha256.Sum256([]byte(doc.Chunk))), Text: doc.Chunk, Delivery: "complete", + Metadata: map[string]string{"ref": doc.Ref, "source": doc.Source}} + if _, err := st.AppendRunWorkObservation(observation); err != nil { + t.Fatal(err) + } + called := 0 + provider := workObserverForTest() + provider.Observe = func(context.Context, extension.WorkObservation) (extension.WorkObservationResult, error) { + called++ + return extension.WorkObservationResult{State: extension.WorkObservationAccepted, ReceiptID: "rdm-1"}, nil + } + slot := &projectWorkObserverSlot{provider: &provider} + work := func(context.Context, string, core.ID) (bool, error) { return true, nil } + verify := func(ctx context.Context, row store.ProjectWorkObservationRow) error { + return verifyProjectKnowledgeObservation(ctx, hostcaps{st: st}, row) + } + if n, err := deliverProjectWorkObservations(context.Background(), st, slot, work, 10, verify); err != nil || n != 1 || called != 1 { + t.Fatalf("current owned source was not delivered: n=%d calls=%d err=%v", n, called, err) + } + if _, err := st.AppendRunWorkObservation(observation); err != nil { + t.Fatal(err) + } + if _, err := st.DB().Exec(`DELETE FROM knowledge WHERE id=?`, doc.ID); err != nil { + t.Fatal(err) + } + if n, err := deliverProjectWorkObservations(context.Background(), st, slot, work, 10, verify); err == nil || n != 0 || called != 1 { + t.Fatalf("deleted source text crossed into observer: n=%d calls=%d err=%v", n, called, err) + } +} + +func TestTransientToolSourceKeepsCurrentRunAndCandidateStatus(t *testing.T) { + st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "tool.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if err := st.EnsureUser("alice", "Alice"); err != nil { + t.Fatal(err) + } + project, err := st.CreateProject("alice", "P") + if err != nil { + t.Fatal(err) + } + conv, err := st.CreateConversation("alice", string(project.ID), "Work", "") + if err != nil { + t.Fatal(err) + } + run := core.Run{ConversationID: conv.ID, ProjectID: project.ID, Owner: "alice", Kind: core.RunConversation, Status: core.RunRunning, Phase: core.PhaseThinking} + if err := st.CreateRun(&run); err != nil { + t.Fatal(err) + } + _, err = st.AppendRunWorkObservation(store.RunWorkObservationInput{RunID: string(run.ID), SourceKind: "tool-result", SourceID: "call-1", SourceVersion: "v1", + Text: "实际工具返回", Delivery: "complete", Metadata: map[string]string{"sourceStatus": "transient", "toolName": "lookup"}}) + if err != nil { + t.Fatal(err) + } + rows, err := st.PendingProjectWorkObservations(10) + if err != nil || len(rows) != 1 { + t.Fatalf("tool source missing: %+v %v", rows, err) + } + if err := verifyProjectTransientObservation(context.Background(), st, rows[0]); err != nil { + t.Fatalf("committed private tool source rejected: %v", err) + } + if _, err := st.DB().Exec(`DELETE FROM runs WHERE id=?`, run.ID); err != nil { + t.Fatal(err) + } + if err := verifyProjectTransientObservation(context.Background(), st, rows[0]); err == nil { + t.Fatal("deleted run still authorized its transient source") + } +} + +func TestRunPreflightWaitsForAllCommittedSourceParts(t *testing.T) { + st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "run-preflight.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if err := st.EnsureUser("alice", "Alice"); err != nil { + t.Fatal(err) + } + project, err := st.CreateProject("alice", "P") + if err != nil { + t.Fatal(err) + } + conv, err := st.CreateConversation("alice", string(project.ID), "Work", "") + if err != nil { + t.Fatal(err) + } + run := core.Run{ConversationID: conv.ID, ProjectID: project.ID, Owner: "alice", Kind: core.RunConversation, Status: core.RunRunning, Phase: core.PhaseThinking} + if err := st.CreateRun(&run); err != nil { + t.Fatal(err) + } + msg := core.Message{ConversationID: conv.ID, Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "检查成本和效率"}}} + if err := st.AppendMessageWithRequestAccountChecked("alice", false, &msg, "req"); err != nil { + t.Fatal(err) + } + if err := st.SetMessageRunIDAccountChecked("alice", string(msg.ID), string(run.ID), false); err != nil { + t.Fatal(err) + } + if _, err := st.AppendRunWorkObservation(store.RunWorkObservationInput{RunID: string(run.ID), SourceKind: "tool-result", SourceID: "call-1", + SourceVersion: "v1", Text: "工具中的测量结果", Delivery: "complete", Metadata: map[string]string{"sourceStatus": "transient", "toolName": "measure"}}); err != nil { + t.Fatal(err) + } + ready, preflights, observed, verified := false, 0, 0, 0 + provider := workObserverForTest() + provider.Observe = func(_ context.Context, observation extension.WorkObservation) (extension.WorkObservationResult, error) { + observed++ + if observation.AuthorizationEpoch == "" { + t.Fatal("observation omitted the current authorization epoch") + } + return extension.WorkObservationResult{State: extension.WorkObservationAccepted, ReceiptID: fmt.Sprintf("receipt-%d", observed)}, nil + } + provider.Preflight = func(_ context.Context, action extension.ProjectWorkAction) (extension.ProjectWorkDecision, error) { + preflights++ + if action.RunID != run.ID || action.ProjectID != project.ID || len(action.Manifest) != 2 { + t.Fatalf("run manifest omitted a committed source: %+v", action) + } + if !ready { + return extension.ProjectWorkDecision{State: extension.ProjectWorkPending}, nil + } + return extension.ProjectWorkDecision{State: extension.ProjectWorkProceed, DecisionID: "approved-risk-check", SourceWatermark: action.SourceWatermark}, nil + } + slot := &projectWorkObserverSlot{provider: &provider} + work := func(context.Context, string, core.ID) (bool, error) { return true, nil } + verify := func(ctx context.Context, row store.ProjectWorkObservationRow) error { + verified++ + return verifyProjectTransientObservation(ctx, st, row) + } + decision, err := preflightProjectRun(context.Background(), st, slot, work, verify, string(run.ID), "model.consume.tool") + if err != nil || decision.State != extension.ProjectWorkPending || observed != 2 || preflights != 1 || verified != 2 { + t.Fatalf("intake ACK was mistaken for finished risk analysis: %+v observed=%d preflights=%d err=%v", decision, observed, preflights, err) + } + ready = true + decision, err = preflightProjectRun(context.Background(), st, slot, work, verify, string(run.ID), "model.consume.tool") + if err != nil || decision.State != extension.ProjectWorkProceed || observed != 2 || preflights != 2 || verified != 3 { + t.Fatalf("fully analyzed run was not released once: %+v observed=%d verified=%d preflights=%d err=%v", decision, observed, verified, preflights, err) + } +} + +func TestProjectWorkObservationRegistrationIsTrustedAndUnique(t *testing.T) { + slot := &projectWorkObserverSlot{} + trusted := pluginRegistry{pluginID: "rdm", caps: hostcaps{workObserver: slot}} + other := pluginRegistry{pluginID: "other", caps: hostcaps{workObserver: slot}} + if err := other.RegisterProjectWorkObserver(workObserverForTest()); err == nil { + t.Fatal("untrusted plugin registered project observer") + } + if err := (compiledRegistry{}).RegisterProjectWorkObserver(workObserverForTest()); err == nil { + t.Fatal("declarative plugin registered live observer callback") + } + if err := trusted.RegisterProjectWorkObserver(extension.ProjectWorkObserver{}); err == nil { + t.Fatal("partial observer registered") + } + if err := trusted.RegisterProjectWorkObserver(workObserverForTest()); err != nil { + t.Fatal(err) + } + if err := trusted.RegisterProjectWorkObserver(workObserverForTest()); err == nil { + t.Fatal("second observer replaced the trusted provider") + } + if got := slot.get(); got == nil || got.Observe == nil || got.Preflight == nil || got.Respond == nil { + t.Fatal("registered observer not available to host") + } +} diff --git a/internal/app/regulations.go b/internal/app/regulations.go new file mode 100644 index 0000000..f8e2061 --- /dev/null +++ b/internal/app/regulations.go @@ -0,0 +1,220 @@ +package app + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "strings" + "sync" + "unicode/utf8" + + "agent-system-backend/pkg/extension" +) + +// EXT-20:共享法规检索与网络证据。提供方由受信插件 kbreg 进程内注册;基座只 +// 填写调用方身份、做查询最小化校验并审计,不改写提供方返回的状态。 + +const ( + regulationsPluginID = "kbreg" + maxWebQueryRunes = 256 + + auditRegulationsSearch = "regulations.search" + auditRegulationsEvidence = "regulations.evidence" + auditWebSearch = "web.search" + auditWebCapture = "web.capture" + + regulationsAuditInvalid = "invalid" + regulationsAuditError = "error" + regulationsAuditOK = "ok" +) + +var ( + errRegulationsSchema = errors.New("extension: unsupported regulations schema version") + errWebQueryInvalid = errors.New("extension: web search query must be 1-256 characters") +) + +type regulationsSlot struct { + mu sync.RWMutex + provider extension.RegulationsProvider +} + +func (s *regulationsSlot) register(pluginID string, p extension.RegulationsProvider) error { + if p == nil || pluginID != regulationsPluginID { + return errors.New("extension: regulations provider requires trusted kbreg registration") + } + s.mu.Lock() + defer s.mu.Unlock() + if s.provider != nil { + return errors.New("extension: regulations provider already registered") + } + s.provider = p + return nil +} + +func (s *regulationsSlot) get() extension.RegulationsProvider { + if s == nil { + return nil + } + s.mu.RLock() + defer s.mu.RUnlock() + return s.provider +} + +// regulationsAudit is the only content written to the audit trail: never the +// query text, excerpts or captured page text. +type regulationsAudit struct { + Revision string `json:"revision,omitempty"` + Count int `json:"count"` + State string `json:"state"` + QuerySHA256 string `json:"querySha256,omitempty"` +} + +func regulationsQueryDigest(query string) string { + if query == "" { + return "" + } + sum := sha256.Sum256([]byte(query)) + return hex.EncodeToString(sum[:]) +} + +// regulations resolves the provider and the trusted caller. Without an audit +// store the capability is unavailable: calls must never run unaudited. +func (h inprocHost) regulations() (extension.RegulationsProvider, extension.RegulationsCaller, error) { + if err := h.requireEffective(); err != nil { + return nil, extension.RegulationsCaller{}, err + } + p := h.caps.regulations.get() + if p == nil || h.caps.st == nil { + return nil, extension.RegulationsCaller{}, extension.ErrRegulationsUnavailable + } + return p, extension.RegulationsCaller{PluginID: h.pluginID, Actor: h.actor, Owner: h.owner}, nil +} + +// auditRegulations appends one event per call. A call error takes precedence; +// otherwise an audit failure withholds the result. +func (h inprocHost) auditRegulations(action string, rec regulationsAudit, callErr error) error { + if callErr != nil && rec.State == "" { + rec.State = regulationsAuditError + } + evidence, err := json.Marshal(rec) + if err == nil { + err = h.Audit(action, regulationsPluginID, string(evidence)) + } + if callErr != nil { + return callErr + } + return err +} + +func (h inprocHost) SearchRegulations(ctx context.Context, req extension.RegulationsSearchRequest) (extension.RegulationsSearchResult, error) { + p, caller, err := h.regulations() + if err != nil { + return extension.RegulationsSearchResult{}, err + } + rec := regulationsAudit{Revision: req.CorpusRevision, QuerySHA256: regulationsQueryDigest(req.Query)} + if req.SchemaVersion != extension.RegulationsSchemaVersion { + rec.State = regulationsAuditInvalid + return extension.RegulationsSearchResult{}, h.auditRegulations(auditRegulationsSearch, rec, errRegulationsSchema) + } + res, err := p.SearchRegulations(ctx, caller, req) + if err == nil { + rec.Revision, rec.Count, rec.State = res.CorpusRevision, len(res.Hits), res.State + } + if err := h.auditRegulations(auditRegulationsSearch, rec, err); err != nil { + return extension.RegulationsSearchResult{}, err + } + return res, nil +} + +func (h inprocHost) ResolveRegulationEvidence(ctx context.Context, req extension.RegulationEvidenceRequest) (extension.RegulationEvidence, error) { + p, caller, err := h.regulations() + if err != nil { + return extension.RegulationEvidence{}, err + } + rec := regulationsAudit{Revision: req.CorpusRevision} + if req.SchemaVersion != extension.RegulationsSchemaVersion { + rec.State = regulationsAuditInvalid + return extension.RegulationEvidence{}, h.auditRegulations(auditRegulationsEvidence, rec, errRegulationsSchema) + } + ev, err := p.ResolveRegulationEvidence(ctx, caller, req) + if err == nil { + rec.Count, rec.State = 1, regulationsAuditOK + if ev.CorpusRevision != "" { + rec.Revision = ev.CorpusRevision + } + } + if err := h.auditRegulations(auditRegulationsEvidence, rec, err); err != nil { + return extension.RegulationEvidence{}, err + } + return ev, nil +} + +// webQueryExcluded reports whether query contains any non-empty exclusion +// term, case-insensitively. +func webQueryExcluded(query string, exclusions []string) bool { + lower := strings.ToLower(query) + for _, term := range exclusions { + term = strings.ToLower(strings.TrimSpace(term)) + if term != "" && strings.Contains(lower, term) { + return true + } + } + return false +} + +func (h inprocHost) SearchWeb(ctx context.Context, req extension.WebSearchRequest) (extension.WebSearchResult, error) { + p, caller, err := h.regulations() + if err != nil { + return extension.WebSearchResult{}, err + } + req.Query = strings.TrimSpace(req.Query) + rec := regulationsAudit{QuerySHA256: regulationsQueryDigest(req.Query)} + if req.SchemaVersion != extension.RegulationsSchemaVersion { + rec.State = regulationsAuditInvalid + return extension.WebSearchResult{}, h.auditRegulations(auditWebSearch, rec, errRegulationsSchema) + } + if req.Query == "" || !utf8.ValidString(req.Query) || utf8.RuneCountInString(req.Query) > maxWebQueryRunes { + rec.State = regulationsAuditInvalid + return extension.WebSearchResult{}, h.auditRegulations(auditWebSearch, rec, errWebQueryInvalid) + } + if webQueryExcluded(req.Query, req.Exclusions) { + res := extension.WebSearchResult{State: extension.WebSearchStateRejected, Reason: "query contains an excluded term"} + rec.State = res.State + if err := h.auditRegulations(auditWebSearch, rec, nil); err != nil { + return extension.WebSearchResult{}, err + } + return res, nil + } + res, err := p.SearchWeb(ctx, caller, req) + if err == nil { + rec.Count, rec.State = len(res.Results), res.State + } + if err := h.auditRegulations(auditWebSearch, rec, err); err != nil { + return extension.WebSearchResult{}, err + } + return res, nil +} + +func (h inprocHost) CaptureWeb(ctx context.Context, req extension.WebCaptureRequest) (extension.WebCapture, error) { + p, caller, err := h.regulations() + if err != nil { + return extension.WebCapture{}, err + } + rec := regulationsAudit{QuerySHA256: regulationsQueryDigest(strings.TrimSpace(req.SearchQuery))} + if req.SchemaVersion != extension.RegulationsSchemaVersion { + rec.State = regulationsAuditInvalid + return extension.WebCapture{}, h.auditRegulations(auditWebCapture, rec, errRegulationsSchema) + } + capture, err := p.CaptureWeb(ctx, caller, req) + if err == nil { + rec.Count, rec.State = len(capture.Candidates), capture.State + } + if err := h.auditRegulations(auditWebCapture, rec, err); err != nil { + return extension.WebCapture{}, err + } + return capture, nil +} + +var _ extension.RegulationsHost = inprocHost{} diff --git a/internal/app/regulations_compat_test.go b/internal/app/regulations_compat_test.go new file mode 100644 index 0000000..e91fc18 --- /dev/null +++ b/internal/app/regulations_compat_test.go @@ -0,0 +1,134 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "path/filepath" + "testing" + "time" + + agentcommand "agent-command" + "agent-system-backend/internal/cmdbus" + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" +) + +// compatRegulationsPlugin stands in for the external kbreg repository: it uses +// only pkg/extension types and registers through the optional Registrar. +type compatRegulationsPlugin struct{ seen []extension.RegulationsCaller } + +func (p *compatRegulationsPlugin) Register(reg extension.Registry) error { + registrar, ok := reg.(extension.RegulationsRegistrar) + if !ok { + return errors.New("regulations registrar unavailable") + } + return registrar.RegisterRegulationsProvider(p) +} + +func (p *compatRegulationsPlugin) SearchRegulations(_ context.Context, c extension.RegulationsCaller, req extension.RegulationsSearchRequest) (extension.RegulationsSearchResult, error) { + p.seen = append(p.seen, c) + return extension.RegulationsSearchResult{CorpusRevision: "rev-compat", State: extension.RegulationsStateOK, + Hits: []extension.RegulationHit{{RegulationID: "r", EditionID: "e", NodeID: "n", Disclosure: extension.RegulationDisclosureExcerpt}}}, nil +} + +func (p *compatRegulationsPlugin) ResolveRegulationEvidence(_ context.Context, c extension.RegulationsCaller, req extension.RegulationEvidenceRequest) (extension.RegulationEvidence, error) { + p.seen = append(p.seen, c) + return extension.RegulationEvidence{CorpusRevision: req.CorpusRevision, NodeID: req.NodeID, Disclosure: extension.RegulationDisclosureFull, HashVerified: true}, nil +} + +func (p *compatRegulationsPlugin) SearchWeb(_ context.Context, c extension.RegulationsCaller, req extension.WebSearchRequest) (extension.WebSearchResult, error) { + p.seen = append(p.seen, c) + return extension.WebSearchResult{Provider: "compat", SentQuery: req.Query, State: extension.WebSearchStateEmpty}, nil +} + +func (p *compatRegulationsPlugin) CaptureWeb(_ context.Context, c extension.RegulationsCaller, req extension.WebCaptureRequest) (extension.WebCapture, error) { + p.seen = append(p.seen, c) + return extension.WebCapture{FinalURL: req.URL, State: extension.WebCaptureStateFailed, Reason: "offline"}, nil +} + +// compatRegulationsConsumer is a second compiled plugin that reaches the +// provider only through the dispatch Host type assertion. +func compatRegulationsConsumer(reg extension.Registry) error { + return reg.AddCommandHandler(json.RawMessage(`{"id":"consumer.regs","title":"regs"}`), + func(ctx context.Context, h extension.Host, req extension.Request) (any, error) { + regs, ok := h.(extension.RegulationsHost) + if !ok { + return nil, errors.New("regulations host capability missing") + } + search, err := regs.SearchRegulations(ctx, extension.RegulationsSearchRequest{SchemaVersion: extension.RegulationsSchemaVersion, Query: "注册检验"}) + if err != nil { + return nil, err + } + web, err := regs.SearchWeb(ctx, extension.WebSearchRequest{SchemaVersion: extension.RegulationsSchemaVersion, Query: "注册检验 指导原则"}) + if err != nil { + return nil, err + } + return map[string]any{"revision": search.CorpusRevision, "webState": web.State}, nil + }) +} + +func newRegulationsCompatApp(t *testing.T, plugins ...extension.InProc) (*cmdbus.Bus, *store.Store, hostcaps) { + t.Helper() + extension.ResetCompiled() + t.Cleanup(extension.ResetCompiled) + for _, p := range plugins { + extension.RegisterCompiled(p) + } + st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "compat.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + if err := st.EnsureUser("alice", ""); err != nil { + t.Fatal(err) + } + bus := cmdbus.New(st, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() }) + caps := hostcaps{st: st, regulations: ®ulationsSlot{}} + applyCompiled("", bus, caps) + return bus, st, caps +} + +func TestRegulationsCompiledPluginCompatibility(t *testing.T) { + provider := &compatRegulationsPlugin{} + impostor := &compatRegulationsPlugin{} + bus, st, caps := newRegulationsCompatApp(t, + extension.InProc{ID: "impostor", Register: impostor.Register}, + extension.InProc{ID: "kbreg", Register: provider.Register}, + extension.InProc{ID: "consumer", Register: compatRegulationsConsumer}, + ) + if caps.regulations.get() != extension.RegulationsProvider(provider) { + t.Fatal("compiled kbreg provider was not the registered provider") + } + resp := bus.Dispatch(context.Background(), cmdbus.Request{ID: "consumer.regs", Owner: "alice", Actor: "alice", Entrance: "button"}) + if resp.Receipt.State != agentcommand.StateCommitted { + t.Fatalf("dispatch = %+v", resp.Receipt) + } + payload, _ := resp.Payload.(map[string]any) + if payload["revision"] != "rev-compat" || payload["webState"] != extension.WebSearchStateEmpty { + t.Fatalf("payload = %+v", resp.Payload) + } + want := extension.RegulationsCaller{PluginID: "consumer", Actor: "alice", Owner: "alice"} + if len(provider.seen) != 2 || provider.seen[0] != want || provider.seen[1] != want || len(impostor.seen) != 0 { + t.Fatalf("provider callers = %+v impostor = %+v", provider.seen, impostor.seen) + } + evs, err := st.ListAuditEvents("alice", 20) + if err != nil { + t.Fatal(err) + } + actions := map[string]bool{} + for _, ev := range evs { + actions[ev.Action] = true + } + if !actions[auditRegulationsSearch] || !actions[auditWebSearch] { + t.Fatalf("host audit missing: %+v", evs) + } +} + +func TestRegulationsCompiledConsumerWithoutProvider(t *testing.T) { + bus, _, _ := newRegulationsCompatApp(t, extension.InProc{ID: "consumer", Register: compatRegulationsConsumer}) + resp := bus.Dispatch(context.Background(), cmdbus.Request{ID: "consumer.regs", Owner: "alice", Actor: "alice", Entrance: "button"}) + if resp.Receipt.State == agentcommand.StateCommitted { + t.Fatalf("consumer succeeded without a regulations provider: %+v", resp.Payload) + } +} diff --git a/internal/app/regulations_test.go b/internal/app/regulations_test.go new file mode 100644 index 0000000..7d279eb --- /dev/null +++ b/internal/app/regulations_test.go @@ -0,0 +1,347 @@ +package app + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" +) + +type fakeRegulationsProvider struct { + callers []extension.RegulationsCaller + webQueries []string + search extension.RegulationsSearchResult + evidence extension.RegulationEvidence + web extension.WebSearchResult + capture extension.WebCapture + err error +} + +func (f *fakeRegulationsProvider) SearchRegulations(_ context.Context, c extension.RegulationsCaller, _ extension.RegulationsSearchRequest) (extension.RegulationsSearchResult, error) { + f.callers = append(f.callers, c) + return f.search, f.err +} + +func (f *fakeRegulationsProvider) ResolveRegulationEvidence(_ context.Context, c extension.RegulationsCaller, _ extension.RegulationEvidenceRequest) (extension.RegulationEvidence, error) { + f.callers = append(f.callers, c) + return f.evidence, f.err +} + +func (f *fakeRegulationsProvider) SearchWeb(_ context.Context, c extension.RegulationsCaller, req extension.WebSearchRequest) (extension.WebSearchResult, error) { + f.callers = append(f.callers, c) + f.webQueries = append(f.webQueries, req.Query) + return f.web, f.err +} + +func (f *fakeRegulationsProvider) CaptureWeb(_ context.Context, c extension.RegulationsCaller, _ extension.WebCaptureRequest) (extension.WebCapture, error) { + f.callers = append(f.callers, c) + return f.capture, f.err +} + +func newRegulationsTestHost(t *testing.T) (inprocHost, *store.Store) { + t.Helper() + st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "regulations.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + if err := st.EnsureUser("alice", ""); err != nil { + t.Fatal(err) + } + caps := hostcaps{st: st, regulations: ®ulationsSlot{}} + return inprocHost{caps: caps, pluginID: "rdm", owner: "alice", actor: "alice"}, st +} + +func registerFakeRegulations(t *testing.T, h inprocHost) *fakeRegulationsProvider { + t.Helper() + p := &fakeRegulationsProvider{} + if err := (pluginRegistry{pluginID: "kbreg", caps: h.caps}).RegisterRegulationsProvider(p); err != nil { + t.Fatal(err) + } + return p +} + +const regV = extension.RegulationsSchemaVersion + +func callAllRegulations(h inprocHost, version int) []error { + ctx := context.Background() + _, e1 := h.SearchRegulations(ctx, extension.RegulationsSearchRequest{SchemaVersion: version, Query: "医疗器械 临床评价"}) + _, e2 := h.ResolveRegulationEvidence(ctx, extension.RegulationEvidenceRequest{SchemaVersion: version, CorpusRevision: "rev-1", NodeID: "n1"}) + _, e3 := h.SearchWeb(ctx, extension.WebSearchRequest{SchemaVersion: version, Query: "临床评价 指导原则"}) + _, e4 := h.CaptureWeb(ctx, extension.WebCaptureRequest{SchemaVersion: version, URL: "https://www.nmpa.gov.cn/x", SearchQuery: "临床评价 指导原则"}) + return []error{e1, e2, e3, e4} +} + +func TestRegulationsUnavailableWithoutProvider(t *testing.T) { + h, st := newRegulationsTestHost(t) + for i, err := range callAllRegulations(h, regV) { + if !errors.Is(err, extension.ErrRegulationsUnavailable) { + t.Fatalf("method %d without provider: %v", i, err) + } + } + h.caps.regulations = nil + for i, err := range callAllRegulations(h, regV) { + if !errors.Is(err, extension.ErrRegulationsUnavailable) { + t.Fatalf("method %d without slot: %v", i, err) + } + } + if evs, err := st.ListAuditEvents("alice", 10); err != nil || len(evs) != 0 { + t.Fatalf("unavailable calls audited: %+v %v", evs, err) + } +} + +func TestRegulationsRegistrationOnlyTrustedKbregOnce(t *testing.T) { + h, _ := newRegulationsTestHost(t) + first, second := &fakeRegulationsProvider{}, &fakeRegulationsProvider{} + if err := (pluginRegistry{pluginID: "rdm", caps: h.caps}).RegisterRegulationsProvider(first); err == nil { + t.Fatal("non-kbreg plugin registered a regulations provider") + } + kbreg := pluginRegistry{pluginID: "kbreg", caps: h.caps} + if err := kbreg.RegisterRegulationsProvider(nil); err == nil { + t.Fatal("nil provider registered") + } + if err := kbreg.RegisterRegulationsProvider(first); err != nil { + t.Fatal(err) + } + if err := kbreg.RegisterRegulationsProvider(second); err == nil { + t.Fatal("duplicate registration accepted") + } + if h.caps.regulations.get() != first { + t.Fatal("duplicate registration replaced the first provider") + } + if err := (pluginRegistry{pluginID: "kbreg"}).RegisterRegulationsProvider(first); err == nil { + t.Fatal("registration without a host slot accepted") + } +} + +func TestRegulationsDeclarativeRegistrationRejected(t *testing.T) { + h, _ := newRegulationsTestHost(t) + p := &fakeRegulationsProvider{} + if err := (compiledRegistry{}).RegisterRegulationsProvider(p); err == nil { + t.Fatal("declarative registry accepted a regulations provider") + } + if err := (recordRegistry{Registry: pluginRegistry{pluginID: "kbreg", caps: h.caps}}).RegisterRegulationsProvider(p); err != nil { + t.Fatalf("record registry did not forward kbreg registration: %v", err) + } + if h.caps.regulations.get() != p { + t.Fatal("forwarded registration did not fill the slot") + } +} + +func TestRegulationsCallerFilledFromDispatchContext(t *testing.T) { + h, _ := newRegulationsTestHost(t) + h.actor = "alice-session" + p := registerFakeRegulations(t, h) + p.web.State = extension.WebSearchStateOK + for i, err := range callAllRegulations(h, regV) { + if err != nil { + t.Fatalf("method %d: %v", i, err) + } + } + want := extension.RegulationsCaller{PluginID: "rdm", Actor: "alice-session", Owner: "alice"} + if len(p.callers) != 4 { + t.Fatalf("provider calls = %d", len(p.callers)) + } + for i, got := range p.callers { + if got != want { + t.Fatalf("call %d caller = %+v, want %+v", i, got, want) + } + } +} + +func TestRegulationsRejectsOtherSchemaVersions(t *testing.T) { + h, _ := newRegulationsTestHost(t) + p := registerFakeRegulations(t, h) + for _, version := range []int{0, regV + 1} { + for i, err := range callAllRegulations(h, version) { + if !errors.Is(err, errRegulationsSchema) { + t.Fatalf("schema %d method %d: %v", version, i, err) + } + } + } + if len(p.callers) != 0 { + t.Fatalf("provider reached with unsupported schema: %d calls", len(p.callers)) + } +} + +func TestRegulationsWebQueryBounds(t *testing.T) { + h, _ := newRegulationsTestHost(t) + p := registerFakeRegulations(t, h) + ctx := context.Background() + for _, q := range []string{"", " \t\n", strings.Repeat("法", maxWebQueryRunes+1)} { + if _, err := h.SearchWeb(ctx, extension.WebSearchRequest{SchemaVersion: regV, Query: q}); !errors.Is(err, errWebQueryInvalid) { + t.Fatalf("query of %d runes accepted: %v", len([]rune(q)), err) + } + } + if len(p.callers) != 0 { + t.Fatal("provider reached with an invalid query") + } + maxQuery := strings.Repeat("法", maxWebQueryRunes) + if _, err := h.SearchWeb(ctx, extension.WebSearchRequest{SchemaVersion: regV, Query: " " + maxQuery + " "}); err != nil { + t.Fatalf("256-rune query rejected: %v", err) + } + if len(p.webQueries) != 1 || p.webQueries[0] != maxQuery { + t.Fatalf("provider query was not trimmed: %q", p.webQueries) + } +} + +func TestRegulationsExclusionRejectedBeforeProvider(t *testing.T) { + h, st := newRegulationsTestHost(t) + p := registerFakeRegulations(t, h) + ctx := context.Background() + query := "Project-Falcon 临床评价 指导原则" + res, err := h.SearchWeb(ctx, extension.WebSearchRequest{SchemaVersion: regV, Query: query, + Exclusions: []string{"", " ", "project-falcon"}}) + if err != nil || res.State != extension.WebSearchStateRejected || res.SentQuery != "" || len(res.Results) != 0 { + t.Fatalf("excluded query result = %+v %v", res, err) + } + if len(p.callers) != 0 { + t.Fatal("provider received a query containing an excluded term") + } + evs, err := st.ListAuditEvents("alice", 10) + if err != nil || len(evs) != 1 || evs[0].Action != auditWebSearch { + t.Fatalf("rejection audit: %+v %v", evs, err) + } + if strings.Contains(strings.ToLower(evs[0].Evidence), "falcon") || !strings.Contains(evs[0].Evidence, `"state":"rejected"`) { + t.Fatalf("rejection audit evidence = %s", evs[0].Evidence) + } + if _, err := h.SearchWeb(ctx, extension.WebSearchRequest{SchemaVersion: regV, Query: query, Exclusions: []string{"", " "}}); err != nil { + t.Fatal(err) + } + if len(p.callers) != 1 { + t.Fatal("blank exclusions blocked an allowed query") + } +} + +func TestRegulationsProviderStatesPassThroughUnchanged(t *testing.T) { + h, _ := newRegulationsTestHost(t) + p := registerFakeRegulations(t, h) + at := time.Date(2026, 10, 18, 9, 0, 0, 0, time.UTC) + p.search = extension.RegulationsSearchResult{CorpusRevision: "rev-7", State: extension.RegulationsStateDegraded, + DegradedReasons: []string{"vector_index_unavailable"}, + Hits: []extension.RegulationHit{{RegulationID: "r1", EditionID: "e1", NodeID: "n1", + Disclosure: extension.RegulationDisclosureWithheld, WithheldReason: "rights"}, + {RegulationID: "r2", EditionID: "e2", NodeID: "n2", Disclosure: extension.RegulationDisclosureMetadataOnly}}} + p.evidence = extension.RegulationEvidence{CorpusRevision: "rev-7", NodeID: "n1", + Disclosure: extension.RegulationDisclosureWithheld, WithheldReason: "rights", HashVerified: false} + p.web = extension.WebSearchResult{Provider: "fake", SentQuery: "q", SearchedAt: at, + State: extension.WebSearchStateRateLimited, Reason: "quota"} + p.capture = extension.WebCapture{CaptureID: "c1", FetchedAt: at, Retention: extension.WebRetentionMetadataOnly, + State: extension.WebCaptureStateRejected, Reason: "robots"} + ctx := context.Background() + search, err := h.SearchRegulations(ctx, extension.RegulationsSearchRequest{SchemaVersion: regV, Query: "q"}) + if err != nil || !reflect.DeepEqual(search, p.search) { + t.Fatalf("search = %+v %v", search, err) + } + evidence, err := h.ResolveRegulationEvidence(ctx, extension.RegulationEvidenceRequest{SchemaVersion: regV, CorpusRevision: "rev-7", NodeID: "n1"}) + if err != nil || !reflect.DeepEqual(evidence, p.evidence) { + t.Fatalf("evidence = %+v %v", evidence, err) + } + web, err := h.SearchWeb(ctx, extension.WebSearchRequest{SchemaVersion: regV, Query: "q"}) + if err != nil || !reflect.DeepEqual(web, p.web) { + t.Fatalf("web = %+v %v", web, err) + } + p.web.State = extension.WebSearchStateRejected + web, err = h.SearchWeb(ctx, extension.WebSearchRequest{SchemaVersion: regV, Query: "q"}) + if err != nil || web.State != extension.WebSearchStateRejected { + t.Fatalf("provider rejection rewritten: %+v %v", web, err) + } + capture, err := h.CaptureWeb(ctx, extension.WebCaptureRequest{SchemaVersion: regV, URL: "https://example.org"}) + if err != nil || !reflect.DeepEqual(capture, p.capture) { + t.Fatalf("capture = %+v %v", capture, err) + } + p.err = errors.New("provider failure") + if _, err := h.SearchRegulations(ctx, extension.RegulationsSearchRequest{SchemaVersion: regV, Query: "q"}); !errors.Is(err, p.err) { + t.Fatalf("provider failure wrapped as success: %v", err) + } +} + +func TestRegulationsAuditEvidenceOmitsQueryAndText(t *testing.T) { + h, st := newRegulationsTestHost(t) + p := registerFakeRegulations(t, h) + const ( + secretQuery = "第十七条 secret-query-text" + secretExcerpt = "SECRET-EXCERPT 原文片段" + secretPage = "SECRET-PAGE 网页全文" + ) + p.search = extension.RegulationsSearchResult{CorpusRevision: "rev-9", State: extension.RegulationsStateOK, + Hits: []extension.RegulationHit{{NodeID: "n1", Excerpt: secretExcerpt, Disclosure: extension.RegulationDisclosureExcerpt}}} + p.evidence = extension.RegulationEvidence{CorpusRevision: "rev-9", NodeID: "n1", Excerpt: secretExcerpt, + Disclosure: extension.RegulationDisclosureFull, HashVerified: true} + p.web = extension.WebSearchResult{SentQuery: secretQuery, State: extension.WebSearchStateOK, + Results: []extension.WebSearchHit{{Rank: 1, URL: "https://example.org", Snippet: secretExcerpt}}} + p.capture = extension.WebCapture{State: extension.WebCaptureStateCaptured, Retention: extension.WebRetentionFull, + Text: secretPage, Candidates: []extension.WebClauseCandidate{{Excerpt: secretExcerpt}}} + ctx := context.Background() + if _, err := h.SearchRegulations(ctx, extension.RegulationsSearchRequest{SchemaVersion: regV, Query: secretQuery}); err != nil { + t.Fatal(err) + } + if _, err := h.ResolveRegulationEvidence(ctx, extension.RegulationEvidenceRequest{SchemaVersion: regV, CorpusRevision: "rev-9", NodeID: "n1"}); err != nil { + t.Fatal(err) + } + if _, err := h.SearchWeb(ctx, extension.WebSearchRequest{SchemaVersion: regV, Query: " " + secretQuery + " "}); err != nil { + t.Fatal(err) + } + if _, err := h.CaptureWeb(ctx, extension.WebCaptureRequest{SchemaVersion: regV, URL: "https://example.org", SearchQuery: secretQuery}); err != nil { + t.Fatal(err) + } + evs, err := st.ListAuditEvents("alice", 10) + if err != nil || len(evs) != 4 { + t.Fatalf("audit events = %+v %v", evs, err) + } + sum := sha256.Sum256([]byte(secretQuery)) + digest := hex.EncodeToString(sum[:]) + want := map[string]regulationsAudit{ + auditRegulationsSearch: {Revision: "rev-9", Count: 1, State: "ok", QuerySHA256: digest}, + auditRegulationsEvidence: {Revision: "rev-9", Count: 1, State: "ok"}, + auditWebSearch: {Count: 1, State: "ok", QuerySHA256: digest}, + auditWebCapture: {Count: 1, State: "captured", QuerySHA256: digest}, + } + for _, ev := range evs { + for _, secret := range []string{"secret-query-text", "第十七条", "SECRET-EXCERPT", "原文片段", "SECRET-PAGE", "网页全文"} { + if strings.Contains(ev.Evidence, secret) || strings.Contains(ev.Target, secret) { + t.Fatalf("%s audit leaks %q: %s", ev.Action, secret, ev.Evidence) + } + } + var fields map[string]any + if err := json.Unmarshal([]byte(ev.Evidence), &fields); err != nil { + t.Fatal(err) + } + for k := range fields { + if k != "revision" && k != "count" && k != "state" && k != "querySha256" { + t.Fatalf("%s audit has unexpected field %q", ev.Action, k) + } + } + var got regulationsAudit + if err := json.Unmarshal([]byte(ev.Evidence), &got); err != nil { + t.Fatal(err) + } + if w, ok := want[ev.Action]; !ok || got != w || ev.Owner != "alice" { + t.Fatalf("%s audit = %+v owner=%s, want %+v", ev.Action, got, ev.Owner, w) + } + delete(want, ev.Action) + } +} + +func TestRegulationsRequireEffectiveAccount(t *testing.T) { + h, _ := newRegulationsTestHost(t) + p := registerFakeRegulations(t, h) + h.caps.accountGuard = func(string) error { return store.ErrAccountForbidden } + for i, err := range callAllRegulations(h, regV) { + if !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("method %d ignored the account guard: %v", i, err) + } + } + if len(p.callers) != 0 { + t.Fatal("provider reached for an ineffective account") + } +} diff --git a/internal/app/regulations_tools.go b/internal/app/regulations_tools.go new file mode 100644 index 0000000..89d2766 --- /dev/null +++ b/internal/app/regulations_tools.go @@ -0,0 +1,268 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "agent-system-backend/internal/tools" + "agent-system-backend/pkg/extension" + core "agent-system-core" +) + +// EXT-20 内置对话工具:模型经工具注册表只读检索共享法规知识库。执行走 +// inprocHost 的审计路径(有效账号校验 + regulations.* 审计),调用方身份为 +// 运行属主本人与固定的内置工具调用方 ID。 + +const ( + regulationsToolCallerID = "agent-tools" + regulationsToolTimeout = 60 * time.Second + regulationsSearchDefaultLimit = 5 + regulationsSearchMaxLimit = 10 + + toolRegulationsSearch = "regulations_search" + toolRegulationEvidence = "regulation_evidence" + toolKnowledgeSearch = "knowledge_search" + + knowledgeRegulationsFallbackPrefix = "项目资料无命中;以下为法规知识库命中(可用 regulation_evidence 取原文):\n" +) + +func registerRegulationsTools(reg *tools.Registry, caps hostcaps) error { + if err := registerRegulationsSearchTools(reg, caps); err != nil { + return err + } + return wrapKnowledgeSearchWithRegulations(reg, caps) +} + +// wrapKnowledgeSearchWithRegulations 让 knowledge_search 在项目资料无命中时回退 +// 法规知识库;未注册 knowledge_search 时不做任何事。 +func wrapKnowledgeSearchWithRegulations(reg *tools.Registry, caps hostcaps) error { + desc, inner, ok := reg.Get(toolKnowledgeSearch) + if !ok { + return nil + } + return reg.Register(desc, func(rc tools.ToolExecCtx, args json.RawMessage) (string, error) { + out, err := inner(rc, args) + if err != nil || out != tools.KnowledgeSearchNoHits { + return out, err + } + if fallback, ok := knowledgeRegulationsFallback(caps, rc, args); ok { + return knowledgeRegulationsFallbackPrefix + fallback, nil + } + return out, nil + }) +} + +func knowledgeRegulationsFallback(caps hostcaps, rc tools.ToolExecCtx, args json.RawMessage) (string, bool) { + var a struct { + Query string `json:"query"` + } + if json.Unmarshal(args, &a) != nil { + return "", false + } + q, err := json.Marshal(map[string]string{"query": a.Query}) + if err != nil { + return "", false + } + res, err := regulationsSearchTool(caps, rc, q) + if err != nil { + return "", false + } + var parsed regulationsToolSearchOutput + if json.Unmarshal([]byte(res), &parsed) != nil || parsed.State == extension.RegulationsStateUnavailable || len(parsed.Hits) == 0 { + return "", false + } + return res, true +} + +func registerRegulationsSearchTools(reg *tools.Registry, caps hostcaps) error { + if err := reg.Register(core.ToolDesc{ + Name: toolRegulationsSearch, + Description: "检索已发布的医疗器械法规、标准(GB/YY/YY/T 等)、指导原则、通告等共享法规知识库;用户提到标准号、法规名称、条款或监管要求时优先调用本工具,再考虑网络搜索;命中后可用 regulation_evidence 取条款原文以精确引用", + Source: core.SourceBuiltin, ReadOnly: true, + Parameters: json.RawMessage(`{"type":"object","properties":{` + + `"query":{"type":"string","description":"检索词"},` + + `"docNo":{"type":"string","description":"标准号/文号,如 GB 9706.1-2020"},` + + `"clauseNo":{"type":"string","description":"条款号,如 7.2.1"},` + + `"kinds":{"type":"array","items":{"type":"string"},"description":"文献类别过滤"},` + + `"currentOnly":{"type":"boolean","description":"仅现行有效版本"},` + + `"limit":{"type":"integer","minimum":1,"maximum":10,"description":"返回条数,默认 5"}},` + + `"required":["query"]}`), + }, func(rc tools.ToolExecCtx, args json.RawMessage) (string, error) { + return regulationsSearchTool(caps, rc, args) + }); err != nil { + return err + } + return reg.Register(core.ToolDesc{ + Name: toolRegulationEvidence, + Description: "按 regulations_search 返回的 corpusRevision 与 nodeId 取条款原文区间与哈希,用于精确引用", + Source: core.SourceBuiltin, ReadOnly: true, + Parameters: json.RawMessage(`{"type":"object","properties":{` + + `"corpusRevision":{"type":"string","description":"regulations_search 返回的语料修订号"},` + + `"nodeId":{"type":"string","description":"regulations_search 命中的 nodeId"},` + + `"start":{"type":"integer","description":"原文区间起点(可选)"},` + + `"end":{"type":"integer","description":"原文区间终点(可选)"}},` + + `"required":["corpusRevision","nodeId"]}`), + }, func(rc tools.ToolExecCtx, args json.RawMessage) (string, error) { + return regulationEvidenceTool(caps, rc, args) + }) +} + +type regulationsToolHit struct { + RegulationID string `json:"regulationId"` + EditionID string `json:"editionId"` + NodeID string `json:"nodeId"` + DocNo string `json:"docNo,omitempty"` + Title string `json:"title,omitempty"` + EditionLabel string `json:"editionLabel,omitempty"` + LegalStatus string `json:"legalStatus,omitempty"` + ClauseNo string `json:"clauseNo,omitempty"` + HeadingPath []string `json:"headingPath,omitempty"` + Excerpt string `json:"excerpt,omitempty"` + Disclosure string `json:"disclosure"` + WithheldReason string `json:"withheldReason,omitempty"` + PageStart int `json:"pageStart,omitempty"` + PageEnd int `json:"pageEnd,omitempty"` + IsSummary bool `json:"isSummary,omitempty"` + MatchKinds []string `json:"matchKinds,omitempty"` +} + +type regulationsToolSearchOutput struct { + CorpusRevision string `json:"corpusRevision"` + State string `json:"state"` + DegradedReasons []string `json:"degradedReasons,omitempty"` + Hits []regulationsToolHit `json:"hits"` +} + +type regulationsToolUnavailable struct { + State string `json:"state"` + Reason string `json:"reason"` +} + +func regulationsSearchTool(caps hostcaps, rc tools.ToolExecCtx, args json.RawMessage) (string, error) { + var a struct { + Query string `json:"query"` + DocNo string `json:"docNo"` + ClauseNo string `json:"clauseNo"` + Kinds []string `json:"kinds"` + CurrentOnly bool `json:"currentOnly"` + Limit int `json:"limit"` + } + if len(args) > 0 { + if err := json.Unmarshal(args, &a); err != nil { + return "", fmt.Errorf("%s: bad args: %w", toolRegulationsSearch, err) + } + } + a.Query = strings.TrimSpace(a.Query) + if a.Query == "" { + return "", fmt.Errorf("%s: query is required", toolRegulationsSearch) + } + limit := a.Limit + if limit <= 0 { + limit = regulationsSearchDefaultLimit + } else if limit > regulationsSearchMaxLimit { + limit = regulationsSearchMaxLimit + } + h, err := regulationsToolHost(caps, rc, toolRegulationsSearch) + if err != nil { + return "", err + } + ctx, cancel := regulationsToolContext(rc) + defer cancel() + res, err := h.SearchRegulations(ctx, extension.RegulationsSearchRequest{ + SchemaVersion: extension.RegulationsSchemaVersion, + Query: a.Query, + Kinds: a.Kinds, + CurrentOnly: a.CurrentOnly, + DocNo: strings.TrimSpace(a.DocNo), + ClauseNo: strings.TrimSpace(a.ClauseNo), + Limit: limit, + }) + if err != nil { + return regulationsToolError(toolRegulationsSearch, err) + } + out := regulationsToolSearchOutput{CorpusRevision: res.CorpusRevision, State: res.State, + DegradedReasons: res.DegradedReasons, Hits: make([]regulationsToolHit, 0, len(res.Hits))} + for _, hit := range res.Hits { + out.Hits = append(out.Hits, regulationsToolHit{ + RegulationID: hit.RegulationID, EditionID: hit.EditionID, NodeID: hit.NodeID, + DocNo: hit.DocNo, Title: hit.Title, EditionLabel: hit.EditionLabel, LegalStatus: hit.LegalStatus, + ClauseNo: hit.ClauseNo, HeadingPath: hit.HeadingPath, Excerpt: hit.Excerpt, + Disclosure: hit.Disclosure, WithheldReason: hit.WithheldReason, + PageStart: hit.PageStart, PageEnd: hit.PageEnd, IsSummary: hit.IsSummary, MatchKinds: hit.MatchKinds, + }) + } + return regulationsToolJSON(out) +} + +func regulationEvidenceTool(caps hostcaps, rc tools.ToolExecCtx, args json.RawMessage) (string, error) { + var a struct { + CorpusRevision string `json:"corpusRevision"` + NodeID string `json:"nodeId"` + Start int `json:"start"` + End int `json:"end"` + } + if len(args) > 0 { + if err := json.Unmarshal(args, &a); err != nil { + return "", fmt.Errorf("%s: bad args: %w", toolRegulationEvidence, err) + } + } + a.CorpusRevision, a.NodeID = strings.TrimSpace(a.CorpusRevision), strings.TrimSpace(a.NodeID) + if a.CorpusRevision == "" || a.NodeID == "" { + return "", fmt.Errorf("%s: corpusRevision and nodeId are required", toolRegulationEvidence) + } + h, err := regulationsToolHost(caps, rc, toolRegulationEvidence) + if err != nil { + return "", err + } + ctx, cancel := regulationsToolContext(rc) + defer cancel() + ev, err := h.ResolveRegulationEvidence(ctx, extension.RegulationEvidenceRequest{ + SchemaVersion: extension.RegulationsSchemaVersion, + CorpusRevision: a.CorpusRevision, + NodeID: a.NodeID, + Start: a.Start, + End: a.End, + }) + if err != nil { + return regulationsToolError(toolRegulationEvidence, err) + } + return regulationsToolJSON(ev) +} + +// regulationsToolHost binds the audited host to the run owner, who is both the +// owner and the actor of an agent tool call. +func regulationsToolHost(caps hostcaps, rc tools.ToolExecCtx, name string) (inprocHost, error) { + owner := rc.OwnerID() + if owner == "" { + return inprocHost{}, fmt.Errorf("%s: run owner is required", name) + } + return inprocHost{caps: caps, pluginID: regulationsToolCallerID, owner: owner, actor: owner}, nil +} + +func regulationsToolContext(rc tools.ToolExecCtx) (context.Context, context.CancelFunc) { + parent := context.Background() + if c, ok := rc.(interface{ Context() context.Context }); ok && c.Context() != nil { + parent = c.Context() + } + return context.WithTimeout(parent, regulationsToolTimeout) +} + +func regulationsToolError(name string, err error) (string, error) { + if errors.Is(err, extension.ErrRegulationsUnavailable) { + return regulationsToolJSON(regulationsToolUnavailable{State: extension.RegulationsStateUnavailable, + Reason: "shared regulations knowledge base is not available"}) + } + return "", fmt.Errorf("%s: %w", name, err) +} + +func regulationsToolJSON(v any) (string, error) { + b, err := json.Marshal(v) + if err != nil { + return "", err + } + return string(b), nil +} diff --git a/internal/app/regulations_tools_test.go b/internal/app/regulations_tools_test.go new file mode 100644 index 0000000..2c1471d --- /dev/null +++ b/internal/app/regulations_tools_test.go @@ -0,0 +1,318 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + + "agent-system-backend/internal/tools" + "agent-system-backend/pkg/extension" + core "agent-system-core" +) + +type regulationsToolCtx struct{ owner string } + +func (c regulationsToolCtx) ProjectID() string { return "" } +func (c regulationsToolCtx) ConversationID() string { return "" } +func (c regulationsToolCtx) Retrieve(core.RetrievalQuery) ([]core.RetrievalHit, error) { + return nil, nil +} +func (c regulationsToolCtx) WriteArtifact(string, string, string, []byte) (core.Artifact, error) { + return core.Artifact{}, errors.New("read-only") +} +func (c regulationsToolCtx) PeerConversations() ([]core.Conversation, error) { return nil, nil } +func (c regulationsToolCtx) PeerConversationMessages(string, int) ([]core.Message, error) { + return nil, nil +} +func (c regulationsToolCtx) OwnerID() string { return c.owner } +func (c regulationsToolCtx) WorkspaceRoot() string { return "" } +func (c regulationsToolCtx) SettingBool(_ string, d bool) bool { return d } + +type recordingRegulationsProvider struct { + *fakeRegulationsProvider + searches []extension.RegulationsSearchRequest + evidences []extension.RegulationEvidenceRequest +} + +func (p *recordingRegulationsProvider) SearchRegulations(ctx context.Context, c extension.RegulationsCaller, req extension.RegulationsSearchRequest) (extension.RegulationsSearchResult, error) { + p.searches = append(p.searches, req) + return p.fakeRegulationsProvider.SearchRegulations(ctx, c, req) +} + +func (p *recordingRegulationsProvider) ResolveRegulationEvidence(ctx context.Context, c extension.RegulationsCaller, req extension.RegulationEvidenceRequest) (extension.RegulationEvidence, error) { + p.evidences = append(p.evidences, req) + return p.fakeRegulationsProvider.ResolveRegulationEvidence(ctx, c, req) +} + +func newRegulationsToolsRegistry(t *testing.T, h inprocHost) *tools.Registry { + t.Helper() + reg := tools.NewRegistry() + if err := registerRegulationsTools(reg, h.caps); err != nil { + t.Fatal(err) + } + return reg +} + +func registerRecordingRegulations(t *testing.T, h inprocHost) *recordingRegulationsProvider { + t.Helper() + p := &recordingRegulationsProvider{fakeRegulationsProvider: &fakeRegulationsProvider{}} + if err := (pluginRegistry{pluginID: "kbreg", caps: h.caps}).RegisterRegulationsProvider(p); err != nil { + t.Fatal(err) + } + return p +} + +func callRegulationsTool(reg *tools.Registry, name, args string) core.ToolCallResult { + return reg.Execute(regulationsToolCtx{owner: "alice"}, core.ToolCallRequest{CallID: "c1", Name: name, Arguments: json.RawMessage(args)}) +} + +func TestRegulationsToolsRegisteredAsReadOnlyBuiltins(t *testing.T) { + h, _ := newRegulationsTestHost(t) + reg := newRegulationsToolsRegistry(t, h) + for _, name := range []string{toolRegulationsSearch, toolRegulationEvidence} { + desc, _, ok := reg.Get(name) + if !ok || desc.Source != core.SourceBuiltin || !desc.ReadOnly || desc.Edits || desc.Hidden { + t.Fatalf("%s desc = %+v ok=%v", name, desc, ok) + } + var schema struct { + Type string `json:"type"` + Required []string `json:"required"` + Props map[string]any `json:"properties"` + } + if err := json.Unmarshal(desc.Parameters, &schema); err != nil || schema.Type != "object" || len(schema.Required) == 0 { + t.Fatalf("%s schema = %s %v", name, desc.Parameters, err) + } + for _, req := range schema.Required { + if _, ok := schema.Props[req]; !ok { + t.Fatalf("%s required %q not declared", name, req) + } + } + } +} + +func TestRegulationsSearchToolMapsHitsAndClampsLimit(t *testing.T) { + h, st := newRegulationsTestHost(t) + p := registerRecordingRegulations(t, h) + p.search = extension.RegulationsSearchResult{CorpusRevision: "rev-3", State: extension.RegulationsStateDegraded, + DegradedReasons: []string{"vector_index_unavailable"}, + Hits: []extension.RegulationHit{{RegulationID: "r1", EditionID: "e1", NodeID: "n1", DocNo: "GB 9706.1-2020", + Title: "医用电气设备", EditionLabel: "2020", LegalStatus: "current", ClauseNo: "7.2.1", + HeadingPath: []string{"7", "7.2"}, Excerpt: "条款片段", ExcerptStart: 3, NodeSHA256: "abc", + Disclosure: extension.RegulationDisclosureWithheld, WithheldReason: "rights", PageStart: 12, PageEnd: 13, + IsSummary: true, MatchKinds: []string{"docNo", "text"}, Score: 0.9}}} + reg := newRegulationsToolsRegistry(t, h) + res := callRegulationsTool(reg, toolRegulationsSearch, + `{"query":" 电气安全 ","docNo":"GB 9706.1-2020","clauseNo":"7.2.1","kinds":["standard"],"currentOnly":true,"limit":50}`) + if res.Status != "ok" { + t.Fatalf("search result = %+v", res) + } + want := regulationsToolSearchOutput{CorpusRevision: "rev-3", State: extension.RegulationsStateDegraded, + DegradedReasons: []string{"vector_index_unavailable"}, + Hits: []regulationsToolHit{{RegulationID: "r1", EditionID: "e1", NodeID: "n1", DocNo: "GB 9706.1-2020", + Title: "医用电气设备", EditionLabel: "2020", LegalStatus: "current", ClauseNo: "7.2.1", + HeadingPath: []string{"7", "7.2"}, Excerpt: "条款片段", + Disclosure: extension.RegulationDisclosureWithheld, WithheldReason: "rights", PageStart: 12, PageEnd: 13, + IsSummary: true, MatchKinds: []string{"docNo", "text"}}}} + var got regulationsToolSearchOutput + if err := json.Unmarshal([]byte(res.Output), &got); err != nil || !reflect.DeepEqual(got, want) { + t.Fatalf("search output = %s %v", res.Output, err) + } + if strings.Contains(res.Output, "score") || strings.Contains(res.Output, "nodeSha256") { + t.Fatalf("search output leaks unlisted fields: %s", res.Output) + } + wantReq := extension.RegulationsSearchRequest{SchemaVersion: regV, Query: "电气安全", Kinds: []string{"standard"}, + CurrentOnly: true, DocNo: "GB 9706.1-2020", ClauseNo: "7.2.1", Limit: regulationsSearchMaxLimit} + if len(p.searches) != 1 || !reflect.DeepEqual(p.searches[0], wantReq) { + t.Fatalf("provider request = %+v", p.searches) + } + wantCaller := extension.RegulationsCaller{PluginID: regulationsToolCallerID, Actor: "alice", Owner: "alice"} + if len(p.callers) != 1 || p.callers[0] != wantCaller { + t.Fatalf("caller = %+v", p.callers) + } + callRegulationsTool(reg, toolRegulationsSearch, `{"query":"q"}`) + callRegulationsTool(reg, toolRegulationsSearch, `{"query":"q","limit":-3}`) + callRegulationsTool(reg, toolRegulationsSearch, `{"query":"q","limit":7}`) + for i, want := range []int{regulationsSearchDefaultLimit, regulationsSearchDefaultLimit, 7} { + if got := p.searches[i+1].Limit; got != want { + t.Fatalf("limit case %d = %d, want %d", i, got, want) + } + } + evs, err := st.ListAuditEvents("alice", 10) + if err != nil || len(evs) != 4 { + t.Fatalf("audit events = %+v %v", evs, err) + } + for _, ev := range evs { + if ev.Action != auditRegulationsSearch || ev.Actor != "plugin:"+regulationsToolCallerID || strings.Contains(ev.Evidence, "电气安全") { + t.Fatalf("search audit = %+v", ev) + } + } +} + +func TestRegulationEvidenceToolMapsFields(t *testing.T) { + h, st := newRegulationsTestHost(t) + p := registerRecordingRegulations(t, h) + p.evidence = extension.RegulationEvidence{CorpusRevision: "rev-3", RegulationID: "r1", EditionID: "e1", NodeID: "n1", + ClauseNo: "7.2.1", Excerpt: "原文", Start: 4, End: 10, NodeSHA256: "abc", SourceAssetSHA256: "def", + PageStart: 12, PageEnd: 12, Disclosure: extension.RegulationDisclosureFull, HashVerified: true} + reg := newRegulationsToolsRegistry(t, h) + res := callRegulationsTool(reg, toolRegulationEvidence, `{"corpusRevision":"rev-3","nodeId":"n1","start":4,"end":10}`) + if res.Status != "ok" { + t.Fatalf("evidence result = %+v", res) + } + var got extension.RegulationEvidence + if err := json.Unmarshal([]byte(res.Output), &got); err != nil || got != p.evidence { + t.Fatalf("evidence output = %s %v", res.Output, err) + } + wantReq := extension.RegulationEvidenceRequest{SchemaVersion: regV, CorpusRevision: "rev-3", NodeID: "n1", Start: 4, End: 10} + if len(p.evidences) != 1 || p.evidences[0] != wantReq { + t.Fatalf("provider request = %+v", p.evidences) + } + if evs, err := st.ListAuditEvents("alice", 10); err != nil || len(evs) != 1 || evs[0].Action != auditRegulationsEvidence { + t.Fatalf("evidence audit = %+v %v", evs, err) + } +} + +func TestRegulationsToolsRejectMissingArgs(t *testing.T) { + h, _ := newRegulationsTestHost(t) + p := registerRecordingRegulations(t, h) + reg := newRegulationsToolsRegistry(t, h) + for _, c := range []struct{ name, args string }{ + {toolRegulationsSearch, `{}`}, + {toolRegulationsSearch, `{"query":" "}`}, + {toolRegulationEvidence, `{"corpusRevision":"rev-3"}`}, + {toolRegulationEvidence, `{"nodeId":"n1"}`}, + {toolRegulationEvidence, `{"corpusRevision":"rev-3","nodeId":" "}`}, + } { + if res := callRegulationsTool(reg, c.name, c.args); res.Status != "error" { + t.Fatalf("%s %s accepted: %+v", c.name, c.args, res) + } + } + if len(p.callers) != 0 { + t.Fatal("provider reached with missing arguments") + } +} + +func TestRegulationsToolsReportUnavailable(t *testing.T) { + h, st := newRegulationsTestHost(t) + reg := newRegulationsToolsRegistry(t, h) + assertUnavailable := func(label string) { + t.Helper() + for _, c := range []struct{ name, args string }{ + {toolRegulationsSearch, `{"query":"q"}`}, + {toolRegulationEvidence, `{"corpusRevision":"rev-3","nodeId":"n1"}`}, + } { + res := callRegulationsTool(reg, c.name, c.args) + var out regulationsToolUnavailable + if res.Status != "ok" || json.Unmarshal([]byte(res.Output), &out) != nil || + out.State != extension.RegulationsStateUnavailable || out.Reason == "" { + t.Fatalf("%s %s = %+v", label, c.name, res) + } + } + } + assertUnavailable("no provider") + if evs, err := st.ListAuditEvents("alice", 10); err != nil || len(evs) != 0 { + t.Fatalf("unavailable calls audited: %+v %v", evs, err) + } + p := registerRecordingRegulations(t, h) + p.err = extension.ErrRegulationsUnavailable + assertUnavailable("provider unavailable") + p.err = errors.New("provider failure") + if res := callRegulationsTool(reg, toolRegulationsSearch, `{"query":"q"}`); res.Status != "error" { + t.Fatalf("provider failure reported as success: %+v", res) + } +} + +type knowledgeHitsCtx struct { + regulationsToolCtx + hits []core.RetrievalHit +} + +func (c knowledgeHitsCtx) Retrieve(core.RetrievalQuery) ([]core.RetrievalHit, error) { + return c.hits, nil +} + +func newKnowledgeFallbackRegistry(t *testing.T, h inprocHost) *tools.Registry { + t.Helper() + reg := tools.NewRegistry() + if err := reg.RegisterBuiltin(); err != nil { + t.Fatal(err) + } + before, _, _ := reg.Get(toolKnowledgeSearch) + if err := registerRegulationsTools(reg, h.caps); err != nil { + t.Fatal(err) + } + if after, _, _ := reg.Get(toolKnowledgeSearch); !reflect.DeepEqual(after, before) { + t.Fatalf("knowledge_search desc changed: %+v", after) + } + return reg +} + +func callKnowledgeSearch(reg *tools.Registry, hits []core.RetrievalHit) core.ToolCallResult { + rc := knowledgeHitsCtx{regulationsToolCtx: regulationsToolCtx{owner: "alice"}, hits: hits} + return reg.Execute(rc, core.ToolCallRequest{CallID: "c1", Name: toolKnowledgeSearch, Arguments: json.RawMessage(`{"query":"电气安全"}`)}) +} + +func TestKnowledgeSearchFallsBackToRegulationsHits(t *testing.T) { + h, _ := newRegulationsTestHost(t) + p := registerRecordingRegulations(t, h) + p.search = extension.RegulationsSearchResult{CorpusRevision: "rev-9", State: extension.RegulationsStateOK, + Hits: []extension.RegulationHit{{RegulationID: "r1", EditionID: "e1", NodeID: "n1", DocNo: "GB 9706.1-2020", + Excerpt: "法规条款片段", Disclosure: extension.RegulationDisclosureFull}}} + reg := newKnowledgeFallbackRegistry(t, h) + res := callKnowledgeSearch(reg, nil) + if res.Status != "ok" || !strings.HasPrefix(res.Output, knowledgeRegulationsFallbackPrefix) || + !strings.Contains(res.Output, "法规条款片段") || !strings.Contains(res.Output, "GB 9706.1-2020") { + t.Fatalf("fallback result = %+v", res) + } + if len(p.searches) != 1 || p.searches[0].Query != "电气安全" { + t.Fatalf("regulations request = %+v", p.searches) + } +} + +func TestKnowledgeSearchKeepsNoHitsWhenRegulationsEmpty(t *testing.T) { + h, _ := newRegulationsTestHost(t) + p := registerRecordingRegulations(t, h) + p.search = extension.RegulationsSearchResult{CorpusRevision: "rev-9", State: extension.RegulationsStateOK} + reg := newKnowledgeFallbackRegistry(t, h) + if res := callKnowledgeSearch(reg, nil); res.Status != "ok" || res.Output != tools.KnowledgeSearchNoHits { + t.Fatalf("empty regulations result = %+v", res) + } + if len(p.searches) != 1 { + t.Fatalf("regulations not consulted: %+v", p.searches) + } +} + +func TestKnowledgeSearchKeepsNoHitsWhenRegulationsUnavailable(t *testing.T) { + h, _ := newRegulationsTestHost(t) + reg := newKnowledgeFallbackRegistry(t, h) + if res := callKnowledgeSearch(reg, nil); res.Status != "ok" || res.Output != tools.KnowledgeSearchNoHits { + t.Fatalf("no provider result = %+v", res) + } + p := registerRecordingRegulations(t, h) + for _, err := range []error{extension.ErrRegulationsUnavailable, errors.New("provider failure")} { + p.err = err + if res := callKnowledgeSearch(reg, nil); res.Status != "ok" || res.Output != tools.KnowledgeSearchNoHits { + t.Fatalf("provider %v result = %+v", err, res) + } + } +} + +func TestKnowledgeSearchProjectHitSkipsRegulations(t *testing.T) { + h, _ := newRegulationsTestHost(t) + p := registerRecordingRegulations(t, h) + p.search = extension.RegulationsSearchResult{CorpusRevision: "rev-9", State: extension.RegulationsStateOK, + Hits: []extension.RegulationHit{{RegulationID: "r1", EditionID: "e1", NodeID: "n1", Excerpt: "法规条款片段"}}} + reg := newKnowledgeFallbackRegistry(t, h) + hits := []core.RetrievalHit{{Doc: core.KnowledgeDoc{Title: "项目说明书", Source: "upload", Chunk: "项目片段"}, Score: 0.8}} + res := callKnowledgeSearch(reg, hits) + if res.Status != "ok" || !strings.Contains(res.Output, "项目片段") || + strings.Contains(res.Output, knowledgeRegulationsFallbackPrefix) || strings.Contains(res.Output, "法规条款片段") { + t.Fatalf("project hit result = %+v", res) + } + if len(p.searches) != 0 { + t.Fatalf("regulations consulted on project hit: %+v", p.searches) + } +} diff --git a/internal/app/resource_authz_v2_test.go b/internal/app/resource_authz_v2_test.go index 17dff17..ed3423e 100644 --- a/internal/app/resource_authz_v2_test.go +++ b/internal/app/resource_authz_v2_test.go @@ -71,3 +71,19 @@ func TestCompiledRegistryRejectsV2(t *testing.T) { t.Fatal("声明式扩展不得注册资源级授权回调(fail-closed)") } } + +func TestAuthorizeResourceEnrichesEveryInProcessQueryWithCurrentRolePolicy(t *testing.T) { + snapshot := extension.RolePolicySnapshot{SchemaVersion: extension.RolePolicySchemaVersion, + Owner: "alice", AccountEnabled: true, RoleID: "role_1", RoleEnabled: true, AuthorityEpoch: 7} + a := projectAuthz{enrich: func(q extension.ResourceAuthzQuery) (extension.ResourceAuthzQuery, error) { + q.AuthorityEpoch = snapshot.AuthorityEpoch + q.RolePolicy = &snapshot + return q, nil + }} + a.setV2("rdm", func(q extension.ResourceAuthzQuery) (bool, error) { + return q.AuthorityEpoch == 7 && q.RolePolicy != nil && q.RolePolicy.Owner == q.Actor, nil + }) + if ok, err := a.AuthorizeResource(extension.ResourceAuthzQuery{Actor: "alice", Action: "search", ResourceType: "knowledge", ResourceID: "k1"}); err != nil || !ok { + t.Fatalf("enriched query ok=%v err=%v", ok, err) + } +} diff --git a/internal/app/role_provider_test.go b/internal/app/role_provider_test.go new file mode 100644 index 0000000..a534c67 --- /dev/null +++ b/internal/app/role_provider_test.go @@ -0,0 +1,200 @@ +package app + +import ( + "context" + "errors" + "testing" + + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" +) + +func TestRoleProviderReturnsVersionedNonSecretPolicy(t *testing.T) { + st, err := store.OpenSQLite(t.TempDir() + "/role-provider.db") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + if err := st.CreateAccount("admin", "Admin", "hash", true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("alice", "Alice", "hash", false); err != nil { + t.Fatal(err) + } + alice, _ := st.GetAccountByOwner("alice") + role, err := st.CreateRole("admin", store.RoleCreate{Name: "软件工程师", Description: "负责软件研发"}) + if err != nil { + t.Fatal(err) + } + receipt, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, + []store.RolePermissionChange{{ActionID: "document.read", Allowed: true, CapAllowed: true}}, "provider-policy") + if err != nil { + t.Fatal(err) + } + if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "provider-binding"); err != nil { + t.Fatal(err) + } + h := inprocHost{caps: hostcaps{st: st}, pluginID: "rdm", owner: "admin", actor: "admin"} + provider, ok := any(h).(extension.RolePolicyProvider) + if !ok { + t.Fatal("dispatch host missing RolePolicyProvider") + } + snapshot, err := provider.RolePolicyForUser(context.Background(), alice.ID) + if err != nil { + t.Fatal(err) + } + byOwner, err := provider.RolePolicyForOwner(context.Background(), "alice") + if err != nil || byOwner.UserID != alice.ID || byOwner.RoleID != role.RoleID { + t.Fatalf("owner policy=%+v err=%v", byOwner, err) + } + if snapshot.SchemaVersion != extension.RolePolicySchemaVersion || snapshot.UserID != alice.ID || !snapshot.AccountEnabled || + snapshot.RoleID != role.RoleID || snapshot.RoleName != "软件工程师" || snapshot.RoleDescription != "负责软件研发" || + !snapshot.RoleEnabled || snapshot.RoleVersion != receipt.RoleVersion || snapshot.AuthorityEpoch < receipt.AuthorityEpoch { + t.Fatalf("snapshot=%+v", snapshot) + } + permission := snapshot.Permissions["document.read"] + if !permission.Allowed || !permission.CapAllowed { + t.Fatalf("permission=%+v", permission) + } + decision, err := provider.CheckRoleAction(context.Background(), alice.ID, "document.read") + if err != nil || !decision.Allowed || !decision.CapAllowed || decision.RoleID != role.RoleID { + t.Fatalf("decision=%+v err=%v", decision, err) + } + systemProvider, ok := any(h).(extension.SystemActionProvider) + if !ok { + t.Fatal("dispatch host missing SystemActionProvider") + } + systemDecision, err := systemProvider.CheckCurrentSystemAction(context.Background(), "system.permissions.manage") + if err != nil || !systemDecision.Allowed || systemDecision.AuthorityEpoch < 1 { + t.Fatalf("system decision=%+v err=%v", systemDecision, err) + } + updated, err := st.ReplaceRolePermissions("admin", role.RoleID, snapshot.RoleVersion, + []store.RolePermissionChange{{ActionID: "document.read", Allowed: false, CapAllowed: true}}, "provider-policy-update") + if err != nil { + t.Fatal(err) + } + refreshed, err := provider.RolePolicyForUser(context.Background(), alice.ID) + if err != nil || refreshed.AuthorityEpoch != updated.AuthorityEpoch || refreshed.RoleVersion != updated.RoleVersion { + t.Fatalf("refreshed=%+v receipt=%+v err=%v", refreshed, updated, err) + } +} + +func TestRoleProviderRejectsUntrustedScopedHost(t *testing.T) { + st, err := store.OpenSQLite(t.TempDir() + "/role-provider.db") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + if err := st.CreateAccount("alice", "Alice", "hash", false); err != nil { + t.Fatal(err) + } + alice, _ := st.GetAccountByOwner("alice") + h := inprocHost{caps: hostcaps{st: st}, pluginID: "rdm", owner: "alice", actor: ""} + provider := any(h).(extension.RolePolicyProvider) + if _, err := provider.RolePolicyForUser(context.Background(), alice.ID); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("empty actor provider: %v", err) + } +} + +func TestRolePolicyAdminUsesBoundActorAndStableOwnerCompatibility(t *testing.T) { + st, err := store.OpenSQLite(t.TempDir() + "/role-admin.db") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + if err := st.CreateAccount("admin", "Admin", "hash", true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("alice", "Alice", "hash", false); err != nil { + t.Fatal(err) + } + h := inprocHost{caps: hostcaps{st: st}, pluginID: "rdm", owner: "admin", actor: "admin"} + admin, ok := any(h).(extension.RolePolicyAdmin) + if !ok { + t.Fatal("dispatch host missing RolePolicyAdmin") + } + role, err := admin.CreateRole(context.Background(), "软件工程师", "负责软件研发") + if err != nil { + t.Fatal(err) + } + role, err = admin.SetRolePermission(context.Background(), role.RoleID, "document.read", true, true, "legacy-action") + if err != nil || !role.Permissions["document.read"].Allowed { + t.Fatalf("role=%+v err=%v", role, err) + } + binding, err := admin.AssignRoleByOwner(context.Background(), "alice", role.RoleID, "legacy-assign") + if err != nil || binding.RoleID != role.RoleID { + t.Fatalf("binding=%+v err=%v", binding, err) + } + roles, err := admin.ListRoles(context.Background()) + if err != nil || len(roles) != 1 || roles[0].BindingCount != 1 { + t.Fatalf("roles=%+v err=%v", roles, err) + } +} + +// 非管理员分发主体(项目创建者)只读已启用职位:roleId+name,按 name→roleId 排序, +// 不含权限/绑定/计数;空 actor(scoped host)与已停用账号一律拒绝。 +func TestRoleOptionsExposeEnabledRolesToNonAdminActor(t *testing.T) { + st, err := store.OpenSQLite(t.TempDir() + "/role-options.db") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + if err := st.CreateAccount("admin", "Admin", "hash", true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("alice", "Alice", "hash", false); err != nil { + t.Fatal(err) + } + alice, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + beta, err := st.CreateRole("admin", store.RoleCreate{Name: "Beta 职位", Description: "停用前"}) + if err != nil { + t.Fatal(err) + } + alpha, err := st.CreateRole("admin", store.RoleCreate{Name: "Alpha 职位"}) + if err != nil { + t.Fatal(err) + } + retired, err := st.CreateRole("admin", store.RoleCreate{Name: "Gamma 职位"}) + if err != nil { + t.Fatal(err) + } + disabled := false + if _, err := st.PatchRole("admin", retired.RoleID, retired.RoleVersion, store.RolePatch{Enabled: &disabled}); err != nil { + t.Fatal(err) + } + h := inprocHost{caps: hostcaps{st: st}, pluginID: "rdm", owner: "alice", actor: "alice"} + options, ok := any(h).(extension.RoleOptionsProvider) + if !ok { + t.Fatal("dispatch host missing RoleOptionsProvider") + } + // 管理面仍然拒绝非管理员:只读目录不放大权限。 + if _, err := any(h).(extension.RolePolicyAdmin).ListRoles(context.Background()); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("non-admin ListRoles: %v", err) + } + got, err := options.ListRoleOptions(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(got) != 2 || got[0] != (extension.RoleOption{RoleID: alpha.RoleID, Name: "Alpha 职位"}) || + got[1] != (extension.RoleOption{RoleID: beta.RoleID, Name: "Beta 职位"}) { + t.Fatalf("options=%+v", got) + } + scoped := inprocHost{caps: hostcaps{st: st}, pluginID: "rdm", owner: "alice"} + if _, err := any(scoped).(extension.RoleOptionsProvider).ListRoleOptions(context.Background()); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("empty actor options: %v", err) + } + if _, err := st.SetAccountEnabled("admin", alice.ID, alice.StateVersion, false); err != nil { + t.Fatal(err) + } + if _, err := options.ListRoleOptions(context.Background()); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("disabled actor options: %v", err) + } + cancelled, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := options.ListRoleOptions(cancelled); !errors.Is(err, context.Canceled) { + t.Fatalf("cancelled context: %v", err) + } +} diff --git a/internal/app/scoped_host_test.go b/internal/app/scoped_host_test.go index d284723..0791606 100644 --- a/internal/app/scoped_host_test.go +++ b/internal/app/scoped_host_test.go @@ -24,6 +24,9 @@ func TestScopedHostFactory(t *testing.T) { t.Fatal(err) } t.Cleanup(func() { st.Close() }) + if err := st.EnsureUser("alice", ""); err != nil { + t.Fatal(err) + } set := settings.NewService(st) pipe := rag.NewPipeline(st, set, nil, nil) caps := hostcaps{st: st, pipe: pipe, set: set} @@ -39,6 +42,9 @@ func TestScopedHostFactory(t *testing.T) { if h.Owner() != "alice" || h.PluginID() != "rdm" { t.Fatalf("Owner/PluginID = %s/%s, want alice/rdm", h.Owner(), h.PluginID()) } + if _, ok := h.(extension.DelegatedProjectProjectionHost); ok { + t.Fatal("globally reconstructed Host exposed request-bound projection authority") + } // KV 往返落 owner 作用域。 if err := h.KVPut("k1", "v1"); err != nil { t.Fatal(err) diff --git a/internal/app/service_store.go b/internal/app/service_store.go new file mode 100644 index 0000000..0d45a56 --- /dev/null +++ b/internal/app/service_store.go @@ -0,0 +1,111 @@ +package app + +import ( + "errors" + "fmt" + "log" + "os" + "path/filepath" + "strings" + + "agent-system-backend/internal/blobstore" + "agent-system-backend/internal/instanceguard" + "agent-system-backend/internal/store" +) + +// openServiceStore 打开活动服务使用的存储。 +// SQLite 档先取活动服务锁,再打开(打开即迁移),然后核对守卫标记。 +// 返回的函数先关存储再放锁。 +func openServiceStore(opt Options) (*store.Store, func(), error) { + nop := func() {} + if opt.StorageDSN != "" { + artifacts := opt.ArtifactsDir + if artifacts == "" { + artifacts = filepath.Join(opt.DataDir, "artifacts") + } + // 共享工件(U5/KTD9):配置了 S3 兼容对象存储时注入(多实例共享); + // 未配置回落本地目录(单实例/共享卷)。凭据只经环境(不入库不入报告)。 + blobs, berr := blobstore.NewS3FromEnv() + if berr != nil { + return nil, nop, fmt.Errorf("blobstore s3: %w", berr) + } + if blobs != nil { + log.Printf("artifacts: shared via S3-compatible object storage (prefix %q)", blobs.Prefix) + } + st, err := store.OpenPostgresWithOptions(opt.StorageDSN, artifacts, opt.PGPool, blobs) + if err != nil && opt.Tier == "team" { + // team 档拒绝就绪(R2):PG 故障不回落 SQLite,错误必须明说。 + return nil, nop, fmt.Errorf("team 档打开 PostgreSQL 失败,拒绝就绪(不回落 SQLite): %w", err) + } + if err != nil { + return nil, nop, fmt.Errorf("open store: %w", err) + } + if err := verifyConfiguredIdentity(st, "pg"); err != nil { + _ = st.Close() + return nil, nop, err + } + return st, func() { _ = st.Close() }, nil + } + + lease, err := instanceguard.Acquire(opt.DataDir) + if err != nil { + return nil, nop, fmt.Errorf("instance guard: %w", err) + } + releaseLease := func() { lease.Release() } + if err := lease.AutomaticRefusal(); err != nil { + adoptable := opt.AdoptInstanceGuard && (errors.Is(err, instanceguard.ErrDBReplaced) || errors.Is(err, instanceguard.ErrLockCorrupt)) + if !adoptable { + releaseLease() + return nil, nop, fmt.Errorf("instance guard: %w", err) + } + } + st, err := store.OpenSQLite(lease.DBPath()) + if err != nil { + releaseLease() + return nil, nop, fmt.Errorf("open store: %w", err) + } + if err := verifyConfiguredIdentity(st, "sqlite"); err != nil { + _ = st.Close() + releaseLease() + return nil, nop, err + } + if err := instanceguard.Bind(st, lease, instanceguard.BindOptions{ + Adopt: opt.AdoptInstanceGuard, + InstanceID: strings.TrimSpace(os.Getenv("AGENT_SYSTEM_INSTANCE_ID")), + }); err != nil { + _ = st.Close() + releaseLease() + return nil, nop, fmt.Errorf("instance guard: %w", err) + } + return st, func() { + _ = st.Close() + lease.Release() + }, nil +} + +// verifyConfiguredIdentity U1 I3:svc 注入的服务实例身份(manifest.instance_id)与库内 marker 对齐; +// 未设置则跳过(兼容未接线旧调用方)。与 --instance(runs 进程标识)无关。 +func verifyConfiguredIdentity(st *store.Store, defaultRef string) error { + sysID := strings.TrimSpace(os.Getenv("AGENT_SYSTEM_INSTANCE_ID")) + if sysID == "" { + return nil + } + dbref := strings.TrimSpace(os.Getenv("AGENT_SYSTEM_DBREF")) + if dbref == "" { + dbref = defaultRef + } + if err := st.VerifySystemIdentity(sysID, dbref); err != nil { + return fmt.Errorf("system identity: %w", err) + } + return nil +} + +// Migrate 执行显式迁移。SQLite 必须先取得活动服务锁,有服务占用则拒绝。 +func Migrate(opt Options) error { + _, release, err := openServiceStore(opt) + if err != nil { + return err + } + release() + return nil +} diff --git a/internal/app/super_admin.go b/internal/app/super_admin.go new file mode 100644 index 0000000..7392153 --- /dev/null +++ b/internal/app/super_admin.go @@ -0,0 +1,58 @@ +package app + +import ( + "errors" + "fmt" + "log" + + "agent-system-backend/internal/config" + "agent-system-backend/internal/server" + "agent-system-backend/internal/store" +) + +// ErrCLIAdminAppointment rejects every local CLI attempt to create an +// administrator (R4, R5): only the server-side super administrator appoints. +var ErrCLIAdminAppointment = errors.New("administrator appointment is reserved to the super administrator; no CLI path may create an administrator") + +// ErrCLIBootstrapAdminDisabled rejects the retired local bootstrap-admin path: +// only service startup establishes the super administrator (KTD4). +var ErrCLIBootstrapAdminDisabled = errors.New("bootstrap-admin is disabled; the super administrator is established at service startup from SUPER_ADMIN_OWNER and SUPER_ADMIN_PASSWORD") + +// ValidateSuperAdmin is the startup gate for R1: with authentication enabled, +// missing, partial or policy-violating configuration refuses startup. The +// unauthenticated embedded mode neither requires nor uses the credentials. +func ValidateSuperAdmin(authOn bool, sa config.SuperAdmin) error { + if !authOn { + return nil + } + if err := sa.Validate(server.ValidatePassword); err != nil { + return fmt.Errorf("super admin: %w", err) + } + return nil +} + +// BootstrapSuperAdmin establishes the configured super administrator in +// persistent account state (KTD4). Identity conflicts stop startup; logs carry +// the owner only, never the password or its hash. +func BootstrapSuperAdmin(st *store.Store, authOn bool, sa config.SuperAdmin) error { + if !authOn { + return nil + } + if err := ValidateSuperAdmin(authOn, sa); err != nil { + return err + } + hash, err := store.HashPassword(sa.Password()) + if err != nil { + return fmt.Errorf("super admin: hash password: %w", err) + } + out, err := st.BootstrapSuperAdmin(sa.Owner, hash) + if err != nil { + return fmt.Errorf("super admin %q: %w", sa.Owner, err) + } + if out.Created { + log.Printf("super admin: established account %q", sa.Owner) + } else { + log.Printf("super admin: account %q already established", sa.Owner) + } + return nil +} diff --git a/internal/app/super_admin_test.go b/internal/app/super_admin_test.go new file mode 100644 index 0000000..96be25b --- /dev/null +++ b/internal/app/super_admin_test.go @@ -0,0 +1,129 @@ +package app + +import ( + "bytes" + "errors" + "log" + "os" + "path/filepath" + "strings" + "testing" + + "agent-system-backend/internal/config" + "agent-system-backend/internal/store" +) + +const appSuperAdminPassword = "Sup3r-Secret-Value" + +func openSuperAdminStore(t *testing.T) *store.Store { + t.Helper() + st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "agent-system.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + return st +} + +func captureLog(t *testing.T) *bytes.Buffer { + t.Helper() + var buf bytes.Buffer + prev, flags := log.Writer(), log.Flags() + log.SetOutput(&buf) + t.Cleanup(func() { log.SetOutput(prev); log.SetFlags(flags) }) + return &buf +} + +func TestValidateSuperAdminStartupGate(t *testing.T) { + cases := []struct { + name string + sa config.SuperAdmin + }{ + {"both absent", config.SuperAdmin{}}, + {"owner only", config.NewSuperAdmin("root", "")}, + {"password only", config.NewSuperAdmin("", appSuperAdminPassword)}, + {"invalid password", config.NewSuperAdmin("root", "short")}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := ValidateSuperAdmin(true, tc.sa) + if !errors.Is(err, config.ErrSuperAdminConfig) { + t.Fatalf("authentication-enabled startup accepted %s: %v", tc.name, err) + } + if err := ValidateSuperAdmin(false, tc.sa); err != nil { + t.Fatalf("unauthenticated mode must not require credentials: %v", err) + } + }) + } + if err := ValidateSuperAdmin(true, config.NewSuperAdmin("root", appSuperAdminPassword)); err != nil { + t.Fatal(err) + } +} + +func TestBootstrapSuperAdminStartup(t *testing.T) { + st := openSuperAdminStore(t) + logs := captureLog(t) + sa := config.NewSuperAdmin("root", appSuperAdminPassword) + for i := 0; i < 2; i++ { + if err := BootstrapSuperAdmin(st, true, sa); err != nil { + t.Fatalf("bootstrap %d: %v", i, err) + } + } + account, err := st.GetAccountByOwner("root") + if err != nil { + t.Fatal(err) + } + if !account.IsSuperAdmin || !account.IsAdmin { + t.Fatalf("startup did not establish super admin: %+v", account) + } + if strings.Contains(logs.String(), appSuperAdminPassword) || strings.Contains(logs.String(), "argon2") { + t.Fatalf("startup log leaks credential material: %s", logs.String()) + } + if !strings.Contains(logs.String(), "established account") || !strings.Contains(logs.String(), "already established") { + t.Fatalf("startup log lacks bootstrap outcome: %s", logs.String()) + } +} + +func TestBootstrapSuperAdminStartupFailsClosed(t *testing.T) { + st := openSuperAdminStore(t) + if err := st.CreateAccount("root", "", "", false); err != nil { + t.Fatal(err) + } + err := BootstrapSuperAdmin(st, true, config.NewSuperAdmin("root", appSuperAdminPassword)) + if !errors.Is(err, store.ErrSuperAdminConflict) { + t.Fatalf("conflicting owner did not stop startup: %v", err) + } + if strings.Contains(err.Error(), appSuperAdminPassword) { + t.Fatalf("startup error leaks password: %v", err) + } + if err := BootstrapSuperAdmin(st, true, config.NewSuperAdmin("root", "short")); !errors.Is(err, config.ErrSuperAdminConfig) { + t.Fatalf("invalid password reached storage: %v", err) + } +} + +func TestBootstrapSuperAdminSkippedWithoutAuthentication(t *testing.T) { + st := openSuperAdminStore(t) + if err := BootstrapSuperAdmin(st, false, config.SuperAdmin{}); err != nil { + t.Fatal(err) + } + users, err := st.ListUsersAdmin() + if err != nil { + t.Fatal(err) + } + if len(users) != 0 { + t.Fatalf("unauthenticated mode created accounts: %d", len(users)) + } +} + +func TestCLIAccountPathsCannotAppointAdministrators(t *testing.T) { + dir := t.TempDir() + if _, _, err := CreateUserAccount("", dir, "mallory", "", "", true, true, false); !errors.Is(err, ErrCLIAdminAppointment) { + t.Fatalf("CLI create --admin: %v", err) + } + if _, err := BootstrapAdmin("", dir, "mallory", false); !errors.Is(err, ErrCLIBootstrapAdminDisabled) { + t.Fatalf("CLI bootstrap-admin: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "agent-system.db")); !os.IsNotExist(err) { + t.Fatalf("rejected CLI paths touched storage: %v", err) + } +} diff --git a/internal/app/surface_audit_actor_test.go b/internal/app/surface_audit_actor_test.go new file mode 100644 index 0000000..b4f3fb3 --- /dev/null +++ b/internal/app/surface_audit_actor_test.go @@ -0,0 +1,115 @@ +package app + +import ( + "context" + "encoding/json" + "path/filepath" + "testing" + "time" + + agentcommand "agent-command" + "agent-system-backend/internal/cmdbus" + "agent-system-backend/internal/store" + "agent-system-backend/internal/surfacesvc" + "agent-system-backend/pkg/extension" + core "agent-system-core" +) + +func TestSurfaceButtonDispatchUsesAuthenticatedActor(t *testing.T) { + st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "audit.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if err := st.EnsureUser("alice", "Alice at action"); err != nil { + t.Fatal(err) + } + p, err := st.CreateProject("alice", "A") + if err != nil { + t.Fatal(err) + } + c, err := st.CreateConversation("alice", string(p.ID), "work", "") + if err != nil { + t.Fatal(err) + } + bus := cmdbus.New(st, nil, func() time.Time { return time.Now().UTC() }) + if err := bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "demo.submit", Title: "Submit"}, "", func(context.Context, cmdbus.Request) (any, error) { return "ok", nil }); err != nil { + t.Fatal(err) + } + result, err := dispatchSurfaceAction(context.Background(), bus, surfacesvc.ActionDispatch{CommandID: "demo.submit", Owner: "alice", ConversationID: string(c.ID)}) + if err != nil { + t.Fatal(err) + } + if result.State != "committed" { + t.Fatalf("state: %s", result.State) + } + rows, _, _, err := st.ListProjectCommandAudits("alice", string(p.ID), 0, 0, 10) + if err != nil { + t.Fatal(err) + } + if len(rows) != 1 || rows[0].Envelope.Actor != "alice" || rows[0].ActorName != "Alice at action" { + t.Fatalf("button audit actor: %+v", rows) + } +} + +func TestCompiledHandlerReceivesHostOperationID(t *testing.T) { + st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "audit.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + p, err := st.CreateProject("alice", "A") + if err != nil { + t.Fatal(err) + } + c, err := st.CreateConversation("alice", string(p.ID), "work", "") + if err != nil { + t.Fatal(err) + } + bus := cmdbus.New(st, nil, func() time.Time { return time.Now().UTC() }) + slot := &projectAuditSlot{} + caps := hostcaps{st: st, projectAudit: slot} + reg := pluginRegistry{compiledRegistry: compiledRegistry{bus: bus}, pluginID: "rdm", caps: caps} + var seen string + if err := reg.AddCommandHandler(json.RawMessage(`{"id":"rdm.confirm","title":"Confirm"}`), func(_ context.Context, _ extension.Host, req extension.Request) (any, error) { + seen = req.OperationID + return "domain-committed", nil + }); err != nil { + t.Fatal(err) + } + resp := bus.Dispatch(context.Background(), cmdbus.Request{ID: "rdm.confirm", Actor: "alice", Owner: "alice", ConversationID: string(c.ID)}) + rows, _, _, err := st.ListProjectCommandAudits("alice", string(p.ID), 0, 0, 10) + if err != nil { + t.Fatal(err) + } + if seen == "" || resp.Receipt.OperationID != seen || len(rows) != 1 || rows[0].Envelope.OperationID != seen { + t.Fatalf("handler/domain correlation: seen=%q receipt=%+v rows=%+v", seen, resp.Receipt, rows) + } + if err := reg.RegisterProjectAuditAuthorizer(func(actor string, project core.ID) (bool, error) { return actor == "bob" && project == p.ID, nil }); err != nil { + t.Fatal(err) + } + publisherFactory, err := (recordRegistry{Registry: reg}).RegisterProjectAuditLinkVerifier(func(_ context.Context, proof extension.ProjectAuditLinkProof) (bool, error) { + return proof.Owner == "alice" && proof.ProjectID == p.ID && proof.OperationID == seen && proof.SourceID == "domain-commit-1" && proof.ResourceID == "doc-1", nil + }) + if err != nil { + t.Fatal(err) + } + if err := reg.RegisterProjectAuditDisclosureAuthorizer(func(_ context.Context, actor string, r extension.ProjectAuditLinkedResource) (extension.ProjectAuditDisclosure, error) { + return extension.ProjectAuditDisclosure{Metadata: actor == "bob" && r.ResourceID == "doc-1"}, nil + }); err != nil { + t.Fatal(err) + } + publisher, err := publisherFactory.ForOwner("alice") + if err != nil { + t.Fatal(err) + } + linked, err := publisher.PublishProjectAuditLink(context.Background(), extension.ProjectAuditLinkRequest{ProjectID: p.ID, OperationID: seen, SourceKind: "domain-commit", SourceID: "domain-commit-1", ResourceKind: "document", ResourceID: "doc-1", DisclosureClass: "metadata", IdempotencyKey: "outbox-1"}) + if err != nil || linked.HostAuditOffset != rows[0].Offset { + t.Fatalf("publish by trusted operation: %+v %v", linked, err) + } + bob := inprocHost{caps: caps, owner: "bob", actor: "bob", pluginID: "rdm"} + page, err := bob.QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: p.ID, LinkedSourceKind: "domain-commit", LinkedSourceID: "domain-commit-1", LinkedHostOffset: linked.HostAuditOffset}) + if err != nil || len(page.Receipts) != 1 || page.Receipts[0].OperationID != seen { + t.Fatalf("linked read: %+v %v", page, err) + } +} diff --git a/internal/app/surface_dispatch.go b/internal/app/surface_dispatch.go new file mode 100644 index 0000000..7540747 --- /dev/null +++ b/internal/app/surface_dispatch.go @@ -0,0 +1,29 @@ +package app + +import ( + "context" + "fmt" + + "agent-system-backend/internal/cmdbus" + "agent-system-backend/internal/surfacesvc" +) + +// dispatchSurfaceAction receives Owner from SurfaceService's authenticated +// caller. The button is acting for that user, so both Actor and Owner must be +// populated before the command bus writes its audit envelope. +func dispatchSurfaceAction(ctx context.Context, bus *cmdbus.Bus, d surfacesvc.ActionDispatch) (surfacesvc.ActionOutcome, error) { + if cmdbus.RequiresProjectWorkDecision(d.CommandID) { + return surfacesvc.ActionOutcome{}, fmt.Errorf("project formal action requires a committed conversation source preflight") + } + resp := bus.Dispatch(ctx, cmdbus.Request{ID: d.CommandID, Params: d.Params, + Entrance: "button", Actor: d.Owner, Owner: d.Owner, ConversationID: d.ConversationID}) + state := "committed" + if resp.Receipt.State != "" { + state = string(resp.Receipt.State) + } + cardID := "" + if resp.Card != nil { + cardID = string(resp.Card.ID) + } + return surfacesvc.ActionOutcome{State: state, CardID: cardID, Receipt: resp.Receipt, Payload: resp.Payload}, nil +} diff --git a/internal/app/task_startup_test.go b/internal/app/task_startup_test.go new file mode 100644 index 0000000..d7f0665 --- /dev/null +++ b/internal/app/task_startup_test.go @@ -0,0 +1,96 @@ +package app + +import ( + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "syscall" + "testing" + "time" + + "agent-system-backend/internal/store" + core "agent-system-core" +) + +func TestTaskStartupReconcilesQueueBeforeServing(t *testing.T) { + if os.Getenv("TASK_STARTUP_CHILD") == "1" { + if err := Run(Options{Addr: os.Getenv("TASK_STARTUP_ADDR"), DataDir: os.Getenv("TASK_STARTUP_DATA"), PluginsDir: filepath.Join(os.Getenv("TASK_STARTUP_DATA"), "plugins"), ForceMock: true, InstanceID: "startup-test", DisableExecTools: true, DisableConvCardMirror: true}); err != nil { + t.Fatal(err) + } + return + } + data := t.TempDir() + st, err := store.OpenSQLite(filepath.Join(data, "agent-system.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + p, err := st.CreateProject(store.OwnerDefault, "startup") + if err != nil { + t.Fatal(err) + } + conv, err := st.CreateConversation(store.OwnerDefault, p.ID, "startup", "") + if err != nil { + t.Fatal(err) + } + card := core.ActivityCard{ID: "card-startup", ProjectID: p.ID, ConversationID: conv.ID, Kind: core.CardKindProgress, Status: core.CardActive, Title: "整理"} + card.Task = &core.TaskSnapshot{SchemaVersion: 1, TaskID: "task-startup", AttemptID: "attempt-startup", CardID: card.ID, ProjectID: p.ID, ConversationID: conv.ID, OwnerInstanceID: "startup-test", Revision: 1, StreamScope: "conversation:" + conv.ID, ExecutionState: "queued", OutcomeCompleteness: "none", DeliveryState: "pending", Title: "整理"} + if _, err = st.CommitTaskProjection(store.TaskProjectionWrite{Owner: conv.Owner, Card: card}); err != nil { + t.Fatal(err) + } + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + addr := ln.Addr().String() + ln.Close() + cmd := exec.Command(os.Args[0], "-test.run=^TestTaskStartupReconcilesQueueBeforeServing$") + cmd.Env = append(os.Environ(), "TASK_STARTUP_CHILD=1", "TASK_STARTUP_ADDR="+addr, "TASK_STARTUP_DATA="+data) + logfile, err := os.Create(filepath.Join(t.TempDir(), "startup.log")) + if err != nil { + t.Fatal(err) + } + defer logfile.Close() + cmd.Stdout, cmd.Stderr = logfile, logfile + if err = cmd.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = cmd.Process.Signal(syscall.SIGTERM) + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + select { + case <-done: + case <-time.After(5 * time.Second): + _ = cmd.Process.Kill() + <-done + } + }) + client := &http.Client{Timeout: time.Second} + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + resp, err := client.Get("http://" + addr + "/api/conversations/" + conv.ID + "/cards") + if err == nil { + resp.Body.Close() + if resp.StatusCode == http.StatusOK { + got, err := st.GetCard(conv.Owner, card.ID) + if err != nil || got.Task == nil || got.Task.ExecutionState != "interrupted" || got.Task.RunID != "" { + t.Fatalf("service became ready with orphan queue: %+v, %v", got.Task, err) + } + if got.Task.Conclusion != "排队任务未恢复,未开始执行" { + t.Fatal(got.Task.Conclusion) + } + projectEvents, err := st.EventsAfter("project:"+p.ID, 0, 100) + if err != nil || len(projectEvents) != 0 { + t.Fatalf("recovery ignored disabled card mirroring: %v %v", projectEvents, err) + } + return + } + } + time.Sleep(20 * time.Millisecond) + } + b, _ := os.ReadFile(logfile.Name()) + t.Fatalf("startup did not become ready: %s", b) +} diff --git a/internal/app/tavily_keys.go b/internal/app/tavily_keys.go new file mode 100644 index 0000000..8a76067 --- /dev/null +++ b/internal/app/tavily_keys.go @@ -0,0 +1,23 @@ +package app + +import ( + "agent-system-backend/internal/settings" + "agent-system-backend/internal/store" +) + +const tavilySecretService = "tools.tavily" + +type activeSecretSource interface { + ActiveSecret(owner, service string) (string, bool) +} + +// tavilyKeyPool 解析 Tavily 密钥池:默认属主受管密钥 → 系统行受管密钥 +// (owner=settings.OwnerSystemRow)→ 设置明文 tools.tavily.apiKeys。 +func tavilyKeyPool(secrets activeSecretSource, plaintext string) []string { + for _, owner := range []string{store.OwnerDefault, settings.OwnerSystemRow} { + if active, ok := secrets.ActiveSecret(owner, tavilySecretService); ok { + return splitKeyPool(active) + } + } + return splitKeyPool(plaintext) +} diff --git a/internal/app/tavily_keys_test.go b/internal/app/tavily_keys_test.go new file mode 100644 index 0000000..0618c99 --- /dev/null +++ b/internal/app/tavily_keys_test.go @@ -0,0 +1,53 @@ +package app + +import ( + "path/filepath" + "reflect" + "testing" + + "agent-system-backend/internal/settings" + "agent-system-backend/internal/store" +) + +func newTavilyKeysStore(t *testing.T) *store.Store { + t.Helper() + st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "tavily.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + return st +} + +func TestTavilyKeyPoolFallsBackToSystemRowSecret(t *testing.T) { + st := newTavilyKeysStore(t) + if _, err := st.AddSecret(settings.OwnerSystemRow, tavilySecretService, "system", "fake-sys-a, fake-sys-b"); err != nil { + t.Fatal(err) + } + if got, want := tavilyKeyPool(st, "fake-plain"), []string{"fake-sys-a", "fake-sys-b"}; !reflect.DeepEqual(got, want) { + t.Fatalf("system-row key pool = %v, want %v", got, want) + } +} + +func TestTavilyKeyPoolPrefersOwnerRowSecret(t *testing.T) { + st := newTavilyKeysStore(t) + if _, err := st.AddSecret(settings.OwnerSystemRow, tavilySecretService, "system", "fake-sys"); err != nil { + t.Fatal(err) + } + if _, err := st.AddSecret(store.OwnerDefault, tavilySecretService, "owner", "fake-owner"); err != nil { + t.Fatal(err) + } + if got, want := tavilyKeyPool(st, "fake-plain"), []string{"fake-owner"}; !reflect.DeepEqual(got, want) { + t.Fatalf("owner-row key pool = %v, want %v", got, want) + } +} + +func TestTavilyKeyPoolFallsBackToPlaintextSetting(t *testing.T) { + st := newTavilyKeysStore(t) + if got, want := tavilyKeyPool(st, " fake-plain-a ,, fake-plain-b"), []string{"fake-plain-a", "fake-plain-b"}; !reflect.DeepEqual(got, want) { + t.Fatalf("plaintext key pool = %v, want %v", got, want) + } + if got := tavilyKeyPool(st, ""); len(got) != 0 { + t.Fatalf("empty key pool = %v", got) + } +} diff --git a/internal/authz/catalog/catalog.go b/internal/authz/catalog/catalog.go new file mode 100644 index 0000000..6d7a20e --- /dev/null +++ b/internal/authz/catalog/catalog.go @@ -0,0 +1,93 @@ +// Package catalog owns the trusted action catalog used by system, role, and +// project authorization. Callers cannot invent an action or its scope. +package catalog + +type Scope string + +const ( + ScopeSystem Scope = "system" + ScopeProject Scope = "project" +) + +type Descriptor struct { + ActionID string `json:"actionId"` + Label string `json:"label"` + Description string `json:"description"` + Group string `json:"group"` + Scope Scope `json:"scope"` + Protected bool `json:"protected"` +} + +var descriptors = []Descriptor{ + {ActionID: "system.settings.read", Label: "查看系统设置", Description: "查看系统范围的受管配置", Group: "系统设置", Scope: ScopeSystem, Protected: true}, + {ActionID: "system.settings.write", Label: "修改系统设置", Description: "修改系统范围的受管配置", Group: "系统设置", Scope: ScopeSystem, Protected: true}, + {ActionID: "system.users.view", Label: "查看用户", Description: "查看账号目录及获准资料", Group: "用户管理", Scope: ScopeSystem, Protected: true}, + {ActionID: "system.users.create", Label: "创建用户", Description: "创建普通账号", Group: "用户管理", Scope: ScopeSystem, Protected: true}, + {ActionID: "system.users.edit", Label: "编辑用户", Description: "编辑账号的非凭据资料", Group: "用户管理", Scope: ScopeSystem, Protected: true}, + {ActionID: "system.users.disable", Label: "停用用户", Description: "停用或重新启用账号", Group: "用户管理", Scope: ScopeSystem, Protected: true}, + {ActionID: "system.roles.view", Label: "查看角色", Description: "查看职位角色及绑定人数", Group: "角色管理", Scope: ScopeSystem, Protected: true}, + {ActionID: "system.roles.manage", Label: "管理角色", Description: "创建、编辑和停用职位角色", Group: "角色管理", Scope: ScopeSystem, Protected: true}, + {ActionID: "system.roles.assign", Label: "分配角色", Description: "替换用户唯一生效职位角色", Group: "角色管理", Scope: ScopeSystem, Protected: true}, + {ActionID: "system.permissions.view", Label: "查看系统权限", Description: "查看系统授权、角色默认与上限", Group: "权限管理", Scope: ScopeSystem, Protected: true}, + {ActionID: "system.permissions.manage", Label: "管理系统权限", Description: "配置明确系统授权与平台上限", Group: "权限管理", Scope: ScopeSystem, Protected: true}, + {ActionID: "project.create", Label: "创建项目", Description: "创建新的项目", Group: "项目入口", Scope: ScopeSystem}, + {ActionID: "project.settings", Label: "项目设置", Description: "修改本项目设置", Group: "项目管理", Scope: ScopeProject}, + {ActionID: "project.archive", Label: "归档项目", Description: "归档或恢复本人创建的项目", Group: "项目管理", Scope: ScopeProject}, + {ActionID: "project.stage.advance", Label: "推进项目阶段", Description: "推进本人创建项目的阶段", Group: "项目管理", Scope: ScopeProject}, + {ActionID: "project.members.manage", Label: "管理项目成员", Description: "维护本项目成员", Group: "项目管理", Scope: ScopeProject}, + {ActionID: "project.permissions.manage", Label: "管理项目权限", Description: "维护本项目成员权限覆盖", Group: "项目管理", Scope: ScopeProject}, + {ActionID: "document.duties.assign", Label: "分配文档三权", Description: "分配本项目文档编制、审核和批准职责", Group: "文档职责", Scope: ScopeProject}, + {ActionID: "document.read", Label: "读取文档", Description: "读取获准的项目文档", Group: "文档动作", Scope: ScopeProject}, + {ActionID: "document.author", Label: "编制文档", Description: "履行已分配的文档编制职责", Group: "文档动作", Scope: ScopeProject}, + {ActionID: "document.review", Label: "审核文档", Description: "履行已分配的文档审核职责", Group: "文档动作", Scope: ScopeProject}, + {ActionID: "document.approve", Label: "批准文档", Description: "履行已分配的文档批准职责", Group: "文档动作", Scope: ScopeProject}, + {ActionID: "document.publish", Label: "发布文档", Description: "发布已完成审签的项目文档", Group: "文档动作", Scope: ScopeProject}, + {ActionID: "trace.edit", Label: "编辑追溯网", Description: "编辑本项目追溯关系", Group: "项目内容", Scope: ScopeProject}, + {ActionID: "knowledge.publish", Label: "发布项目知识", Description: "发布或撤销本项目知识", Group: "项目内容", Scope: ScopeProject}, + {ActionID: "project.audit.read", Label: "读取业务审计", Description: "读取获准项目的业务审计", Group: "项目内容", Scope: ScopeProject}, +} + +var descriptorByID = func() map[string]Descriptor { + out := make(map[string]Descriptor, len(descriptors)) + for _, descriptor := range descriptors { + out[descriptor.ActionID] = descriptor + } + return out +}() + +func DescriptorByID(id string) (Descriptor, bool) { + descriptor, ok := descriptorByID[id] + return descriptor, ok +} + +func Catalog() []Descriptor { + out := make([]Descriptor, len(descriptors)) + copy(out, descriptors) + return out +} + +// ProjectCreateAction is the system action whose holder manages the projects +// they create. Enabling it links ProjectCreateLinkedActions for those projects. +const ProjectCreateAction = "project.create" + +var projectCreateLinkedActions = []string{ + "project.settings", + "project.members.manage", + "project.permissions.manage", + "document.duties.assign", +} + +// ProjectCreateLinkedActions returns the own-project management capabilities +// that a project creator receives together with project.create. +func ProjectCreateLinkedActions() []string { + return append([]string(nil), projectCreateLinkedActions...) +} + +func IsProjectCreateLinkedAction(id string) bool { + for _, linked := range projectCreateLinkedActions { + if linked == id { + return true + } + } + return false +} diff --git a/internal/authz/catalog/catalog_test.go b/internal/authz/catalog/catalog_test.go new file mode 100644 index 0000000..85e7d47 --- /dev/null +++ b/internal/authz/catalog/catalog_test.go @@ -0,0 +1,49 @@ +package catalog + +import "testing" + +func TestCatalogRejectsUnknownAndProtectsManagementActions(t *testing.T) { + if _, ok := DescriptorByID("invented.action"); ok { + t.Fatal("unknown action registered") + } + got, ok := DescriptorByID("system.roles.manage") + if !ok || got.Scope != ScopeSystem || !got.Protected { + t.Fatalf("descriptor=%+v", got) + } + create, ok := DescriptorByID("project.create") + if !ok || create.Scope != ScopeSystem || create.Protected { + t.Fatalf("project.create=%+v", create) + } +} + +func TestProjectCreateLinkedActionsAreCatalogedProjectActions(t *testing.T) { + linked := ProjectCreateLinkedActions() + if len(linked) != 4 { + t.Fatalf("linked=%v", linked) + } + for _, actionID := range linked { + descriptor, ok := DescriptorByID(actionID) + if !ok || descriptor.Scope != ScopeProject || descriptor.Protected || !IsProjectCreateLinkedAction(actionID) { + t.Fatalf("linked action %s descriptor=%+v ok=%v", actionID, descriptor, ok) + } + } + if IsProjectCreateLinkedAction(ProjectCreateAction) || IsProjectCreateLinkedAction("document.read") { + t.Fatal("unlinked action reported as linked") + } + linked[0] = "mutated" + if ProjectCreateLinkedActions()[0] == "mutated" { + t.Fatal("linked actions exposed mutable storage") + } +} + +func TestCatalogReturnsIndependentCopies(t *testing.T) { + first := Catalog() + if len(first) == 0 { + t.Fatal("empty catalog") + } + first[0].Label = "mutated" + second := Catalog() + if second[0].Label == "mutated" { + t.Fatal("Catalog exposed mutable registry storage") + } +} diff --git a/internal/authz/service.go b/internal/authz/service.go new file mode 100644 index 0000000..86cb0ee --- /dev/null +++ b/internal/authz/service.go @@ -0,0 +1,110 @@ +package authz + +import ( + "agent-system-backend/internal/authz/catalog" + "agent-system-backend/internal/store" +) + +type Descriptor = catalog.Descriptor +type Scope = catalog.Scope + +const ( + ScopeSystem = catalog.ScopeSystem + ScopeProject = catalog.ScopeProject +) + +func Catalog() []Descriptor { return catalog.Catalog() } + +type Decision struct { + Allowed bool `json:"allowed"` + ActionID string `json:"actionId"` + Source string `json:"source,omitempty"` + Reason string `json:"reason,omitempty"` + AuthorityEpoch int64 `json:"authorityEpoch"` +} + +type Service struct { + store *store.Store +} + +func NewService(st *store.Store) *Service { return &Service{store: st} } + +func (s *Service) AuthorizeSystem(actor, actionID string) Decision { + decision := Decision{ActionID: actionID} + descriptor, ok := catalog.DescriptorByID(actionID) + if !ok || descriptor.Scope != catalog.ScopeSystem || s == nil || s.store == nil { + decision.Reason = "unknown-system-action" + return decision + } + epoch, err := s.store.AuthorityEpoch() + if err != nil { + decision.Reason = "authority-unavailable" + return decision + } + decision.AuthorityEpoch = epoch + account, err := s.store.GetAccountByOwner(actor) + if err != nil || !account.Enabled { + decision.Reason = "account-unavailable" + return decision + } + // is_admin remains the narrow bootstrap/root recovery source. Ordinary + // delegated management uses explicit system_grants. + if account.IsAdmin { + decision.Allowed, decision.Source = true, "bootstrap-admin" + return decision + } + allowed, err := s.store.SystemGrantAllowed(actor, actionID) + if err != nil { + decision.Reason = "authority-unavailable" + return decision + } + if allowed { + decision.Allowed, decision.Source = true, "system-grant" + return decision + } + if actionID == "project.create" { + roleAllowed, capAllowed, err := s.store.RolePermissionAllowed(actor, actionID) + if err != nil { + decision.Reason = "authority-unavailable" + return decision + } + if roleAllowed && capAllowed { + decision.Allowed, decision.Source = true, "role" + return decision + } + } + decision.Reason = "not-granted" + return decision +} + +// EffectiveLinkedCapabilities returns the own-project management actions that +// the actor's project.create links. HTTP and command readers share this call +// so the link is computed once per actor from the Backend authority. +func (s *Service) EffectiveLinkedCapabilities(actor string) (store.CapabilityAssociation, error) { + if s == nil || s.store == nil { + return store.CapabilityAssociation{LinkedActions: []string{}}, store.ErrAccountForbidden + } + return s.store.LinkedCapabilities(actor) +} + +// ExplainCapabilities explains only the actor's own creator, member and +// document-duty capabilities; HTTP and Host providers share this call. +func (s *Service) ExplainCapabilities(actor string) (store.CapabilityExplanation, error) { + if s == nil || s.store == nil { + return store.CapabilityExplanation{}, store.ErrAccountForbidden + } + return s.store.ExplainCapabilities(actor) +} + +func (s *Service) EffectiveSystemActions(actor string) []string { + actions := []string{} + for _, descriptor := range catalog.Catalog() { + if descriptor.Scope != catalog.ScopeSystem { + continue + } + if decision := s.AuthorizeSystem(actor, descriptor.ActionID); decision.Allowed { + actions = append(actions, descriptor.ActionID) + } + } + return actions +} diff --git a/internal/cardsvc/account_checked.go b/internal/cardsvc/account_checked.go new file mode 100644 index 0000000..f207dd3 --- /dev/null +++ b/internal/cardsvc/account_checked.go @@ -0,0 +1,249 @@ +package cardsvc + +import ( + "agent-system-backend/internal/store" + core "agent-system-core" + "errors" +) + +func (s *Service) requireAccountCheckedEmitter() error { + if s.emit != nil && s.accountCheckedEmit == nil { + return ErrGuardedEmitterRequired + } + return nil +} + +func (s *Service) ResolveByRefAccountChecked(actor, ref, actionID, answer string, allowImplicitDefault bool) error { + if err := s.requireAccountCheckedEmitter(); err != nil { + return err + } + id, err := s.st.CardIDByRef(actor, ref) + if errors.Is(err, store.ErrNotFound) { + return nil + } + if err != nil { + return err + } + card, version, err := s.st.GetCardVersion(actor, id) + if err != nil { + return err + } + if card.Status.Terminal() { + return nil + } + card.Status = core.CardResolved + recordAnswer(&card, actionID, answer) + if err := s.UpdateVersionedAccountChecked(actor, card, version, allowImplicitDefault); errors.Is(err, store.ErrConflict) { + return nil + } else if err != nil { + return err + } + return s.emitCardAccountChecked(actor, card, core.EvCardResolved, + core.CardResolvedPayload{CardID: card.ID, ActionID: actionID, Answer: answer}) +} + +// AppendAccountChecked preserves stream-card throttling while fencing its +// durable body update against account disable. +func (s *Service) AppendAccountChecked(actor, id, text string, allowImplicitDefault bool) error { + if err := s.requireAccountCheckedEmitter(); err != nil { + return err + } + card, version, err := s.st.GetCardVersion(actor, id) + if err != nil { + return err + } + if card.Task != nil { + return ErrNoAction + } + if text != "" { + if n := len(card.Body); n > 0 && card.Body[n-1].Type == core.UIMarkdown { + card.Body[n-1].Markdown += text + } else { + card.Body = append(card.Body, core.UINode{Type: core.UIMarkdown, Markdown: text}) + } + } + card.UpdatedAt = s.now().UTC() + if err := s.st.SaveCardVersionedAccountChecked(actor, &card, "", version, allowImplicitDefault); err != nil { + return err + } + if card.ConversationID == "" && card.ProjectID == "" { + return nil + } + s.emitMu.Lock() + last, seen := s.lastEmit[id] + emit := !seen || s.now().Sub(last) >= cardAppendWindow + if emit { + s.lastEmit[id] = s.now() + } + s.emitMu.Unlock() + if !emit { + return nil + } + return s.emitCardAccountChecked(actor, card, core.EvCardUpdated, core.CardPayload{Card: card}) +} + +func (s *Service) emitCardAccountChecked(actor string, card core.ActivityCard, typ core.StreamEventType, payload any) error { + if s.accountCheckedEmit == nil || (card.ConversationID == "" && card.ProjectID == "") { + return nil + } + if card.ConversationID != "" { + if err := s.accountCheckedEmit(actor, card.ConversationID, card.SourceRunID, typ, payload); err != nil { + return err + } + if !s.MirrorConvCards { + return nil + } + } + if card.ProjectID != "" { + return s.accountCheckedEmit(actor, string(core.ProjectChannelKey(card.ProjectID)), card.SourceRunID, typ, payload) + } + return nil +} + +// CreateAccountChecked creates a card under the actor's final Store fence. +func (s *Service) CreateAccountChecked(actor string, card core.ActivityCard, ref string, allowImplicitDefault bool) (core.ActivityCard, error) { + if err := s.requireAccountCheckedEmitter(); err != nil { + return card, err + } + if card.Task != nil { + return card, store.ErrConflict + } + if card.ID != "" { + if existing, err := s.st.GetCard(actor, card.ID); err == nil && existing.Task != nil { + return card, store.ErrConflict + } + } + if card.ID == "" { + card.ID = store.NewID("card") + } + now := s.now().UTC() + card.CreatedAt, card.UpdatedAt = now, now + if card.Status == "" { + card.Status = core.CardActive + } + if err := s.st.SaveCardAccountChecked(actor, &card, ref, allowImplicitDefault); err != nil { + return card, err + } + return card, s.emitCardAccountChecked(actor, card, core.EvCardCreated, core.CardPayload{Card: card}) +} + +func (s *Service) updateVersionedAccountChecked(actor string, card core.ActivityCard, expectedVersion int, allowImplicitDefault bool) (core.ActivityCard, error) { + if err := s.requireAccountCheckedEmitter(); err != nil { + return card, err + } + if card.Task == nil { + if current, err := s.st.GetCard(actor, card.ID); err == nil && current.Task != nil { + return card, store.ErrConflict + } + } + if card.Task != nil { + // Task cards keep their revision-checked projection and its in-transaction + // card events; the account fence covers that same transaction. + if err := s.commitProjection(store.TaskProjectionWrite{Owner: actor, Card: card, ExpectedCardVersion: expectedVersion, + ExpectedTaskRevision: card.Task.Revision, Channels: s.taskChannels(card), AccountActor: actor, AllowImplicitDefault: allowImplicitDefault}); err != nil { + return card, err + } + return s.Get(actor, card.ID) + } + card.UpdatedAt = s.now().UTC() + if err := s.st.SaveCardVersionedAccountChecked(actor, &card, "", expectedVersion, allowImplicitDefault); err != nil { + return card, err + } + return card, s.emitCardAccountChecked(actor, card, core.EvCardUpdated, core.CardPayload{Card: card}) +} + +func (s *Service) UpdateVersionedAccountChecked(actor string, card core.ActivityCard, expectedVersion int, allowImplicitDefault bool) error { + _, err := s.updateVersionedAccountChecked(actor, card, expectedVersion, allowImplicitDefault) + return err +} + +func (s *Service) SetPinnedAccountChecked(actor, id string, pinned, allowImplicitDefault bool) (core.ActivityCard, error) { + card, version, err := s.st.GetCardVersion(actor, id) + if err != nil { + return card, err + } + card.Pinned = pinned + card, err = s.updateVersionedAccountChecked(actor, card, version, allowImplicitDefault) + if err != nil { + return card, err + } + return card, nil +} + +func (s *Service) SetStatusAccountChecked(actor, id string, status core.CardStatus, tone, note string, allowImplicitDefault bool) (core.ActivityCard, error) { + card, version, err := s.st.GetCardVersion(actor, id) + if err != nil { + return card, err + } + card.Status = status + if tone != "" { + card.Tone = tone + } + card, err = s.updateVersionedAccountChecked(actor, card, version, allowImplicitDefault) + if err != nil { + return card, err + } + if card.Task != nil { + return card, nil + } + return card, s.emitCardAccountChecked(actor, card, core.EvCardStatus, + core.CardStatusPayload{CardID: card.ID, Status: status, Tone: card.Tone, Note: note}) +} + +func (s *Service) DismissAccountChecked(actor, id string, allowImplicitDefault bool) (core.ActivityCard, error) { + return s.SetStatusAccountChecked(actor, id, core.CardDismissed, "", "dismissed by user", allowImplicitDefault) +} + +func (s *Service) ActionAccountChecked(actor, id, actionID, freeText string, allowImplicitDefault bool) (core.ActivityCard, string, error) { + card, version, err := s.st.GetCardVersion(actor, id) + if err != nil { + return card, "", err + } + if card.Status != core.CardActionRequired { + return card, "", ErrNotAwaiting + } + label, found := actionID, false + for _, action := range card.Actions { + if action.ID == actionID { + label, found = action.Label, true + break + } + } + if !found { + if actionID == "" && freeText != "" { + label = freeText + } else { + return card, "", ErrNoAction + } + } + card.Status = core.CardResolved + recordAnswer(&card, actionID, freeText) + card, err = s.updateVersionedAccountChecked(actor, card, version, allowImplicitDefault) + if err != nil { + return card, "", err + } + err = s.emitCardAccountChecked(actor, card, core.EvCardResolved, + core.CardResolvedPayload{CardID: card.ID, ActionID: actionID, Answer: freeText}) + return card, label, err +} + +// ToProjectAccountChecked performs the copy and dismissal in one transaction. +// Events are published after commit through the actor-aware event sink. +func (s *Service) ToProjectAccountChecked(actor, id string, allowImplicitDefault bool) (core.ActivityCard, error) { + if err := s.requireAccountCheckedEmitter(); err != nil { + return core.ActivityCard{}, err + } + archived, origin, created, err := s.st.ArchiveCardToProjectAccountChecked(actor, id, s.now().UTC(), allowImplicitDefault) + if err != nil || !created { + return archived, err + } + if err := s.emitCardAccountChecked(actor, archived, core.EvCardCreated, core.CardPayload{Card: archived}); err != nil { + return archived, err + } + if err := s.emitCardAccountChecked(actor, origin, core.EvCardUpdated, core.CardPayload{Card: origin}); err != nil { + return archived, err + } + err = s.emitCardAccountChecked(actor, origin, core.EvCardStatus, + core.CardStatusPayload{CardID: origin.ID, Status: core.CardDismissed, Tone: origin.Tone, Note: "已留档到项目"}) + return archived, err +} diff --git a/internal/cardsvc/account_checked_test.go b/internal/cardsvc/account_checked_test.go new file mode 100644 index 0000000..10d2a5b --- /dev/null +++ b/internal/cardsvc/account_checked_test.go @@ -0,0 +1,109 @@ +package cardsvc + +import ( + "errors" + "testing" + + "agent-system-backend/internal/store" + core "agent-system-core" +) + +func TestAccountCheckedCardActionsUseActorEventSinkAndRespectDisable(t *testing.T) { + st, err := store.OpenSQLite(t.TempDir() + "/cards.db") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + if err := st.CreateAccount("admin", "", "", true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("alice", "", "", false); err != nil { + t.Fatal(err) + } + project, err := st.CreateProject("alice", "cards") + if err != nil { + t.Fatal(err) + } + conv, err := st.CreateConversation("alice", project.ID, "cards", "") + if err != nil { + t.Fatal(err) + } + legacyCalls := 0 + svc := New(st, func(string, string, core.StreamEventType, any) error { + legacyCalls++ + return nil + }) + type emitted struct { + actor, channel string + typ core.StreamEventType + } + var guarded []emitted + svc.SetAccountCheckedEmitter(func(actor, channel, runID string, typ core.StreamEventType, payload any) error { + guarded = append(guarded, emitted{actor, channel, typ}) + return nil + }) + base := core.ActivityCard{ProjectID: project.ID, ConversationID: conv.ID, Title: "Action", + Status: core.CardActionRequired, Actions: []core.UIAction{{ID: "yes", Label: "Approve"}}} + action, err := svc.CreateAccountChecked("alice", base, "", false) + if err != nil { + t.Fatal(err) + } + if _, label, err := svc.ActionAccountChecked("alice", action.ID, "yes", "", false); err != nil || label != "Approve" { + t.Fatalf("guarded action: label=%q err=%v", label, err) + } + pending, err := svc.CreateAccountChecked("alice", base, "", false) + if err != nil { + t.Fatal(err) + } + pin, err := svc.CreateAccountChecked("alice", core.ActivityCard{ProjectID: project.ID, ConversationID: conv.ID, Title: "Pin"}, "", false) + if err != nil { + t.Fatal(err) + } + if _, err := svc.SetPinnedAccountChecked("alice", pin.ID, true, false); err != nil { + t.Fatal(err) + } + if _, err := svc.DismissAccountChecked("alice", pin.ID, false); err != nil { + t.Fatal(err) + } + archive, err := svc.CreateAccountChecked("alice", core.ActivityCard{ProjectID: project.ID, ConversationID: conv.ID, Title: "Archive"}, "", false) + if err != nil { + t.Fatal(err) + } + archived, err := svc.ToProjectAccountChecked("alice", archive.ID, false) + if err != nil || archived.ConversationID != "" { + t.Fatalf("guarded archive: %+v %v", archived, err) + } + if legacyCalls != 0 { + t.Fatalf("guarded card operations used unguarded emitter %d times", legacyCalls) + } + if len(guarded) == 0 { + t.Fatal("guarded operations published no events") + } + for _, event := range guarded { + if event.actor != "alice" { + t.Fatalf("event actor lost: %+v", event) + } + } + before := len(guarded) + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + for _, mutate := range []func() error{ + func() error { _, err := svc.CreateAccountChecked("alice", base, "", false); return err }, + func() error { _, _, err := svc.ActionAccountChecked("alice", pending.ID, "yes", "", false); return err }, + func() error { _, err := svc.SetPinnedAccountChecked("alice", pin.ID, false, false); return err }, + func() error { _, err := svc.DismissAccountChecked("alice", pin.ID, false); return err }, + func() error { _, err := svc.ToProjectAccountChecked("alice", archive.ID, false); return err }, + } { + if err := mutate(); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("disabled actor mutated card: %v", err) + } + } + if len(guarded) != before { + t.Fatalf("disabled actor published %d events", len(guarded)-before) + } +} diff --git a/internal/cardsvc/cardsvc.go b/internal/cardsvc/cardsvc.go index 6b4d0ab..a38f08a 100644 --- a/internal/cardsvc/cardsvc.go +++ b/internal/cardsvc/cardsvc.go @@ -17,19 +17,27 @@ import ( // 获得持久化、seq 排序、断线重放与终态对账(Stream 契约)。 type Emitter func(conversationID, runID string, typ core.StreamEventType, payload any) error +// AccountCheckedEmitter persists an actor-attributed event through its own +// final account-checked Store transaction. The card write and event emission +// are separate commits; the application wires this to its guarded event sink. +type AccountCheckedEmitter func(actor, conversationID, runID string, typ core.StreamEventType, payload any) error + var ( - ErrNotFound = store.ErrNotFound // 语义对齐存储层:跨租户/不存在一律 NotFound - ErrNoAction = errors.New("card: action not found") - ErrNotAwaiting = errors.New("card: not awaiting action") + ErrNotFound = store.ErrNotFound // 语义对齐存储层:跨租户/不存在一律 NotFound + ErrNoAction = errors.New("card: action not found") + ErrNotAwaiting = errors.New("card: not awaiting action") + ErrGuardedEmitterRequired = errors.New("card: account-checked emitter required") ) // cardAppendWindow 是流式追加(Append)的 card.updated 发布节流窗口(D45)。 const cardAppendWindow = 250 * time.Millisecond type Service struct { - st *store.Store - emit Emitter - projectSharedEmit ProjectSharedEmitter + TaskCommitter func(store.TaskProjectionWrite) ([]core.StreamEvent, error) + st *store.Store + emit Emitter + accountCheckedEmit AccountCheckedEmitter + projectSharedEmit ProjectSharedEmitter // MirrorConvCards EXT-12:会话卡是否镜像进项目公共流(缺省 true=现状全量 // 镜像;false 时会话卡只进本人会话流,项目级卡不受影响)。 MirrorConvCards bool @@ -42,6 +50,10 @@ func New(st *store.Store, emit Emitter) *Service { return &Service{st: st, emit: emit, now: time.Now, lastEmit: map[string]time.Time{}, MirrorConvCards: true} } +func (s *Service) SetAccountCheckedEmitter(emit AccountCheckedEmitter) { + s.accountCheckedEmit = emit +} + // ProjectSharedEmitter is an internal trusted publication adapter. It is never // bound to model arguments or exposed as a mandatory extension capability. type ProjectSharedEmitter func(owner, producer, channel, runID string, typ core.StreamEventType, payload any) error @@ -108,6 +120,14 @@ func (s *Service) emitCard(c core.ActivityCard, typ core.StreamEventType, payloa // (HITL 投影卡 = "hitl:",to_project 留档副本 = "to_project:<原卡ID>"); // ConversationID 为空的项目级卡发布到项目流。 func (s *Service) Create(owner string, card core.ActivityCard, ref string) (core.ActivityCard, error) { + if card.Task != nil { + return card, store.ErrConflict + } + if card.ID != "" { + if existing, err := s.st.GetCard(owner, card.ID); err == nil && existing.Task != nil { + return card, store.ErrConflict + } + } if card.ID == "" { card.ID = store.NewID("card") } @@ -124,16 +144,53 @@ func (s *Service) Create(owner string, card core.ActivityCard, ref string) (core } // Get 读取单卡。 -func (s *Service) Get(owner, id string) (core.ActivityCard, error) { return s.st.GetCard(owner, id) } +func (s *Service) Get(owner, id string) (core.ActivityCard, error) { + c, err := s.st.GetCard(owner, id) + if err == nil { + c = s.present(owner, c) + } + return c, err +} // List 按会话列出(updated_at 降序;置顶与待办排序是前端关注点)。 func (s *Service) List(owner, conversationID string) ([]core.ActivityCard, error) { - return s.st.ListCards(owner, conversationID) + cards, err := s.st.ListCards(owner, conversationID) + if err == nil { + cards = s.PresentCards(owner, cards) + } + return cards, err +} + +// PresentCards applies the read-time projections to cards read elsewhere +// (for example a cursor snapshot), in place. +func (s *Service) PresentCards(owner string, cards []core.ActivityCard) []core.ActivityCard { + for i := range cards { + cards[i] = s.present(owner, cards[i]) + } + return cards +} + +// present applies every read-time projection: resolved HITL answers and +// task source visibility. +func (s *Service) present(owner string, c core.ActivityCard) core.ActivityCard { + return s.VisibleTaskCard(owner, s.presentResolved(owner, c)) } // Update 保存整卡变更(流式 Body 增量等)并发布 card.updated;ref 保持原值。 // 系统镜像路径(同会话 run 的顺序行为)沿用盲写;用户态裁决走 UpdateVersioned。 func (s *Service) Update(owner string, card core.ActivityCard) error { + if card.Task == nil { + if old, e := s.st.GetCard(owner, card.ID); e == nil && old.Task != nil { + return store.ErrConflict + } + } + if card.Task != nil { + _, ver, err := s.st.GetCardVersion(owner, card.ID) + if err != nil { + return err + } + return s.UpdateVersioned(owner, card, ver) + } card.UpdatedAt = s.now().UTC() if err := s.st.SaveCard(owner, &card, ""); err != nil { return err @@ -144,6 +201,14 @@ func (s *Service) Update(owner string, card core.ActivityCard) error { // UpdateVersioned 乐观并发保存(U3,KTD7):版本不匹配透传 store.ErrConflict, // HTTP 层映射 409;客户端重读卡后重试。 func (s *Service) UpdateVersioned(owner string, card core.ActivityCard, expectedVersion int) error { + if card.Task == nil { + if current, err := s.st.GetCard(owner, card.ID); err == nil && current.Task != nil { + return store.ErrConflict + } + } + if card.Task != nil { + return s.commitProjection(store.TaskProjectionWrite{Owner: owner, Card: card, ExpectedCardVersion: expectedVersion, ExpectedTaskRevision: card.Task.Revision, Channels: s.taskChannels(card)}) + } card.UpdatedAt = s.now().UTC() if err := s.st.SaveCardVersioned(owner, &card, "", expectedVersion); err != nil { return err @@ -160,6 +225,9 @@ func (s *Service) Append(owner, id, text string) error { if err != nil { return err } + if c.Task != nil { + return ErrNoAction + } if text != "" { if n := len(c.Body); n > 0 && c.Body[n-1].Type == core.UIMarkdown { c.Body[n-1].Markdown += text @@ -200,6 +268,9 @@ func (s *Service) SetStatus(owner, id string, status core.CardStatus, tone, note if err := s.UpdateVersioned(owner, c, ver); err != nil { return c, err } + if c.Task != nil { + return s.Get(owner, c.ID) + } return c, s.emitCard(c, core.EvCardStatus, core.CardStatusPayload{CardID: c.ID, Status: status, Tone: c.Tone, Note: note}) } @@ -210,7 +281,10 @@ func (s *Service) SetPinned(owner, id string, pinned bool) (core.ActivityCard, e return c, err } c.Pinned = pinned - return c, s.UpdateVersioned(owner, c, ver) + if e := s.UpdateVersioned(owner, c, ver); e != nil { + return c, e + } + return s.Get(owner, c.ID) } // Dismiss 关闭卡(非确认语义,留痕)。 @@ -245,6 +319,7 @@ func (s *Service) Action(owner, id, actionID, freeText string) (core.ActivityCar return c, "", ErrNoAction } c.Status = core.CardResolved + recordAnswer(&c, actionID, freeText) if err := s.UpdateVersioned(owner, c, ver); err != nil { return c, "", err } @@ -264,6 +339,9 @@ func (s *Service) ToProject(owner, id string) (core.ActivityCard, error) { if c.ConversationID == "" { return c, errors.New("card: already project-level") } + if c.Task != nil { + return c, errors.New("card: keep the original task reference in its conversation") + } if ref, rerr := s.st.CardRef(owner, c.ID); rerr == nil { if strings.HasPrefix(ref, "hitl:") || strings.HasPrefix(ref, "cmd:") { return c, errors.New("card: flow-bound card cannot be archived to project") @@ -306,6 +384,7 @@ func (s *Service) ResolveByRef(owner, ref, actionID, answer string) error { return nil // 已决(双现任一侧先到) } c.Status = core.CardResolved + recordAnswer(&c, actionID, answer) if err := s.UpdateVersioned(owner, c, ver); err != nil { if errors.Is(err, store.ErrConflict) { return nil // 双现任一侧先到(版本竞争败者):不重试,卡以先到侧为准 diff --git a/internal/cardsvc/resolution.go b/internal/cardsvc/resolution.go new file mode 100644 index 0000000..d00bb99 --- /dev/null +++ b/internal/cardsvc/resolution.go @@ -0,0 +1,59 @@ +package cardsvc + +import ( + core "agent-system-core" + "strings" +) + +func recordAnswer(card *core.ActivityCard, actionID, answer string) { + label := actionID + for _, action := range card.Actions { + if action.ID == actionID { + label = action.Label + break + } + } + body := []core.UINode{} + for _, node := range card.Body { + if node.Type == core.UIMarkdown && (strings.Contains(node.Markdown, "可在输入框自由输入") || strings.Contains(node.Markdown, "自由输入回应")) { + continue + } + body = append(body, node) + } + pairs := [][2]string{} + if label != "" { + pairs = append(pairs, [2]string{"你的选择", label}) + } + if strings.TrimSpace(answer) != "" && answer != label { + pairs = append(pairs, [2]string{"补充说明", answer}) + } + if len(pairs) > 0 { + body = append(body, core.UINode{Type: core.UIKV, KV: pairs}) + } + card.Body = body +} + +func (s *Service) presentResolved(owner string, card core.ActivityCard) core.ActivityCard { + if card.Status != core.CardResolved { + return card + } + // Already-recorded answers survive unchanged; old projections are repaired + // from their owner-scoped persisted HITL resolution without changing history. + for _, node := range card.Body { + for _, pair := range node.KV { + if pair[0] == "你的选择" || pair[0] == "补充说明" { + return card + } + } + } + ref, err := s.st.CardRef(owner, card.ID) + if err != nil || !strings.HasPrefix(ref, "hitl:") { + return card + } + resolution, err := s.st.HITLResolution(owner, strings.TrimPrefix(ref, "hitl:")) + if err != nil { + return card + } + recordAnswer(&card, "", resolution.Answer) + return card +} diff --git a/internal/cardsvc/resolution_test.go b/internal/cardsvc/resolution_test.go new file mode 100644 index 0000000..62e6275 --- /dev/null +++ b/internal/cardsvc/resolution_test.go @@ -0,0 +1,41 @@ +package cardsvc + +import ( + "agent-system-backend/internal/store" + core "agent-system-core" + "strings" + "testing" +) + +func TestHistoricalResolvedChoiceDisplaysPersistedAnswer(t *testing.T) { + st, svc, _ := openTest(t) + hitl := core.HITLCard{ID: "old-choice", ConversationID: "cnv_1"} + if err := st.SaveHITLCard(store.OwnerDefault, &hitl); err != nil { + t.Fatal(err) + } + if err := st.ResolveHITLCard(store.OwnerDefault, hitl.ID, core.HITLResolvedPayload{CardID: hitl.ID, Answer: "生成摘要", Kind: "choice"}); err != nil { + t.Fatal(err) + } + card, err := svc.Create(store.OwnerDefault, core.ActivityCard{Kind: core.CardKindConfirm, Status: core.CardResolved, Body: []core.UINode{{Type: core.UIMarkdown, Markdown: "_可在输入框自由输入回应。_"}}}, "hitl:"+hitl.ID) + if err != nil { + t.Fatal(err) + } + got, err := svc.Get(store.OwnerDefault, card.ID) + if err != nil { + t.Fatal(err) + } + found := false + for _, node := range got.Body { + if strings.Contains(node.Markdown, "输入框") { + t.Fatal("stale input hint retained") + } + for _, pair := range node.KV { + if pair[1] == "生成摘要" { + found = true + } + } + } + if !found { + t.Fatal("stored choice answer missing") + } +} diff --git a/internal/cardsvc/task_projection.go b/internal/cardsvc/task_projection.go new file mode 100644 index 0000000..5931530 --- /dev/null +++ b/internal/cardsvc/task_projection.go @@ -0,0 +1,63 @@ +package cardsvc + +import ( + "agent-system-backend/internal/store" + core "agent-system-core" + "time" +) + +func taskCardState(task core.TaskSnapshot) (core.CardStatus, string) { + switch task.ExecutionState { + case "completed": + return core.CardDone, "ok" + case "failed", "interrupted": + return core.CardFailed, "danger" + case "awaiting_user": + return core.CardActionRequired, "warn" + default: + return core.CardActive, "info" + } +} +func (s *Service) commitProjection(w store.TaskProjectionWrite) error { + if s.TaskCommitter != nil { + _, err := s.TaskCommitter(w) + return err + } + _, err := s.st.CommitTaskProjection(w) + return err +} +func (s *Service) taskChannels(card core.ActivityCard) []string { + channels := []string{card.ConversationID} + if s.MirrorConvCards && card.ProjectID != "" { + channels = append(channels, string(core.ProjectChannelKey(card.ProjectID))) + } + return channels +} +func (s *Service) CommitTask(owner string, next core.TaskSnapshot, expectedRevision int64) error { + return s.CommitTaskSources(owner, next, expectedRevision, nil) +} +func (s *Service) CommitTaskSources(owner string, next core.TaskSnapshot, expectedRevision int64, witnesses []store.ArtifactWitness) error { + c, version, err := s.st.GetCardVersion(owner, next.CardID) + if err != nil { + return err + } + c.Task = &next + c.SourceRunID = next.RunID + c.Body = next.SummaryBody() + c.Status, c.Tone = taskCardState(next) + c.UpdatedAt = time.Now().UTC() + return s.commitProjection(store.TaskProjectionWrite{Owner: owner, Card: c, ExpectedCardVersion: version, ExpectedTaskRevision: expectedRevision, SourceWitnesses: witnesses, Channels: s.taskChannels(c)}) +} +func (s *Service) CreateTask(owner string, card core.ActivityCard, ref string) (core.ActivityCard, error) { + if card.Task == nil { + return card, store.ErrConflict + } + card.Status, card.Tone = taskCardState(*card.Task) + card.Body = card.Task.SummaryBody() + card.CreatedAt = time.Now().UTC() + card.UpdatedAt = card.CreatedAt + if err := s.commitProjection(store.TaskProjectionWrite{Owner: owner, Card: card, Ref: ref, Channels: s.taskChannels(card)}); err != nil { + return card, err + } + return s.st.GetCard(owner, card.ID) +} diff --git a/internal/cardsvc/task_projection_test.go b/internal/cardsvc/task_projection_test.go new file mode 100644 index 0000000..96b7419 --- /dev/null +++ b/internal/cardsvc/task_projection_test.go @@ -0,0 +1,63 @@ +package cardsvc + +import ( + core "agent-system-core" + "testing" +) + +func TestTaskProjectionPreservesUserReadingPreferences(t *testing.T) { + st, svc, _ := openTest(t) + c := core.ActivityCard{ID: "card-task", ProjectID: "p1", ConversationID: "c1", Title: "任务", Kind: core.CardKindProgress} + c.Task = &core.TaskSnapshot{SchemaVersion: 1, TaskID: "t1", AttemptID: "a1", CardID: c.ID, ProjectID: "p1", ConversationID: "c1", OwnerInstanceID: "i1", StreamScope: "conversation:c1", Revision: 1, ExecutionState: "queued", OutcomeCompleteness: "none", DeliveryState: "pending", Title: "任务"} + if _, e := svc.CreateTask("o", c, ""); e != nil { + t.Fatal(e) + } + current, e := st.GetCard("o", c.ID) + if e != nil { + t.Fatal("task card not saved", e) + } + current.Pinned = true + current.Status = core.CardDismissed + if e = st.SaveCard("o", ¤t, ""); e != nil { + t.Fatal(e) + } + next := *current.Task + next.Revision = 2 + next.ExecutionState = "completed" + next.Conclusion = "有用成果" + if e = svc.CommitTask("o", next, 1); e != nil { + t.Fatal(e) + } + got, e := st.GetCard("o", c.ID) + if e != nil || !got.Pinned || got.Status != core.CardDismissed || got.Task.ExecutionState != "completed" { + t.Fatal("work update stole user state", got, e) + } +} + +func TestTaskProjectionUserPinHasAtomicEventWatermark(t *testing.T) { + st, svc, _ := openTest(t) + c := core.ActivityCard{ID: "card-pin", ProjectID: "p1", ConversationID: "c1", Title: "任务", Kind: core.CardKindProgress} + c.Task = &core.TaskSnapshot{SchemaVersion: 1, TaskID: "t1", AttemptID: "a1", CardID: c.ID, ProjectID: "p1", ConversationID: "c1", OwnerInstanceID: "i1", StreamScope: "conversation:c1", Revision: 1, ExecutionState: "queued", OutcomeCompleteness: "none", DeliveryState: "pending", Title: "任务"} + if _, e := svc.CreateTask("o", c, ""); e != nil { + t.Fatal(e) + } + if _, e := svc.SetPinned("o", c.ID, true); e != nil { + t.Fatal(e) + } + snapshot, e := st.CardsAtCursor("o", "c1") + if e != nil || snapshot.ReplaySeq != 2 || len(snapshot.Cards) != 1 || !snapshot.Cards[0].Pinned { + t.Fatal("pin not atomically replayable", snapshot, e) + } +} + +func TestTaskProjectionCannotCreateSecondExecutionOwner(t *testing.T) { + _, svc, _ := openTest(t) + c := core.ActivityCard{ID: "card-archive", ProjectID: "p1", ConversationID: "c1", Title: "任务", Kind: core.CardKindProgress} + c.Task = &core.TaskSnapshot{SchemaVersion: 1, TaskID: "t1", AttemptID: "a1", CardID: c.ID, ProjectID: "p1", ConversationID: "c1", OwnerInstanceID: "i1", StreamScope: "conversation:c1", Revision: 1, ExecutionState: "queued", OutcomeCompleteness: "none", DeliveryState: "pending", Title: "任务"} + if _, e := svc.CreateTask("o", c, ""); e != nil { + t.Fatal(e) + } + if _, e := svc.ToProject("o", c.ID); e == nil { + t.Fatal("task was copied with a second execution owner") + } +} diff --git a/internal/cardsvc/task_visibility.go b/internal/cardsvc/task_visibility.go new file mode 100644 index 0000000..b45f82e --- /dev/null +++ b/internal/cardsvc/task_visibility.go @@ -0,0 +1,106 @@ +package cardsvc + +import ( + core "agent-system-core" + "crypto/sha256" + "encoding/hex" +) + +// Task source authorization is conservative: same owner and same project. +// The host rechecks it on every fresh read and stream replay, without an ACL cache. +func (s *Service) taskSourceState(owner, project string, src core.TaskSourceRef) string { + art, e := s.st.GetArtifact(owner, src.ArtifactID) + if e != nil || art.ProjectID != project { + return "unavailable" + } + data, e := s.st.ArtifactContent(owner, src.ArtifactID) + if e != nil { + return "unavailable" + } + sum := sha256.Sum256(data) + if hex.EncodeToString(sum[:]) != src.ContentHash { + return "changed" + } + return "available" +} +func (s *Service) VisibleTaskCard(owner string, c core.ActivityCard) core.ActivityCard { + if c.Task == nil { + return c + } + task := *c.Task + denied := false + changed := false + if _, e := s.st.GetCard(owner, c.ID); e != nil { + denied = true + } + refs := append([]core.TaskSourceRef(nil), task.Inputs...) + for _, f := range task.Findings { + refs = append(refs, f.Sources...) + } + for _, src := range refs { + switch s.taskSourceState(owner, c.ProjectID, src) { + case "unavailable": + denied = true + case "changed": + changed = true + } + } + // Result refs must also be owned and accessible before exposing generated names. + for _, ref := range task.ResultRefs { + if ref.ArtifactID != "" { + a, e := s.st.GetArtifact(owner, ref.ArtifactID) + if e != nil || a.ProjectID != c.ProjectID { + denied = true + } + } + } + if !denied && !changed { + return c + } + task.Goal = "" + task.Focus = "" + task.Coverage = "" + task.Remaining = "" + task.NextStep = "" + task.Conclusion = "" + task.CheckpointKeys = nil + task.Limitations = nil + task.Phase.Label = "" + task.Actions = nil + task.ResultRefs = nil + if denied { + c.Title = "后台任务" + task.Title = c.Title + task.Inputs = nil + task.Findings = nil + task.Conclusion = "相关来源当前不可用,结果暂不展示" + task.Limitations = []string{"相关来源当前不可用,请核实访问权限与文件状态"} + task.Remaining = "请核实材料访问权限与文件是否仍可读取" + } else { + task.Findings = append([]core.TaskFinding(nil), task.Findings...) + for i := range task.Findings { + f := &task.Findings[i] + f.Status = "superseded" + f.Sources = append([]core.TaskSourceRef(nil), f.Sources...) + for j := range f.Sources { + f.Sources[j].Availability = s.taskSourceState(owner, c.ProjectID, f.Sources[j]) + } + } + task.Conclusion = "依据文件已更新,当前结论需要重新核验" + task.Limitations = []string{"依据文件已更新,历史发现不能直接作为当前结论"} + task.Remaining = "展开历史记录可查看处理时的合法引用;当前文件与当时版本可能不同" + } + task.OutcomeCompleteness = "partial" + c.Task = &task + c.Actions = nil + c.Body = task.SummaryBody() + // The durable work result and its follow-up choice derive from the same + // sources, so they follow the task conclusion's visibility. + if c.Work != nil { + work := *c.Work + work.Result = "" + work.Decision = nil + c.Work = &work + } + return c +} diff --git a/internal/cardsvc/task_visibility_test.go b/internal/cardsvc/task_visibility_test.go new file mode 100644 index 0000000..e076cff --- /dev/null +++ b/internal/cardsvc/task_visibility_test.go @@ -0,0 +1,41 @@ +package cardsvc + +import ( + core "agent-system-core" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "strings" + "testing" +) + +func TestTaskVisibilityHidesAllDerivedTextOnSourceRevocation(t *testing.T) { + st, svc, _ := openTest(t) + data := []byte("private input") + art := core.Artifact{Owner: "o", ProjectID: "p", Name: "private name"} + if e := st.CreateArtifact(&art, data); e != nil { + t.Fatal(e) + } + sum := sha256.Sum256(data) + src := core.TaskSourceRef{ID: "s", ArtifactID: art.ID, ContentHash: hex.EncodeToString(sum[:]), Label: art.Name, Quote: string(data)} + c := core.ActivityCard{ID: "ct", ProjectID: "p", ConversationID: "c", Title: "private title", Kind: core.CardKindProgress} + c.Task = &core.TaskSnapshot{SchemaVersion: 1, TaskID: "t", AttemptID: "a", CardID: c.ID, ProjectID: "p", ConversationID: "c", OwnerInstanceID: "i", Revision: 1, StreamScope: "conversation:c", ExecutionState: "queued", OutcomeCompleteness: "none", DeliveryState: "pending", Title: c.Title, Focus: "private focus", Coverage: "private coverage", Remaining: "private remaining", NextStep: "private advice", Conclusion: "private conclusion", Inputs: []core.TaskSourceRef{src}, Findings: []core.TaskFinding{{ID: "f", Revision: 1, Status: "active", Kind: "material_record", Text: "private finding", Sources: []core.TaskSourceRef{src}}}} + if _, e := svc.CreateTask("o", c, ""); e != nil { + t.Fatal(e) + } + if e := st.UpdateArtifactContent("o", art.ID, []byte("updated")); e != nil { + t.Fatal(e) + } + changed := svc.VisibleTaskCard("o", c) + if changed.Task.Findings[0].Status != "superseded" || changed.Task.Findings[0].Sources[0].Availability != "changed" { + t.Fatal("old evidence masquerades as current") + } + if e := st.DeleteArtifact("o", art.ID); e != nil { + t.Fatal(e) + } + hidden := svc.VisibleTaskCard("o", c) + raw, _ := json.Marshal(hidden) + if strings.Contains(string(raw), "private") { + t.Fatal("derived information leaked", string(raw)) + } +} diff --git a/internal/cmdbus/account_guard_test.go b/internal/cmdbus/account_guard_test.go new file mode 100644 index 0000000..b968960 --- /dev/null +++ b/internal/cmdbus/account_guard_test.go @@ -0,0 +1,169 @@ +package cmdbus + +import ( + "context" + "errors" + "strings" + "testing" + + agentcommand "agent-command" +) + +func TestEffectiveAccountGuardRejectsBeforeDispatchEffects(t *testing.T) { + for _, tc := range []struct { + name string + req Request + err error + want string + }{ + {"disabled handler", Request{ID: "demo.ping", Owner: "alice", Actor: "alice"}, errors.New("disabled"), "alice"}, + {"failed lookup", Request{ID: "demo.ping", Owner: "alice", Actor: "alice"}, errors.New("database unavailable"), "alice"}, + {"plugin actor uses account owner", Request{ID: "demo.ping", Owner: "alice", Actor: "plugin:rdm"}, errors.New("disabled"), "alice"}, + {"actor fallback", Request{ID: "demo.ping", Actor: "alice"}, errors.New("disabled"), "alice"}, + } { + t.Run(tc.name, func(t *testing.T) { + audit, cards := &memAudit{}, &memCards{} + b := newTestBus(audit, cards) + called := 0 + b.SetEffectiveAccountGuard(func(_ context.Context, actor string) error { + called++ + if actor != tc.want { + t.Fatalf("guard actor = %q, want %q", actor, tc.want) + } + return tc.err + }) + resp := b.Dispatch(context.Background(), tc.req) + if resp.Receipt.State != agentcommand.StateRejected || !strings.Contains(resp.Receipt.Reason, "effective account") { + t.Fatalf("guard rejection: %+v", resp.Receipt) + } + if called != 1 || len(audit.logs) != 0 || len(cards.created) != 0 { + t.Fatalf("guard calls=%d audits=%d cards=%d", called, len(audit.logs), len(cards.created)) + } + }) + } +} + +func TestEffectiveAccountGuardRejectsBeforePendingConfirmAndRechecks(t *testing.T) { + audit, cards := &memAudit{}, &memCards{} + b := newTestBus(audit, cards) + allowed := false + b.SetEffectiveAccountGuard(func(_ context.Context, actor string) error { + if actor != "alice" { + t.Fatalf("guard actor = %q", actor) + } + if !allowed { + return errors.New("disabled") + } + return nil + }) + req := Request{ID: "demo.risky", Owner: "alice", Actor: "alice", ConversationID: "cnv_1", Params: map[string]any{"target": "prod"}} + if r := b.Dispatch(context.Background(), req); r.Receipt.State != agentcommand.StateRejected { + t.Fatalf("disabled dispatch: %+v", r.Receipt) + } + if len(cards.created) != 0 || len(b.pending) != 0 || len(audit.logs) != 0 { + t.Fatal("disabled dispatch created confirmation effects") + } + allowed = true + r := b.Dispatch(context.Background(), req) + if r.Receipt.State != agentcommand.StateAccepted || len(cards.created) != 1 || len(b.pending) != 1 { + t.Fatalf("enabled dispatch: %+v", r.Receipt) + } + allowed = false + before := len(audit.logs) + if r := b.Confirm("alice", "card_x1", "confirm"); r.Receipt.State != agentcommand.StateRejected { + t.Fatalf("disabled confirm: %+v", r.Receipt) + } + if len(b.pending) != 1 || len(audit.logs) != before { + t.Fatal("disabled confirm consumed pending request or wrote audit") + } + allowed = true + if r := b.Confirm("alice", "card_x1", "confirm"); r.Receipt.State != agentcommand.StateCommitted { + t.Fatalf("re-enabled confirm: %+v", r.Receipt) + } +} + +func TestEffectiveAccountGuardStopsConfirmedHandlerAfterStateChanges(t *testing.T) { + b := New(&memAudit{}, &memCards{}, nil) + executed := 0 + if err := b.RegisterPluginCommand(agentcommand.CommandSpec{ID: "demo.effect", Title: "Effect"}, func(context.Context, Request) (any, error) { + executed++ + return "done", nil + }); err != nil { + t.Fatal(err) + } + checks := 0 + b.SetEffectiveAccountGuard(func(_ context.Context, actor string) error { + checks++ + if actor != "alice" { + t.Fatalf("guard actor = %q", actor) + } + if checks == 4 { + return errors.New("disabled after confirmation started") + } + return nil + }) + // Confirm passes its own check, then Dispatch must check once more before + // invoking the handler. A direct confirmed request takes the same path. + if err := b.RegisterBuiltin(agentcommand.CommandSpec{ID: "demo.high", Title: "High", Danger: agentcommand.DangerHigh}, "", func(context.Context, Request) (any, error) { + executed++ + return "done", nil + }); err != nil { + t.Fatal(err) + } + r := b.Dispatch(context.Background(), Request{ID: "demo.high", Owner: "alice", ConversationID: "cnv_1"}) + if r.Receipt.State != agentcommand.StateAccepted { + t.Fatalf("initial dispatch: %+v", r.Receipt) + } + // Dispatch checks again before creating the confirmation card. Confirm's + // check passes; its nested Dispatch catches the fourth-state change. + if r := b.Confirm("alice", "card_x1", "confirm"); r.Receipt.State != agentcommand.StateRejected { + t.Fatalf("confirm after disable: %+v", r.Receipt) + } + if executed != 0 || len(b.pending) != 0 { + t.Fatalf("executed=%d pending=%d", executed, len(b.pending)) + } + // Direct confirmed execution also passes through the same guard. + checks = 3 + if r := b.Dispatch(context.Background(), Request{ID: "demo.effect", Owner: "alice", Confirmed: true}); r.Receipt.State != agentcommand.StateRejected || executed != 0 { + t.Fatalf("direct confirmed dispatch: %+v executed=%d", r.Receipt, executed) + } +} + +type trackingPendingStore struct { + owner, payload string + takes int +} + +func (p *trackingPendingStore) SavePendingConfirm(_ string, owner, payload string) error { + p.owner, p.payload = owner, payload + return nil +} +func (p *trackingPendingStore) TakePendingConfirm(string) (string, string, bool, error) { + p.takes++ + return p.owner, p.payload, p.payload != "", nil +} + +func TestEffectiveAccountGuardDoesNotConsumeStoredPendingOnDeniedConfirm(t *testing.T) { + b := newTestBus(&memAudit{}, &memCards{}) + pendingStorage := &trackingPendingStore{} + b.SetPendingStore(pendingStorage) + allowed := true + b.SetEffectiveAccountGuard(func(_ context.Context, actor string) error { + if actor != "alice" { + t.Fatalf("guard actor = %q", actor) + } + if !allowed { + return errors.New("database unavailable") + } + return nil + }) + r := b.Dispatch(context.Background(), Request{ID: "demo.risky", Owner: "alice", ConversationID: "cnv_1", Params: map[string]any{"target": "prod"}}) + if r.Receipt.State != agentcommand.StateAccepted || pendingStorage.payload == "" { + t.Fatalf("pending setup: %+v", r.Receipt) + } + b.pending = map[string]pending{} // force the restored path + allowed = false + if r := b.Confirm("alice", "card_x1", "confirm"); r.Receipt.State != agentcommand.StateRejected || pendingStorage.takes != 0 { + t.Fatalf("denied confirm: %+v takes=%d", r.Receipt, pendingStorage.takes) + } +} diff --git a/internal/cmdbus/account_write_fence_test.go b/internal/cmdbus/account_write_fence_test.go new file mode 100644 index 0000000..6f38692 --- /dev/null +++ b/internal/cmdbus/account_write_fence_test.go @@ -0,0 +1,18 @@ +package cmdbus + +import ( + "context" + "testing" + + agentcommand "agent-command" +) + +func TestStrictAccountWritesRejectLegacyBusPorts(t *testing.T) { + audit, cards := &memAudit{}, &memCards{} + b := newTestBus(audit, cards) + b.RequireAccountCheckedWrites(false) + resp := b.Dispatch(context.Background(), Request{ID: "demo.risky", Owner: "alice", Actor: "alice", ConversationID: "cnv_1", Params: map[string]any{"target": "prod"}}) + if resp.Receipt.State != agentcommand.StateUnknown || len(cards.created) != 0 || len(audit.logs) != 0 || len(b.pending) != 0 { + t.Fatalf("legacy port wrote under strict mode: %+v, cards=%d audit=%d pending=%d", resp.Receipt, len(cards.created), len(audit.logs), len(b.pending)) + } +} diff --git a/internal/cmdbus/cmdbus.go b/internal/cmdbus/cmdbus.go index 9b63e7d..f3d388c 100644 --- a/internal/cmdbus/cmdbus.go +++ b/internal/cmdbus/cmdbus.go @@ -5,6 +5,7 @@ package cmdbus import ( "context" + "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" @@ -15,7 +16,7 @@ import ( "time" agentcommand "agent-command" - "agent-system-core" + core "agent-system-core" ) // AuditStore 审计落盘端口(store 实现;v7 建表、v18 加 owner 列)。 @@ -49,6 +50,13 @@ type CommandTool struct { // 不投影给该 owner 的模型。组合根按需装配(如 generative-ui 按 activation 台账)。 type ToolGate func(owner, commandID string) bool +// EffectiveAccountGuard checks the current account state before a command can +// create a confirmation or invoke a handler. The composition root supplies the +// check; nil keeps standalone and auth-off fixtures unchanged. The actor is +// derived only from trusted Request identity fields, never Params. This is an +// early check, not an atomic fence across handler databases or external calls. +type EffectiveAccountGuard func(ctx context.Context, actor string) error + // SetToolGate 装配 owner 级注入闸(组合根;启动期一次)。 func (b *Bus) SetToolGate(g ToolGate) { b.toolGate = g } @@ -89,9 +97,49 @@ type Request struct { Actor string Owner string ConversationID string + OperationID string // overwritten by the trusted bus before handler execution AnchorMessageID string // 确认卡锚定的发起消息(内联渲染) Confirmed bool // 危险命令经确认卡放行后的执行 HostProjectID string // EXT-18:分发所属基座项目(空=无项目上下文) + // HumanGrant is set only by the authenticated session path or the human + // decision resolver. Client JSON and agent tool dispatch leave it nil. + HumanGrant *HumanGrant +} + +// RequiresProjectWorkDecision identifies formal RDM writes that must be bound +// to an analyzed committed-message manifest before dispatch. Read views and +// the separate exact-card structure issue/decision path are excluded. +func RequiresProjectWorkDecision(id string) bool { + for _, prefix := range []string{ + "rdm.reqs.", "rdm.review.", "rdm.draft.", "rdm.cnode.", "rdm.cedge.", + "rdm.conclusion.", "rdm.section.", "rdm.stage.", "rdm.change.", + } { + if strings.HasPrefix(id, prefix) { + for _, read := range []string{"rdm.reqs.list", "rdm.reqs.exports", "rdm.reqs.underived", "rdm.review.status", "rdm.review.doc.status", "rdm.draft.proposals", "rdm.conclusion.list", "rdm.change.list"} { + if id == read { + return false + } + } + return true + } + } + switch id { + case "rdm.structure.propose", "rdm.structure.revise", "rdm.docs.instantiate", "rdm.docs.delivery.approve", + "rdm.doc.snapshot", "rdm.ingest", "rdm.upload.confirm", "rdm.knowledge.publish", "rdm.trace.confirm", + "rdm.conflicts.resolve": + return true + default: + return false + } +} + +// HumanGrant ties this dispatch to one persisted human response. +// It is not a secret token and is not read from command params. +type HumanGrant struct { + CardID string + ProofID string + Actor string + Decision string } // Response 分发结果:回执 + 处理负载 + 可能的确认卡。 @@ -108,17 +156,25 @@ type pending struct { } type Bus struct { - reg *agentcommand.Registry - audit AuditStore - cards CardPort - now func() time.Time - handlers map[string]Handler - slash map[string]string // "/x" → command id - pendMu sync.Mutex - pending map[string]pending - pendingStore PendingStore - intents *IntentStore // G2-2:意图声明表(自然语言路由) - toolGate ToolGate // U3:owner 级工具注入闸(nil = 不过滤) + reg *agentcommand.Registry + audit AuditStore + cards CardPort + now func() time.Time + handlers map[string]Handler + slash map[string]string // "/x" → command id + pendMu sync.Mutex + pending map[string]pending + pendingStore PendingStore + intents *IntentStore // G2-2:意图声明表(自然语言路由) + toolGate ToolGate // U3:owner 级工具注入闸(nil = 不过滤) + accountGuard EffectiveAccountGuard // A1: optional command execution precheck + strictAccountWrites bool + allowImplicitLocal bool + projectWorkPreflight func(context.Context, Request) error +} + +func (b *Bus) SetProjectWorkPreflight(fn func(context.Context, Request) error) { + b.projectWorkPreflight = fn } // PendingStore 把危险命令的确认挂起态落盘。进程内 map 仍是热路径;重启后从存储取回。 @@ -144,6 +200,70 @@ func (b *Bus) Registry() *agentcommand.Registry { return b.reg } // SetPendingStore 注入确认挂起态的持久化实现(nil = 仅进程内)。 func (b *Bus) SetPendingStore(st PendingStore) { b.pendingStore = st } +// SetEffectiveAccountGuard installs the composition-root check at startup. +// Handler Store writes still need their own final transaction fence. +func (b *Bus) SetEffectiveAccountGuard(g EffectiveAccountGuard) { b.accountGuard = g } + +// RequireAccountCheckedWrites makes every Bus-owned durable sink use a final +// account transaction. The composition root enables this after wiring Store +// and Cards; standalone in-memory fixtures retain their legacy ports. +func (b *Bus) RequireAccountCheckedWrites(allowImplicitLocal bool) { + b.strictAccountWrites = true + b.allowImplicitLocal = allowImplicitLocal +} + +func (b *Bus) createConfirmCard(actor string, card core.ActivityCard, ref string) (core.ActivityCard, error) { + if !b.strictAccountWrites { + return b.cards.Create(actor, card, ref) + } + checked, ok := b.cards.(interface { + CreateAccountChecked(string, core.ActivityCard, string, bool) (core.ActivityCard, error) + }) + if !ok { + return core.ActivityCard{}, fmt.Errorf("cmdbus: account-checked card port required") + } + return checked.CreateAccountChecked(actor, card, ref, b.allowImplicitLocal) +} + +func (b *Bus) setConfirmCardStatus(actor, id string, status core.CardStatus, tone, note string) (core.ActivityCard, error) { + if !b.strictAccountWrites { + return b.cards.SetStatus(actor, id, status, tone, note) + } + checked, ok := b.cards.(interface { + SetStatusAccountChecked(string, string, core.CardStatus, string, string, bool) (core.ActivityCard, error) + }) + if !ok { + return core.ActivityCard{}, fmt.Errorf("cmdbus: account-checked card port required") + } + return checked.SetStatusAccountChecked(actor, id, status, tone, note, b.allowImplicitLocal) +} + +func (b *Bus) savePending(cardID, owner, payload string) error { + if !b.strictAccountWrites { + return b.pendingStore.SavePendingConfirm(cardID, owner, payload) + } + checked, ok := b.pendingStore.(interface { + SavePendingConfirmAccountChecked(string, string, string, bool) error + }) + if !ok { + return fmt.Errorf("cmdbus: account-checked pending store required") + } + return checked.SavePendingConfirmAccountChecked(cardID, owner, payload, b.allowImplicitLocal) +} + +func (b *Bus) takePending(cardID, owner string) (string, string, bool, error) { + if !b.strictAccountWrites { + return b.pendingStore.TakePendingConfirm(cardID) + } + checked, ok := b.pendingStore.(interface { + TakePendingConfirmAccountChecked(string, string, bool) (string, string, bool, error) + }) + if !ok { + return "", "", false, fmt.Errorf("cmdbus: account-checked pending store required") + } + return checked.TakePendingConfirmAccountChecked(cardID, owner, b.allowImplicitLocal) +} + // RegisterBuiltin 登记内置命令并绑定宿主处理器与 `/` 别名。 func (b *Bus) RegisterBuiltin(spec agentcommand.CommandSpec, slash string, h Handler) error { spec.Binding = agentcommand.Binding{Kind: agentcommand.BindBuiltin} @@ -242,10 +362,16 @@ func (b *Bus) HasCommand(id string) bool { // List 四入口共享的命令清单(`/` 菜单、按钮、卡片 action 提示用)。 func (b *Bus) List() []agentcommand.Registered { return b.reg.List() } -// Dispatch 统一分发:校验→危险收口→执行→审计。任何路径都产出回执与审计(入口对称)。 +// Dispatch 统一分发:账号预检→校验→危险收口→执行→审计。账号预检拒绝只返回回执, +// 避免停用账号或检查故障时继续写入审计;其余路径维持四入口审计对称。 func (b *Bus) Dispatch(ctx context.Context, req Request) Response { start := b.now() req.Entrance = agentcommand.CanonicalEntrance(req.Entrance) + if err := b.checkEffectiveAccount(ctx, accountActor(req)); err != nil { + // A rejected account cannot create a card, pending row, audit row, or + // invoke a handler. A failed read is also a denial. + return b.accountRejected(req.ID, start) + } receipt := func(state agentcommand.ResultState, op, reason string) Response { return Response{Receipt: b.auditReceipt(req, state, op, reason, start)} } @@ -256,22 +382,37 @@ func (b *Bus) Dispatch(ctx context.Context, req Request) Response { if err := validateParams(spec.ParamsSchema, req.Params); err != nil { return receipt(agentcommand.StateRejected, "", "params: "+err.Error()) } + if RequiresProjectWorkDecision(req.ID) { + if b.projectWorkPreflight == nil { + return receipt(agentcommand.StateRejected, "", "project source preflight unavailable") + } + if err := b.projectWorkPreflight(ctx, req); err != nil { + return receipt(agentcommand.StateRejected, "", "project source preflight: "+err.Error()) + } + } // 高危收口:非确认入口先落内联确认卡(committed 语义 = 卡已创建;确认后二次分发执行)。 if spec.Danger == agentcommand.DangerHigh && !req.Confirmed { if b.cards == nil || req.ConversationID == "" { return receipt(agentcommand.StateRejected, "", "high-danger command requires conversation context for inline confirm") } - card, err := b.cards.Create(req.Owner, confirmCard(spec, req), "cmd:"+spec.ID) + if err := b.checkEffectiveAccount(ctx, accountActor(req)); err != nil { + return b.accountRejected(req.ID, start) + } + card, err := b.createConfirmCard(accountActor(req), confirmCard(spec, req), "cmd:"+spec.ID) if err != nil { return receipt(agentcommand.StateUnknown, "", "confirm card: "+err.Error()) } + if b.pendingStore != nil { + raw, _ := json.Marshal(req) + if err := b.savePending(card.ID, accountActor(req), string(raw)); err != nil { + return receipt(agentcommand.StateUnknown, "", "confirm pending: "+err.Error()) + } + } else if b.strictAccountWrites { + return receipt(agentcommand.StateUnknown, "", "confirm pending: account-checked store required") + } b.pendMu.Lock() b.pending[card.ID] = pending{req: req, cardID: card.ID} b.pendMu.Unlock() - if b.pendingStore != nil { - raw, _ := json.Marshal(req) - _ = b.pendingStore.SavePendingConfirm(card.ID, req.Owner, string(raw)) - } r := b.auditReceipt(req, agentcommand.StateAccepted, card.ID, "awaiting explicit confirm", start) return Response{Receipt: r, Card: &card} } @@ -279,59 +420,118 @@ func (b *Bus) Dispatch(ctx context.Context, req Request) Response { if !ok { return receipt(agentcommand.StateRejected, "", "no host handler bound for "+req.ID) } + if err := b.checkEffectiveAccount(ctx, accountActor(req)); err != nil { + return b.accountRejected(req.ID, start) + } + // The domain handler can persist this trusted ID with its transaction/outbox. + // Entropy, not the clock or caller Params, distinguishes concurrent operations. + // A handler failure still writes only a rejected audit receipt without an ID. + var opBytes [24]byte + if _, err := rand.Read(opBytes[:]); err != nil { + return receipt(agentcommand.StateUnknown, "", "operation ID unavailable") + } + req.OperationID = "op_" + hex.EncodeToString(opBytes[:]) payload, err := h(ctx, req) if err != nil { return receipt(agentcommand.StateRejected, "", "rejected: "+err.Error()) } - op := fmt.Sprintf("op_%s_%d", strings.ReplaceAll(strings.TrimPrefix(req.ID, ""), ".", "_"), b.now().UnixNano()) - return Response{Receipt: b.auditReceipt(req, agentcommand.StateCommitted, op, "", start), Payload: payload} + return Response{Receipt: b.auditReceipt(req, agentcommand.StateCommitted, req.OperationID, "", start), Payload: payload} } // Confirm 确认卡回道:actionID=confirm 执行挂起请求(entrance 归一 card),其余视为取消。 func (b *Bus) Confirm(owner, cardID, actionID string) Response { + return b.ConfirmContext(context.Background(), owner, cardID, actionID) +} + +// ConfirmContext carries the request cancellation through the confirmed +// handler and repeats the account check before consuming durable state. +func (b *Bus) ConfirmContext(ctx context.Context, owner, cardID, actionID string) Response { + // The caller supplies the authenticated card owner. Check before even + // consuming a persisted pending request; Dispatch checks the stored + // request again immediately before executing its handler. + if err := b.checkEffectiveAccount(ctx, owner); err != nil { + return b.accountRejected("unknown", b.now()) + } b.pendMu.Lock() p, ok := b.pending[cardID] - if ok && actionID == "confirm" { - delete(b.pending, cardID) - } b.pendMu.Unlock() - if ok && actionID == "confirm" && b.pendingStore != nil { - _, _, _, _ = b.pendingStore.TakePendingConfirm(cardID) + if ok && p.req.Owner != "" && p.req.Owner != owner { + return Response{Receipt: agentcommand.ExecReceipt{CommandID: p.req.ID, State: agentcommand.StateRejected, Reason: "confirm owner mismatch", Time: b.now().UTC()}} } - if !ok && b.pendingStore != nil { - _, payload, found, err := b.pendingStore.TakePendingConfirm(cardID) - if err == nil && found { - var req Request - if json.Unmarshal([]byte(payload), &req) == nil { - p = pending{req: req, cardID: cardID} - ok = true - } + if b.pendingStore != nil { + storedOwner, payload, found, err := b.takePending(cardID, owner) + if err != nil { + return b.accountRejected("unknown", b.now()) } + if found { + var req Request + if storedOwner != owner || json.Unmarshal([]byte(payload), &req) != nil { + return Response{Receipt: agentcommand.ExecReceipt{CommandID: "unknown", State: agentcommand.StateRejected, Reason: "invalid pending confirm", Time: b.now().UTC()}} + } + p = pending{req: req, cardID: cardID} + ok = true + } else if b.strictAccountWrites { + ok = false + } + } else if b.strictAccountWrites { + ok = false } if !ok { return Response{Receipt: agentcommand.ExecReceipt{CommandID: "unknown", State: agentcommand.StateRejected, Reason: "no pending confirm (expired or already resolved)", Time: b.now().UTC()}} } req := p.req + if req.Owner != "" && req.Owner != owner { + return Response{Receipt: agentcommand.ExecReceipt{CommandID: req.ID, State: agentcommand.StateRejected, Reason: "confirm owner mismatch", Time: b.now().UTC()}} + } + b.pendMu.Lock() + delete(b.pending, cardID) + b.pendMu.Unlock() req.Confirmed = true req.Entrance = "card" if actionID != "confirm" { if b.cards != nil { - _, _ = b.cards.SetStatus(owner, cardID, core.CardResolved, "muted", "cancelled") + _, _ = b.setConfirmCardStatus(owner, cardID, core.CardResolved, "muted", "cancelled") } start := b.now() return Response{Receipt: b.auditReceipt(req, agentcommand.StateRejected, "", "cancelled at confirm card", start)} } - resp := b.Dispatch(context.Background(), req) + resp := b.Dispatch(ctx, req) if b.cards != nil { if resp.Receipt.State == agentcommand.StateCommitted { - _, _ = b.cards.SetStatus(owner, cardID, core.CardResolved, "ok", "confirmed") + _, _ = b.setConfirmCardStatus(owner, cardID, core.CardResolved, "ok", "confirmed") } else { - _, _ = b.cards.SetStatus(owner, cardID, core.CardFailed, "danger", resp.Receipt.Reason) + _, _ = b.setConfirmCardStatus(owner, cardID, core.CardFailed, "danger", resp.Receipt.Reason) } } return resp } +func accountActor(req Request) string { + // Plugin and delegated commands use Actor for an audit label such as a + // plugin ID; Owner is the effective account whose state controls effects. + if req.Owner != "" { + return req.Owner + } + return req.Actor +} + +func (b *Bus) checkEffectiveAccount(ctx context.Context, actor string) error { + if err := ctx.Err(); err != nil { + return err + } + if b.accountGuard == nil { + return nil + } + return b.accountGuard(ctx, actor) +} + +func (b *Bus) accountRejected(commandID string, start time.Time) Response { + return Response{Receipt: agentcommand.ExecReceipt{ + CommandID: commandID, State: agentcommand.StateRejected, + Reason: "effective account unavailable", Time: start.UTC(), + }} +} + func (b *Bus) auditReceipt(req Request, state agentcommand.ResultState, op, reason string, start time.Time) agentcommand.ExecReceipt { env := agentcommand.AuditEnvelope{ Time: start.UTC(), Actor: req.Actor, CommandID: req.ID, Entrance: agentcommand.CanonicalEntrance(req.Entrance), @@ -341,7 +541,20 @@ func (b *Bus) auditReceipt(req Request, state agentcommand.ResultState, op, reas if _, src, err := b.reg.Resolve(req.ID); err == nil { env.Source = src } - if err := b.audit.AppendAudit(req.Owner, env); err != nil { + var err error + if b.strictAccountWrites { + checked, ok := b.audit.(interface { + AppendAuditAccountChecked(string, agentcommand.AuditEnvelope, bool) error + }) + if !ok { + err = fmt.Errorf("cmdbus: account-checked audit store required") + } else { + err = checked.AppendAuditAccountChecked(accountActor(req), env, b.allowImplicitLocal) + } + } else { + err = b.audit.AppendAudit(req.Owner, env) + } + if err != nil { // 审计不可用:结果标记未知,不臆断成功(四态语义)。 return agentcommand.ExecReceipt{CommandID: req.ID, State: agentcommand.StateUnknown, Reason: "audit unavailable: " + err.Error(), Time: b.now().UTC()} } diff --git a/internal/cmdbus/cmdbus_test.go b/internal/cmdbus/cmdbus_test.go index 7b4e3f3..e3c37bb 100644 --- a/internal/cmdbus/cmdbus_test.go +++ b/internal/cmdbus/cmdbus_test.go @@ -62,6 +62,7 @@ func newTestBus(a AuditStore, c CardPort) *Bus { func TestDispatchFourEntrancesSameAudit(t *testing.T) { a := &memAudit{} b := newTestBus(a, &memCards{}) + b.SetProjectWorkPreflight(func(context.Context, Request) error { return nil }) for _, ent := range []string{"button", "slash", "chat", "card", "api", "weird-unknown"} { resp := b.Dispatch(context.Background(), Request{ID: "demo.ping", Entrance: ent}) if resp.Receipt.State != agentcommand.StateCommitted { @@ -87,6 +88,66 @@ func TestDispatchFourEntrancesSameAudit(t *testing.T) { } } +func TestHandlerReceivesCommittedOperationIDBeforeDomainWrite(t *testing.T) { + audit := &memAudit{} + bus := New(audit, nil, func() time.Time { return time.Unix(1700000000, 123).UTC() }) + var seen string + if err := bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "rdm.confirm", Title: "Confirm"}, "", func(_ context.Context, req Request) (any, error) { + seen = req.OperationID + return "committed-domain-event", nil + }); err != nil { + t.Fatal(err) + } + resp := bus.Dispatch(context.Background(), Request{ID: "rdm.confirm", Actor: "alice", Owner: "alice", Params: map[string]any{"operationId": "forged-client-id"}}) + if seen == "" || seen == "forged-client-id" || resp.Receipt.OperationID != seen || len(audit.logs) != 1 || audit.logs[0].OperationID != seen { + t.Fatalf("operation correlation missing: handler=%q receipt=%+v audit=%+v", seen, resp.Receipt, audit.logs) + } + if err := bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "rdm.fail", Title: "Fail"}, "", func(_ context.Context, req Request) (any, error) { + if req.OperationID == "" { + t.Fatal("handler must receive a tentative host operation ID") + } + return nil, errors.New("domain transaction rejected") + }); err != nil { + t.Fatal(err) + } + failed := bus.Dispatch(context.Background(), Request{ID: "rdm.fail", Actor: "alice", Owner: "alice"}) + if failed.Receipt.State != agentcommand.StateRejected || failed.Receipt.OperationID != "" || len(audit.logs) != 2 || audit.logs[1].OperationID != "" { + t.Fatalf("failed handler was marked committed: %+v audits=%+v", failed.Receipt, audit.logs) + } +} + +func TestOperationIDsRemainUniqueWithFixedClockAndConcurrentDispatch(t *testing.T) { + audit := &memAudit{} + bus := New(audit, nil, func() time.Time { return time.Unix(1700000000, 123).UTC() }) + if err := bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "rdm.confirm", Title: "Confirm"}, "", func(_ context.Context, req Request) (any, error) { + return req.OperationID, nil + }); err != nil { + t.Fatal(err) + } + const count = 64 + var wg sync.WaitGroup + ids := make([]string, count) + for i := range ids { + wg.Add(1) + go func(i int) { + defer wg.Done() + resp := bus.Dispatch(context.Background(), Request{ID: "rdm.confirm", Actor: "alice", Owner: "alice"}) + ids[i] = resp.Receipt.OperationID + }(i) + } + wg.Wait() + seen := map[string]bool{} + for _, id := range ids { + if id == "" || seen[id] { + t.Fatalf("fixed clock produced a missing or duplicate trusted operation ID: %q", id) + } + seen[id] = true + } + if len(audit.logs) != count { + t.Fatalf("audit rows=%d want=%d", len(audit.logs), count) + } +} + func TestDispatchUnknownAndBadParams(t *testing.T) { a := &memAudit{} b := newTestBus(a, &memCards{}) @@ -217,6 +278,7 @@ func TestValidateParamsScalars(t *testing.T) { func TestRegisterPluginCommandDispatch(t *testing.T) { a := &memAudit{} b := newTestBus(a, &memCards{}) + b.SetProjectWorkPreflight(func(context.Context, Request) error { return nil }) err := b.RegisterPluginCommand(agentcommand.CommandSpec{ID: "rdm.ingest", Title: "入库"}, func(ctx context.Context, req Request) (any, error) { return map[string]any{"echo": req.Params["ref"], "owner": req.Owner}, nil }) diff --git a/internal/cmdbus/project_work_command_test.go b/internal/cmdbus/project_work_command_test.go new file mode 100644 index 0000000..0a6d95c --- /dev/null +++ b/internal/cmdbus/project_work_command_test.go @@ -0,0 +1,16 @@ +package cmdbus + +import "testing" + +func TestFormalRDMWritesRequireProjectWorkDecision(t *testing.T) { + for _, id := range []string{"rdm.reqs.add", "rdm.docs.instantiate", "rdm.docs.delivery.approve", "rdm.doc.snapshot", "rdm.conflicts.resolve", "rdm.review.doc.approve", "rdm.structure.propose", "rdm.trace.confirm"} { + if !RequiresProjectWorkDecision(id) { + t.Errorf("formal write %s bypassed project source preflight", id) + } + } + for _, id := range []string{"rdm.inspect.tree", "rdm.reqs.list", "rdm.structure.proposals", "rdm.structure.issue", "rdm.structure.decide"} { + if RequiresProjectWorkDecision(id) { + t.Errorf("read or separately proven command %s was misclassified", id) + } + } +} diff --git a/internal/cmdbus/slash_intent_test.go b/internal/cmdbus/slash_intent_test.go index 6c01f05..8798d27 100644 --- a/internal/cmdbus/slash_intent_test.go +++ b/internal/cmdbus/slash_intent_test.go @@ -67,6 +67,7 @@ func TestIntentDeclareMatchRoute(t *testing.T) { t.Fatalf("匹配 = %+v ok=%v", in, ok) } // 路由:匹配到的命令经总线 chat 入口分发成功。 + bus.SetProjectWorkPreflight(func(context.Context, Request) error { return nil }) resp := bus.Dispatch(context.Background(), Request{ID: in.Command, Params: in.Params, Entrance: "chat", Owner: "alice"}) if resp.Receipt.State != "committed" { t.Fatalf("路由分发 = %+v", resp.Receipt) diff --git a/internal/config/config.go b/internal/config/config.go index 45defa6..90d2538 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -36,6 +36,8 @@ type Config struct { ExecDepsDir string `json:"execDepsDir"` // ExecWorkspace sandbox 档工作区模式:空=直写;overlay=冻结基底+upper。 ExecWorkspace string `json:"execWorkspace"` + // ExecEnvRoot 分层 Python 环境根。空 = 不启用申请代理。 + ExecEnvRoot string `json:"execEnvRoot"` // Tier 部署档(协作存储方案 KTD1):空=auto(DSN→PG,否则 SQLite); // local=强制 SQLite(不允许 DSN);team=强制 PG(缺 DSN/未开认证拒绝启动, // PG 故障拒绝就绪,绝不回落 SQLite)。 @@ -69,6 +71,7 @@ var envKey = map[string]string{ "execBackend": "AGENT_SYSTEM_EXEC_BACKEND", "execDepsDir": "AGENT_SYSTEM_EXEC_DEPS_DIR", "execWorkspace": "AGENT_SYSTEM_EXEC_WORKSPACE", + "execEnvRoot": "AGENT_SYSTEM_EXEC_ENV_ROOT", "insecureListen": "AGENT_SYSTEM_INSECURE_LISTEN", "allowedHosts": "AGENT_SYSTEM_ALLOWED_HOSTS", "disableExecTools": "AGENT_SYSTEM_DISABLE_EXEC_TOOLS", @@ -213,6 +216,8 @@ func loadFile(path string) (Config, map[string]bool, error) { dst = &c.ExecDepsDir case "execWorkspace": dst = &c.ExecWorkspace + case "execEnvRoot": + dst = &c.ExecEnvRoot case "tier": dst = &c.Tier case "pgMaxOpen": @@ -260,6 +265,8 @@ func stringField(c Config, f string) string { return c.ExecDepsDir case "execWorkspace": return c.ExecWorkspace + case "execEnvRoot": + return c.ExecEnvRoot case "tier": return c.Tier } @@ -355,6 +362,8 @@ func setString(c *Config, f, v string) { c.ExecDepsDir = v case "execWorkspace": c.ExecWorkspace = v + case "execEnvRoot": + c.ExecEnvRoot = v case "tier": c.Tier = v } @@ -386,7 +395,7 @@ func (r Resolved) Explain(w io.Writer, file string) { fmt.Fprintln(w, "config file: (none)") } fmt.Fprintln(w, "field value source") - for _, f := range []string{"addr", "data", "plugins", "dsn", "artifacts", "auth", "mock", "instance", "insecureListen", "allowedHosts", "disableExecTools", "execBackend", "tier", "pgMaxOpen", "pgMaxIdle", "pgConnMaxIdleSec", "pgConnMaxLifetimeSec", "pgQueryTimeoutSec"} { + for _, f := range []string{"addr", "data", "plugins", "dsn", "artifacts", "auth", "mock", "instance", "insecureListen", "allowedHosts", "disableExecTools", "execBackend", "execDepsDir", "execWorkspace", "execEnvRoot", "tier", "pgMaxOpen", "pgMaxIdle", "pgConnMaxIdleSec", "pgConnMaxLifetimeSec", "pgQueryTimeoutSec"} { var v string if isBoolField(f) { v = fmt.Sprintf("%t", boolField(r.Config, f)) diff --git a/internal/config/superadmin.go b/internal/config/superadmin.go new file mode 100644 index 0000000..a9800a6 --- /dev/null +++ b/internal/config/superadmin.go @@ -0,0 +1,153 @@ +package config + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "os" + "regexp" + "strings" + "unicode" +) + +// 超级管理员启动凭据(R1):进程环境优先,未设置的键从受保护的 dotenv 文件补齐。 +const ( + EnvSuperAdminOwner = "SUPER_ADMIN_OWNER" + EnvSuperAdminPassword = "SUPER_ADMIN_PASSWORD" + EnvFileKey = "AGENT_SYSTEM_ENV_FILE" +) + +// ErrSuperAdminConfig 标记缺失、不完整、非法或不可读的超级管理员配置; +// 错误文本只含键名与文件路径,绝不含取值。 +var ErrSuperAdminConfig = errors.New("super admin config") + +// SuperAdmin 持有启动期超级管理员身份与口令。口令只在进程内存中, +// 格式化输出与 JSON 序列化一律脱敏。 +type SuperAdmin struct { + Owner string + password string +} + +// NewSuperAdmin 供测试与组合根直接构造(不做校验,校验见 Validate)。 +func NewSuperAdmin(owner, password string) SuperAdmin { + return SuperAdmin{Owner: owner, password: password} +} + +// Password 返回配置口令;调用方不得记录或回传。 +func (s SuperAdmin) Password() string { return s.password } + +func (s SuperAdmin) String() string { + if s.password == "" { + return fmt.Sprintf("SuperAdmin{owner=%q password=}", s.Owner) + } + return fmt.Sprintf("SuperAdmin{owner=%q password=}", s.Owner) +} + +func (s SuperAdmin) GoString() string { return s.String() } + +func (s SuperAdmin) MarshalJSON() ([]byte, error) { + return []byte(fmt.Sprintf(`{"owner":%q,"passwordConfigured":%t}`, s.Owner, s.password != "")), nil +} + +// Validate 检查身份与口令的结构有效性;passwordPolicy 为认证边界的口令策略 +// (由调用方注入,避免在配置层复制策略)。 +func (s SuperAdmin) Validate(passwordPolicy func(string) error) error { + switch { + case s.Owner == "" && s.password == "": + return fmt.Errorf("%w: %s and %s are required when authentication is enabled", ErrSuperAdminConfig, EnvSuperAdminOwner, EnvSuperAdminPassword) + case s.Owner == "": + return fmt.Errorf("%w: %s is missing (partial configuration)", ErrSuperAdminConfig, EnvSuperAdminOwner) + case s.password == "": + return fmt.Errorf("%w: %s is missing (partial configuration)", ErrSuperAdminConfig, EnvSuperAdminPassword) + } + if strings.TrimSpace(s.Owner) != s.Owner { + return fmt.Errorf("%w: %s must not have surrounding whitespace", ErrSuperAdminConfig, EnvSuperAdminOwner) + } + for _, r := range s.Owner { + if unicode.IsControl(r) { + return fmt.Errorf("%w: %s contains control characters", ErrSuperAdminConfig, EnvSuperAdminOwner) + } + } + if passwordPolicy != nil { + if err := passwordPolicy(s.password); err != nil { + return fmt.Errorf("%w: %s violates password policy: %v", ErrSuperAdminConfig, EnvSuperAdminPassword, err) + } + } + return nil +} + +// LoadSuperAdmin 读取超级管理员凭据:envLookup 中非空的键优先;未设置或空的键从 +// envFile(为空时取 AGENT_SYSTEM_ENV_FILE)补齐。文件必须存在且不得对组/其他 +// 用户开放任何权限。未开认证时不读取也不要求(嵌入式单用户档不暴露账号管理)。 +func LoadSuperAdmin(envLookup func(string) (string, bool), envFile string, authEnabled bool) (SuperAdmin, error) { + if !authEnabled { + return SuperAdmin{}, nil + } + owner, _ := envLookup(EnvSuperAdminOwner) + password, _ := envLookup(EnvSuperAdminPassword) + ownerSet, passwordSet := owner != "", password != "" + if envFile == "" { + envFile, _ = envLookup(EnvFileKey) + } + if (!ownerSet || !passwordSet) && envFile != "" { + values, err := ReadEnvFile(envFile) + if err != nil { + return SuperAdmin{}, err + } + if !ownerSet { + owner = values[EnvSuperAdminOwner] + } + if !passwordSet { + password = values[EnvSuperAdminPassword] + } + } + return SuperAdmin{Owner: owner, password: password}, nil +} + +var envFileLine = regexp.MustCompile(`^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$`) + +// ReadEnvFile 按 svc.sh 使用的 dotenv 格式安全解析: +// KEY=VALUE、# 注释、export 前缀、剥成对引号;非法行忽略;不执行内容。 +// 文件对组/其他用户有任何权限位即拒绝(受保护 .env 约定)。 +func ReadEnvFile(path string) (map[string]string, error) { + info, err := os.Stat(path) + if err != nil { + return nil, fmt.Errorf("%w: env file: %v", ErrSuperAdminConfig, err) + } + if !info.Mode().IsRegular() { + return nil, fmt.Errorf("%w: env file %s is not a regular file", ErrSuperAdminConfig, path) + } + if perm := info.Mode().Perm(); perm&0o077 != 0 { + return nil, fmt.Errorf("%w: env file %s is accessible by group/other (mode %04o; chmod 600)", ErrSuperAdminConfig, path, perm) + } + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("%w: env file: %v", ErrSuperAdminConfig, err) + } + values := map[string]string{} + scanner := bufio.NewScanner(bytes.NewReader(raw)) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + line := strings.TrimSuffix(scanner.Text(), "\r") + if line == "" || strings.HasPrefix(line, "#") { + continue + } + m := envFileLine.FindStringSubmatch(line) + if m == nil { + continue + } + values[m[1]] = unquoteEnvValue(strings.TrimLeft(m[2], " \t")) + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("%w: env file %s: unreadable line", ErrSuperAdminConfig, path) + } + return values, nil +} + +func unquoteEnvValue(v string) string { + if len(v) >= 2 && (v[0] == '"' || v[0] == '\'') && v[len(v)-1] == v[0] { + return v[1 : len(v)-1] + } + return v +} diff --git a/internal/config/superadmin_test.go b/internal/config/superadmin_test.go new file mode 100644 index 0000000..dbbae40 --- /dev/null +++ b/internal/config/superadmin_test.go @@ -0,0 +1,157 @@ +package config + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +const testSuperPassword = "Sup3r-Secret-Value" + +func lookupMap(m map[string]string) func(string) (string, bool) { + return func(k string) (string, bool) { + v, ok := m[k] + return v, ok + } +} + +func testPolicy(pw string) error { + if len(pw) < 8 || len(pw) > 256 { + return errors.New("password must be 8-256 characters") + } + return nil +} + +func writeEnvFile(t *testing.T, body string, perm os.FileMode) string { + t.Helper() + path := filepath.Join(t.TempDir(), ".env") + if err := os.WriteFile(path, []byte(body), perm); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, perm); err != nil { + t.Fatal(err) + } + return path +} + +func assertNoSecret(t *testing.T, err error, secret string) { + t.Helper() + if err != nil && secret != "" && strings.Contains(err.Error(), secret) { + t.Fatalf("error leaks secret: %v", err) + } +} + +func TestSuperAdminValidateFailsClosed(t *testing.T) { + cases := []struct { + name, owner, password, want string + }{ + {"both absent", "", "", "SUPER_ADMIN_OWNER and SUPER_ADMIN_PASSWORD are required"}, + {"owner only", "root", "", "SUPER_ADMIN_PASSWORD is missing"}, + {"password only", "", testSuperPassword, "SUPER_ADMIN_OWNER is missing"}, + {"short password", "root", "short", "SUPER_ADMIN_PASSWORD violates password policy"}, + {"owner whitespace", " root", testSuperPassword, "surrounding whitespace"}, + {"owner control", "ro\x01ot", testSuperPassword, "control characters"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + sa, err := LoadSuperAdmin(lookupMap(map[string]string{EnvSuperAdminOwner: tc.owner, EnvSuperAdminPassword: tc.password}), "", true) + if err != nil { + t.Fatal(err) + } + err = sa.Validate(testPolicy) + if !errors.Is(err, ErrSuperAdminConfig) || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("err=%v want %q", err, tc.want) + } + assertNoSecret(t, err, tc.password) + }) + } +} + +func TestSuperAdminLoadFromProcessEnvironment(t *testing.T) { + sa, err := LoadSuperAdmin(lookupMap(map[string]string{EnvSuperAdminOwner: "root", EnvSuperAdminPassword: testSuperPassword}), "", true) + if err != nil { + t.Fatal(err) + } + if sa.Owner != "root" || sa.Password() != testSuperPassword { + t.Fatalf("got owner=%q", sa.Owner) + } + if err := sa.Validate(testPolicy); err != nil { + t.Fatal(err) + } +} + +func TestSuperAdminEnvFileFillsUnsetKeysOnly(t *testing.T) { + path := writeEnvFile(t, "# comment\nexport SUPER_ADMIN_OWNER=\"file-root\"\nSUPER_ADMIN_PASSWORD='"+testSuperPassword+"'\r\nnot a line\n", 0o600) + sa, err := LoadSuperAdmin(lookupMap(map[string]string{}), path, true) + if err != nil { + t.Fatal(err) + } + if sa.Owner != "file-root" || sa.Password() != testSuperPassword { + t.Fatalf("file values not loaded: owner=%q", sa.Owner) + } + sa, err = LoadSuperAdmin(lookupMap(map[string]string{EnvSuperAdminOwner: "env-root", EnvSuperAdminPassword: ""}), path, true) + if err != nil { + t.Fatal(err) + } + if sa.Owner != "env-root" || sa.Password() != testSuperPassword { + t.Fatalf("process environment must win, empty must fall back: owner=%q", sa.Owner) + } + sa, err = LoadSuperAdmin(lookupMap(map[string]string{EnvFileKey: path}), "", true) + if err != nil || sa.Owner != "file-root" { + t.Fatalf("AGENT_SYSTEM_ENV_FILE not honored: owner=%q err=%v", sa.Owner, err) + } +} + +func TestSuperAdminEnvFilePartialFailsValidation(t *testing.T) { + path := writeEnvFile(t, "SUPER_ADMIN_OWNER=root\n", 0o600) + sa, err := LoadSuperAdmin(lookupMap(map[string]string{}), path, true) + if err != nil { + t.Fatal(err) + } + if err := sa.Validate(testPolicy); err == nil || !strings.Contains(err.Error(), "SUPER_ADMIN_PASSWORD is missing") { + t.Fatalf("partial file accepted: %v", err) + } +} + +func TestSuperAdminEnvFileMustBeProtected(t *testing.T) { + path := writeEnvFile(t, "SUPER_ADMIN_OWNER=root\nSUPER_ADMIN_PASSWORD="+testSuperPassword+"\n", 0o644) + _, err := LoadSuperAdmin(lookupMap(map[string]string{}), path, true) + if !errors.Is(err, ErrSuperAdminConfig) || !strings.Contains(err.Error(), "group/other") { + t.Fatalf("world-readable env file accepted: %v", err) + } + assertNoSecret(t, err, testSuperPassword) + _, err = LoadSuperAdmin(lookupMap(map[string]string{}), filepath.Join(t.TempDir(), "missing.env"), true) + if !errors.Is(err, ErrSuperAdminConfig) { + t.Fatalf("missing env file accepted: %v", err) + } +} + +func TestSuperAdminNotRequiredWithoutAuthentication(t *testing.T) { + sa, err := LoadSuperAdmin(lookupMap(map[string]string{EnvSuperAdminOwner: "root"}), filepath.Join(t.TempDir(), "missing.env"), false) + if err != nil { + t.Fatalf("auth-disabled mode must not read or require credentials: %v", err) + } + if sa.Owner != "" || sa.Password() != "" { + t.Fatalf("auth-disabled mode loaded credentials: owner=%q", sa.Owner) + } +} + +func TestSuperAdminRedactsPassword(t *testing.T) { + sa := NewSuperAdmin("root", testSuperPassword) + for _, out := range []string{fmt.Sprint(sa), fmt.Sprintf("%v %+v %#v %s", sa, sa, sa, sa), fmt.Sprint(&sa)} { + if strings.Contains(out, testSuperPassword) { + t.Fatalf("formatted output leaks password: %s", out) + } + } + raw, err := json.Marshal(struct{ SuperAdmin SuperAdmin }{sa}) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), testSuperPassword) || !strings.Contains(string(raw), `"passwordConfigured":true`) { + t.Fatalf("json leaks password or lost status: %s", raw) + } +} diff --git a/internal/ctxasm/ctxasm.go b/internal/ctxasm/ctxasm.go index 010a3d5..cba242d 100644 --- a/internal/ctxasm/ctxasm.go +++ b/internal/ctxasm/ctxasm.go @@ -4,12 +4,14 @@ package ctxasm import ( "encoding/json" "fmt" + "path/filepath" "regexp" "strings" "time" "unicode/utf8" agentllm "agent-native/runtime/llm" + "agent-system-backend/internal/vision" core "agent-system-core" ) @@ -134,6 +136,7 @@ func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history [ // 此处与整段组装共用同一实现,保证两条路径的当前轮语义一致。 func BuildCurrentUser(eff core.EffectiveSettings, currentUser core.Message, attachments []core.ParsedDocument, knowledge []core.RetrievalHit, rep *Report) agentllm.Message { var sb strings.Builder + var images []agentllm.Image for _, p := range currentUser.Parts { if p.Type == core.PartText { sb.WriteString(p.Text) @@ -146,6 +149,22 @@ func BuildCurrentUser(eff core.EffectiveSettings, currentUser core.Message, atta } sb.WriteString("\n\n[附件](用户上传文档的解析全文,按原文顺序保留标题/列表/表格;超长时文末标注截断。请依据文档内容完成任务,输出完整不省略)\n") for _, doc := range attachments { + if pics := vision.ImagesFromDoc(doc); len(pics) > 0 { + kept, notes := vision.Admit(images, pics) + images = append(images, kept...) + if len(kept) > 0 { + fmt.Fprintf(&sb, "### %s\n[图片 %s,图像已随本消息发送]\n", doc.Meta.Filename, doc.Meta.Filename) + } else { + fmt.Fprintf(&sb, "### %s\n[图片 %s,本轮未发送图像]\n", doc.Meta.Filename, doc.Meta.Filename) + } + for _, note := range notes { + fmt.Fprintf(&sb, "%s\n", note) + if rep != nil { + rep.Truncations = append(rep.Truncations, fmt.Sprintf("附件 %s:%s", doc.Meta.Filename, note)) + } + } + continue + } text := doc.PlainText() if runes := []rune(text); len(runes) > limit { text = string(runes[:limit]) + "…(附件超长截断)" @@ -168,7 +187,7 @@ func BuildCurrentUser(eff core.EffectiveSettings, currentUser core.Message, atta rep.Items = append(rep.Items, fmt.Sprintf("knowledge(%d)", len(knowledge))) } } - return agentllm.Message{Role: agentllm.RoleUser, Content: sb.String()} + return agentllm.Message{Role: agentllm.RoleUser, Content: sb.String(), Images: images} } // maxToolArgRunes 是回放进模型的单次工具参数上限。超限时截断字符串值,保持合法 JSON。 @@ -435,6 +454,10 @@ func renderArtifactRef(a *core.ArtifactRef, resolve ArtifactResolver, limit int, if a == nil { return "" } + name := a.Name + if isImageArtifact(a.Kind, name) { + return fmt.Sprintf("此前上传的图片:%s。\n", name) + } if resolve == nil { return fmt.Sprintf("此前已生成文件:%s。\n", a.Name) } @@ -454,6 +477,18 @@ func renderArtifactRef(a *core.ArtifactRef, resolve ArtifactResolver, limit int, return fmt.Sprintf("此前已生成文件 %s,内容如下:\n%s\n", art.Name, text) } +func isImageArtifact(kind, name string) bool { + if kind == "image" { + return true + } + switch strings.ToLower(filepath.Ext(name)) { + case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg": + return true + default: + return false + } +} + // extractiveSummary 把窗口外历史压成短摘要(不调用模型)。summarizeBeyond 打开时注入。 func extractiveSummary(dropped []core.Message) string { var lines []string diff --git a/internal/ctxasm/ctxasm_test.go b/internal/ctxasm/ctxasm_test.go index 0bb553b..8be4b36 100644 --- a/internal/ctxasm/ctxasm_test.go +++ b/internal/ctxasm/ctxasm_test.go @@ -203,3 +203,47 @@ func contains(items []string, want string) bool { } return false } + +func TestCurrentImageAttachmentAndHistoryPlaceholder(t *testing.T) { + png := []byte{ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, + 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x50, + 0x0f, 0x00, 0x04, 0x85, 0x01, 0x80, 0x84, 0xa9, 0x8c, 0x21, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, + 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, + } + doc := core.ParsedDocument{ + Meta: core.DocMeta{Filename: "chart.png", MIME: "image/png"}, + Blocks: []core.DocBlock{{ + Type: core.BlockImageRef, + Ref: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + }}, + } + msg := BuildCurrentUser(core.EffectiveSettings{}, fakeUser("看图"), []core.ParsedDocument{doc}, nil, &Report{}) + if len(msg.Images) != 1 || msg.Images[0].MIME != "image/png" || msg.Images[0].Data == "" { + t.Fatalf("images %+v", msg.Images) + } + if !strings.Contains(msg.Content, "图像已随本消息发送") || strings.Contains(msg.Content, string(png)) { + t.Fatalf("content %q", msg.Content) + } + + prev := core.Message{ID: "old", Role: core.RoleUser, Parts: []core.Part{ + {Type: core.PartText, Text: "上一张"}, + {Type: core.PartArtifactRef, Artifact: &core.ArtifactRef{ArtifactID: "a1", Name: "chart.png", Kind: "image"}}, + }} + msgs, _ := AssembleInput(core.AgentProfile{}, core.EffectiveSettings{}, []core.Message{prev}, fakeUser("继续"), nil, nil, nil, nil, nil, func(core.ID) (core.Artifact, []byte, bool) { + return core.Artifact{Name: "chart.png", MIME: "image/png"}, png, true + }, AssembleOptions{}) + var hist string + for _, m := range msgs { + if strings.Contains(m.Content, "上一张") { + hist = m.Content + if len(m.Images) != 0 { + t.Fatal("history must not resend image bytes") + } + } + } + if !strings.Contains(hist, "此前上传的图片:chart.png") { + t.Fatalf("history placeholder missing: %q", hist) + } +} diff --git a/internal/docgen/comparison.go b/internal/docgen/comparison.go new file mode 100644 index 0000000..512b0b6 --- /dev/null +++ b/internal/docgen/comparison.go @@ -0,0 +1,814 @@ +package docgen + +import ( + "archive/zip" + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "encoding/xml" + "errors" + "fmt" + "io" + "strconv" + "strings" + + "agent-document-renderer/model" +) + +const ( + comparisonExceptionTitle = "RDR-T10-CMP-001" + comparisonExceptionCorporate = "RDR-T10-CMP-002" + comparisonExceptionAnchors = "RDR-T10-CMP-003" + comparisonExceptionSafeLinks = "RDR-T10-CMP-004" +) + +type comparisonBlock struct { + Kind string `json:"kind"` + Level int `json:"level,omitempty"` + Text []string `json:"text,omitempty"` + Rows [][]string `json:"rows,omitempty"` +} + +type comparisonList struct { + Depth int `json:"depth"` + Ordered bool `json:"ordered"` + Start int `json:"start,omitempty"` + Items []comparisonListItem `json:"items"` +} + +type comparisonListItem struct { + Text string `json:"text"` + Checked string `json:"checked,omitempty"` +} + +type comparisonTask struct { + Text string `json:"text"` + Checked string `json:"checked"` +} + +type comparisonSemantics struct { + Lists []comparisonList `json:"lists"` + Tasks []comparisonTask `json:"tasks"` + ThematicBreaks int `json:"thematicBreaks"` + TableAlignments [][][]string `json:"tableAlignments"` +} + +type docxNumbering struct { + Level int `json:"level"` + Format string `json:"format"` + Start int `json:"start"` +} + +type docxComparison struct { + Paragraphs []string `json:"paragraphs"` + TOC []string `json:"toc"` + PageSize map[string]string `json:"pageSize"` + Margins map[string]string `json:"margins"` + Headers int `json:"headers"` + Footers int `json:"footers"` + Bookmarks int `json:"bookmarks"` + ExternalLinks int `json:"externalLinks"` + ForbiddenObject bool `json:"forbiddenObject"` + Semantics map[string]bool `json:"semantics"` + Numbering []docxNumbering `json:"numbering"` + Tasks []comparisonTask `json:"tasks"` + ThematicRules int `json:"thematicRules"` + TableAlignments [][][]string `json:"tableAlignments"` +} + +func compareLegacyAndShared(title string, source []byte, canonical model.CanonicalDocument, legacy, shared []byte) ComparisonReceipt { + receipt := ComparisonReceipt{Enabled: true, LegacySHA256: digestBytes(legacy), SharedSHA256: digestBytes(shared)} + legacyStructure := legacyComparisonStructure(string(source)) + sharedStructure := canonicalComparisonStructure(canonical) + receipt.Dimensions = append(receipt.Dimensions, compareExactDimension("canonical-structure", legacyStructure, sharedStructure, "")) + legacySemantics := sourceComparisonSemantics(string(source)) + sharedSemantics := canonicalComparisonSemantics(canonical) + receipt.Dimensions = append(receipt.Dimensions, + compareExactDimension("list-structure", legacySemantics.Lists, sharedSemantics.Lists, ""), + compareExactDimension("task-state", legacySemantics.Tasks, sharedSemantics.Tasks, ""), + compareExactDimension("thematic-break", legacySemantics.ThematicBreaks, sharedSemantics.ThematicBreaks, ""), + compareExactDimension("table-alignment", legacySemantics.TableAlignments, sharedSemantics.TableAlignments, ""), + ) + + legacyDOCX, legacyErr := inspectComparisonDOCX(legacy) + sharedDOCX, sharedErr := inspectComparisonDOCX(shared) + if legacyErr != nil || sharedErr != nil { + detail := fmt.Sprintf("legacy=%v shared=%v", legacyErr, sharedErr) + for _, name := range []string{ + "visible-text", "table-of-contents", "page-layout", "header-footer", "node-bookmarks", "safe-links", "security-objects", "word-semantics", + "word-numbering", "word-task-state", "word-thematic-rule", "word-table-alignment", + } { + receipt.Dimensions = append(receipt.Dimensions, ComparisonDimension{Name: name, Status: "unassessed", Detail: detail}) + } + return finishComparison(receipt) + } + + legacyParagraphs := append([]string(nil), legacyDOCX.Paragraphs...) + if len(legacyParagraphs) >= 2 && legacyParagraphs[0] == normalizeComparisonText(title) && legacyParagraphs[1] == normalizeComparisonText(title) { + withoutDuplicate := append([]string(nil), legacyParagraphs[1:]...) + if equalJSON(withoutDuplicate, sharedDOCX.Paragraphs) { + receipt.Dimensions = append(receipt.Dimensions, intentionalDimension("visible-text", legacyParagraphs, sharedDOCX.Paragraphs, + comparisonExceptionTitle, "legacy synthetic Title duplicated the first canonical heading")) + } else { + receipt.Dimensions = append(receipt.Dimensions, compareExactDimension("visible-text", legacyParagraphs, sharedDOCX.Paragraphs, "")) + } + } else { + receipt.Dimensions = append(receipt.Dimensions, compareExactDimension("visible-text", legacyParagraphs, sharedDOCX.Paragraphs, "")) + } + if len(legacyDOCX.TOC) == 0 && len(sharedDOCX.TOC) > 0 { + receipt.Dimensions = append(receipt.Dimensions, intentionalDimension("table-of-contents", legacyDOCX.TOC, sharedDOCX.TOC, + comparisonExceptionCorporate, "published organization default declares a table of contents")) + } else { + receipt.Dimensions = append(receipt.Dimensions, compareExactDimension("table-of-contents", legacyDOCX.TOC, sharedDOCX.TOC, "")) + } + + if equalJSON(legacyDOCX.PageSize, sharedDOCX.PageSize) { + receipt.Dimensions = append(receipt.Dimensions, compareExactDimension("page-size", legacyDOCX.PageSize, sharedDOCX.PageSize, "")) + } else { + receipt.Dimensions = append(receipt.Dimensions, ComparisonDimension{Name: "page-size", Status: "accidental", LegacyHash: digestJSON(legacyDOCX.PageSize), SharedHash: digestJSON(sharedDOCX.PageSize), Detail: "page size changed"}) + } + if equalJSON(legacyDOCX.Margins, sharedDOCX.Margins) { + receipt.Dimensions = append(receipt.Dimensions, compareExactDimension("page-margins", legacyDOCX.Margins, sharedDOCX.Margins, "")) + } else { + receipt.Dimensions = append(receipt.Dimensions, intentionalDimension("page-margins", legacyDOCX.Margins, sharedDOCX.Margins, + comparisonExceptionCorporate, "published organization default owns page margins")) + } + if legacyDOCX.Headers == sharedDOCX.Headers && legacyDOCX.Footers == sharedDOCX.Footers { + receipt.Dimensions = append(receipt.Dimensions, compareExactDimension("header-footer", []int{legacyDOCX.Headers, legacyDOCX.Footers}, []int{sharedDOCX.Headers, sharedDOCX.Footers}, "")) + } else { + receipt.Dimensions = append(receipt.Dimensions, intentionalDimension("header-footer", []int{legacyDOCX.Headers, legacyDOCX.Footers}, []int{sharedDOCX.Headers, sharedDOCX.Footers}, + comparisonExceptionCorporate, "published organization default adds approved header/footer rules")) + } + if legacyDOCX.Bookmarks == sharedDOCX.Bookmarks { + receipt.Dimensions = append(receipt.Dimensions, compareExactDimension("node-bookmarks", legacyDOCX.Bookmarks, sharedDOCX.Bookmarks, "")) + } else if legacyDOCX.Bookmarks == 0 && sharedDOCX.Bookmarks > 0 { + receipt.Dimensions = append(receipt.Dimensions, intentionalDimension("node-bookmarks", legacyDOCX.Bookmarks, sharedDOCX.Bookmarks, + comparisonExceptionAnchors, "shared output anchors canonical node IDs")) + } else { + receipt.Dimensions = append(receipt.Dimensions, compareExactDimension("node-bookmarks", legacyDOCX.Bookmarks, sharedDOCX.Bookmarks, "")) + } + if legacyDOCX.ExternalLinks == sharedDOCX.ExternalLinks { + receipt.Dimensions = append(receipt.Dimensions, compareExactDimension("safe-links", legacyDOCX.ExternalLinks, sharedDOCX.ExternalLinks, "")) + } else if legacyDOCX.ExternalLinks > 0 && sharedDOCX.ExternalLinks == 0 { + receipt.Dimensions = append(receipt.Dimensions, intentionalDimension("safe-links", legacyDOCX.ExternalLinks, sharedDOCX.ExternalLinks, + comparisonExceptionSafeLinks, "shared links are inert labels with no external relationship or target leakage")) + } else { + receipt.Dimensions = append(receipt.Dimensions, compareExactDimension("safe-links", legacyDOCX.ExternalLinks, sharedDOCX.ExternalLinks, "")) + } + receipt.Dimensions = append(receipt.Dimensions, + compareExactDimension("security-objects", legacyDOCX.ForbiddenObject, sharedDOCX.ForbiddenObject, ""), + compareExactDimension("word-semantics", legacyDOCX.Semantics, sharedDOCX.Semantics, ""), + compareExactDimension("word-numbering", legacyDOCX.Numbering, sharedDOCX.Numbering, ""), + compareExactDimension("word-task-state", legacyDOCX.Tasks, sharedDOCX.Tasks, ""), + compareExactDimension("word-thematic-rule", legacyDOCX.ThematicRules, sharedDOCX.ThematicRules, ""), + compareExactDimension("word-table-alignment", legacyDOCX.TableAlignments, sharedDOCX.TableAlignments, ""), + ) + return finishComparison(receipt) +} + +func finishComparison(receipt ComparisonReceipt) ComparisonReceipt { + for _, dimension := range receipt.Dimensions { + switch dimension.Status { + case "intentional": + receipt.IntentionalDifferences = append(receipt.IntentionalDifferences, dimension.Name+": "+dimension.Detail) + case "accidental": + receipt.AccidentalDifferences = append(receipt.AccidentalDifferences, dimension.Name+": "+dimension.Detail) + case "unassessed": + receipt.Unassessed = append(receipt.Unassessed, dimension.Name+": "+dimension.Detail) + } + } + switch { + case len(receipt.Unassessed) > 0: + receipt.Assessment = "unassessed" + case len(receipt.AccidentalDifferences) > 0: + receipt.Assessment = "differences" + default: + receipt.Assessment = "assessed" + } + return receipt +} + +func compareExactDimension(name string, legacy, shared any, detail string) ComparisonDimension { + status := "match" + if !equalJSON(legacy, shared) { + status = "accidental" + if detail == "" { + detail = "normalized values differ" + } + } + return ComparisonDimension{Name: name, Status: status, LegacyHash: digestJSON(legacy), SharedHash: digestJSON(shared), Detail: detail} +} + +func intentionalDimension(name string, legacy, shared any, exceptionID, detail string) ComparisonDimension { + return ComparisonDimension{Name: name, Status: "intentional", LegacyHash: digestJSON(legacy), SharedHash: digestJSON(shared), ExceptionID: exceptionID, Detail: detail} +} + +func legacyComparisonStructure(source string) []comparisonBlock { + document := parseMarkdown(source) + blocks := make([]comparisonBlock, 0, len(document.blocks)) + for _, block := range document.blocks { + entry := comparisonBlock{Level: block.level} + switch block.kind { + case bHeading: + entry.Kind, entry.Text = "heading", []string{visibleLegacyRuns(document.parseInline(strings.Join(block.lines, "\n")))} + case bPara: + entry.Kind, entry.Text = "paragraph", []string{visibleLegacyRuns(document.parseInline(strings.Join(block.lines, "\n")))} + case bQuote: + entry.Kind, entry.Text = "quote", []string{normalizeComparisonText(strings.Join(block.lines, "\n"))} + case bCode: + entry.Kind, entry.Text = "code-block", []string{strings.Join(block.lines, "\n") + "\n"} + case bList: + entry.Kind = "list" + for _, item := range block.items { + entry.Text = append(entry.Text, visibleLegacyRuns(document.parseInline(item.text))) + } + case bTable: + entry.Kind = "table" + for _, row := range block.rows { + visible := make([]string, len(row)) + for index := range row { + visible[index] = visibleLegacyRuns(document.parseInline(row[index])) + } + entry.Rows = append(entry.Rows, visible) + } + case bHR: + entry.Kind = "thematic-break" + } + blocks = append(blocks, entry) + } + return blocks +} + +func canonicalComparisonStructure(document model.CanonicalDocument) []comparisonBlock { + blocks := make([]comparisonBlock, 0, len(document.Root.Children)) + for _, node := range document.Root.Children { + entry := comparisonBlock{Kind: string(node.Kind)} + if node.Kind == model.NodeHeading { + fmt.Sscanf(node.Semantic, "heading%d", &entry.Level) + } + switch node.Kind { + case model.NodeHeading, model.NodeParagraph, model.NodeQuote, model.NodeCodeBlock, model.NodeCallout: + entry.Text = []string{normalizeComparisonNodeText(node)} + case model.NodeList: + for _, item := range node.Children { + entry.Text = append(entry.Text, normalizeComparisonNodeText(item)) + } + case model.NodeTable: + for _, row := range node.Children { + values := make([]string, len(row.Children)) + for index := range row.Children { + values[index] = normalizeComparisonNodeText(row.Children[index]) + } + entry.Rows = append(entry.Rows, values) + } + case model.NodeThematicBreak: + default: + continue + } + blocks = append(blocks, entry) + } + return blocks +} + +func normalizeComparisonNodeText(node model.Node) string { + if node.Kind == model.NodeCodeBlock { + return node.Text + } + return normalizeComparisonText(node.Text) +} + +func visibleLegacyRuns(runs []run) string { + var value strings.Builder + for _, run := range runs { + value.WriteString(run.text) + } + return normalizeComparisonText(value.String()) +} + +func normalizeComparisonText(value string) string { return strings.Join(strings.Fields(value), " ") } + +func sourceComparisonSemantics(source string) comparisonSemantics { + document := parseMarkdown(source) + result := comparisonSemantics{} + for _, block := range document.blocks { + switch block.kind { + case bHR: + result.ThematicBreaks++ + case bTable: + rows := make([][]string, len(block.rows)) + for rowIndex, row := range block.rows { + rows[rowIndex] = make([]string, len(row)) + for cellIndex := range row { + alignment := "left" + if cellIndex < len(block.align) && block.align[cellIndex] != "" { + alignment = block.align[cellIndex] + } + rows[rowIndex][cellIndex] = alignment + } + } + result.TableAlignments = append(result.TableAlignments, rows) + } + } + + type openList struct { + indent int + ordered bool + index int + } + var stack []openList + inFence := false + fence := "" + for _, rawLine := range strings.Split(strings.ReplaceAll(source, "\r\n", "\n"), "\n") { + trimmed := strings.TrimSpace(rawLine) + if strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") { + marker := trimmed[:3] + if !inFence { + inFence, fence = true, marker + } else if marker == fence { + inFence, fence = false, "" + } + stack = nil + continue + } + if inFence { + continue + } + line, found := parseComparisonListLine(rawLine) + if !found { + stack = nil + continue + } + for len(stack) > 0 && stack[len(stack)-1].indent > line.indent { + stack = stack[:len(stack)-1] + } + if len(stack) > 0 && stack[len(stack)-1].indent == line.indent && stack[len(stack)-1].ordered != line.ordered { + stack = stack[:len(stack)-1] + } + if len(stack) == 0 || stack[len(stack)-1].indent != line.indent { + start := 0 + if line.ordered { + start = line.start + } + result.Lists = append(result.Lists, comparisonList{Depth: len(stack), Ordered: line.ordered, Start: start}) + stack = append(stack, openList{indent: line.indent, ordered: line.ordered, index: len(result.Lists) - 1}) + } + list := &result.Lists[stack[len(stack)-1].index] + text := normalizeComparisonText(visibleLegacyRuns(document.parseInline(line.text))) + list.Items = append(list.Items, comparisonListItem{Text: text, Checked: line.checked}) + if line.checked != "" { + result.Tasks = append(result.Tasks, comparisonTask{Text: text, Checked: line.checked}) + } + } + return result +} + +type comparisonListLine struct { + indent int + ordered bool + start int + checked string + text string +} + +func parseComparisonListLine(raw string) (comparisonListLine, bool) { + line := strings.TrimRight(raw, " \t") + indent := 0 + for indent < len(line) && line[indent] == ' ' { + indent++ + } + if indent < len(line) && line[indent] == '\t' { + return comparisonListLine{}, false + } + rest := line[indent:] + parsed := comparisonListLine{indent: indent, start: 1} + for _, marker := range []string{"- ", "* ", "+ "} { + if strings.HasPrefix(rest, marker) { + parsed.text = strings.TrimSpace(strings.TrimPrefix(rest, marker)) + if len(parsed.text) >= 3 && parsed.text[0] == '[' && parsed.text[2] == ']' && (parsed.text[1] == ' ' || parsed.text[1] == 'x' || parsed.text[1] == 'X') { + parsed.checked = strconv.FormatBool(parsed.text[1] == 'x' || parsed.text[1] == 'X') + parsed.text = strings.TrimSpace(parsed.text[3:]) + } + return parsed, true + } + } + digits := 0 + for digits < len(rest) && rest[digits] >= '0' && rest[digits] <= '9' { + digits++ + } + if digits == 0 || digits > 9 || digits+1 >= len(rest) || (rest[digits] != '.' && rest[digits] != ')') || rest[digits+1] != ' ' { + return comparisonListLine{}, false + } + value, err := strconv.Atoi(rest[:digits]) + if err != nil { + return comparisonListLine{}, false + } + parsed.start = value + parsed.ordered = true + parsed.text = strings.TrimSpace(rest[digits+2:]) + return parsed, true +} + +func canonicalComparisonSemantics(document model.CanonicalDocument) comparisonSemantics { + result := comparisonSemantics{} + var addList func(model.Node, int) + addList = func(list model.Node, depth int) { + ordered := nodePropertyValue(list, "ordered") == "true" + start := 0 + if ordered { + start = 1 + if value := nodePropertyValue(list, "start"); value != "" { + if parsed, err := strconv.Atoi(value); err == nil { + start = parsed + } + } + } + summary := comparisonList{Depth: depth, Ordered: ordered, Start: start} + for _, item := range list.Children { + checked := nodePropertyValue(item, "checked") + text := normalizeComparisonNodeText(item) + summary.Items = append(summary.Items, comparisonListItem{Text: text, Checked: checked}) + if checked != "" { + result.Tasks = append(result.Tasks, comparisonTask{Text: text, Checked: checked}) + } + } + result.Lists = append(result.Lists, summary) + for _, item := range list.Children { + for _, child := range item.Children { + if child.Kind == model.NodeList { + addList(child, depth+1) + } + } + } + } + for _, node := range document.Root.Children { + switch node.Kind { + case model.NodeList: + addList(node, 0) + case model.NodeThematicBreak: + result.ThematicBreaks++ + case model.NodeTable: + rows := make([][]string, len(node.Children)) + for rowIndex, row := range node.Children { + rows[rowIndex] = make([]string, len(row.Children)) + for cellIndex, cell := range row.Children { + alignment := nodePropertyValue(cell, "alignment") + if alignment == "" { + alignment = "left" + } + rows[rowIndex][cellIndex] = alignment + } + } + result.TableAlignments = append(result.TableAlignments, rows) + } + } + return result +} + +func nodePropertyValue(node model.Node, name string) string { + for _, property := range node.Properties { + if property.Name == name { + return property.Value + } + } + return "" +} + +func inspectComparisonDOCX(raw []byte) (docxComparison, error) { + reader, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw))) + if err != nil { + return docxComparison{}, err + } + parts := make(map[string][]byte, len(reader.File)) + for _, file := range reader.File { + stream, err := file.Open() + if err != nil { + return docxComparison{}, err + } + content, err := io.ReadAll(stream) + stream.Close() + if err != nil { + return docxComparison{}, err + } + parts[file.Name] = content + } + document, found := parts["word/document.xml"] + if !found { + return docxComparison{}, errors.New("word/document.xml missing") + } + result := docxComparison{PageSize: map[string]string{}, Margins: map[string]string{}, Semantics: map[string]bool{}} + decoder := xml.NewDecoder(bytes.NewReader(document)) + var paragraph strings.Builder + type rawNumberedParagraph struct { + Level int + NumID string + Text string + } + var numbered []rawNumberedParagraph + inParagraph, inText, inCell, inParagraphBorder := false, false, false, false + paragraphLevel, paragraphNumID, cellAlignment := 0, "", "" + paragraphRule := false + // A TOC is its field's paragraphs plus an immediately preceding caption + // paragraph excluded from the outline (outlineLvl 9). + inInstruction, paragraphTOC, paragraphCaption := false, false, false + fieldDepth, tocFieldDepth := 0, 0 + var pendingCaption []string + var table [][]string + var row []string + for { + token, err := decoder.Token() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return docxComparison{}, err + } + switch value := token.(type) { + case xml.StartElement: + switch value.Name.Local { + case "p": + inParagraph = true + paragraph.Reset() + paragraphLevel, paragraphNumID, paragraphRule = 0, "", false + paragraphTOC, paragraphCaption = tocFieldDepth > 0, false + case "t": + if inParagraph { + inText = true + } + case "outlineLvl": + if inParagraph && xmlAttributeValue(value.Attr, "val") == "9" { + paragraphCaption = true + } + case "fldChar": + switch xmlAttributeValue(value.Attr, "fldCharType") { + case "begin": + fieldDepth++ + case "end": + if fieldDepth == tocFieldDepth { + tocFieldDepth = 0 + } + fieldDepth = max(fieldDepth-1, 0) + } + case "instrText": + inInstruction = true + case "numId": + if inParagraph { + paragraphNumID = xmlAttributeValue(value.Attr, "val") + } + case "ilvl": + if inParagraph { + paragraphLevel, _ = strconv.Atoi(xmlAttributeValue(value.Attr, "val")) + } + case "pBdr": + if inParagraph { + inParagraphBorder = true + } + case "bottom": + if inParagraphBorder { + paragraphRule = true + } + case "tbl": + table = nil + case "tr": + row = nil + case "tc": + inCell, cellAlignment = true, "" + case "jc": + if inCell { + cellAlignment = xmlAttributeValue(value.Attr, "val") + } + case "pgSz": + copyAttributes(result.PageSize, value.Attr, "w", "h", "orient") + case "pgMar": + copyAttributes(result.Margins, value.Attr, "top", "right", "bottom", "left", "header", "footer", "gutter") + case "bookmarkStart": + result.Bookmarks++ + } + case xml.CharData: + if inText { + paragraph.Write(value) + } + if inInstruction && tocFieldDepth == 0 && fieldDepth > 0 { + if fields := strings.Fields(string(value)); len(fields) > 0 && strings.EqualFold(fields[0], "TOC") { + tocFieldDepth, paragraphTOC = fieldDepth, inParagraph + } + } + case xml.EndElement: + switch value.Name.Local { + case "t": + inText = false + case "instrText": + inInstruction = false + case "pBdr": + inParagraphBorder = false + case "p": + text := normalizeComparisonText(paragraph.String()) + if paragraphTOC { + result.TOC = append(result.TOC, pendingCaption...) + pendingCaption = nil + if text != "" { + result.TOC = append(result.TOC, text) + } + inParagraph = false + break + } + result.Paragraphs = append(result.Paragraphs, pendingCaption...) + pendingCaption = nil + if text != "" && paragraphCaption { + pendingCaption = []string{text} + } else if text != "" { + result.Paragraphs = append(result.Paragraphs, text) + } + if paragraphNumID != "" { + numbered = append(numbered, rawNumberedParagraph{Level: paragraphLevel, NumID: paragraphNumID, Text: text}) + switch { + case strings.HasPrefix(text, "☑"): + result.Tasks = append(result.Tasks, comparisonTask{Text: normalizeComparisonText(strings.TrimSpace(strings.TrimPrefix(text, "☑"))), Checked: "true"}) + case strings.HasPrefix(text, "☐"): + result.Tasks = append(result.Tasks, comparisonTask{Text: normalizeComparisonText(strings.TrimSpace(strings.TrimPrefix(text, "☐"))), Checked: "false"}) + } + } + if paragraphRule { + result.ThematicRules++ + } + inParagraph = false + case "tc": + if cellAlignment == "" { + cellAlignment = "left" + } + row = append(row, cellAlignment) + inCell = false + case "tr": + table = append(table, row) + case "tbl": + result.TableAlignments = append(result.TableAlignments, table) + } + } + } + result.Paragraphs = append(result.Paragraphs, pendingCaption...) + if len(numbered) > 0 { + numbering, found := parts["word/numbering.xml"] + if !found { + return docxComparison{}, errors.New("word/numbering.xml missing") + } + definitions, instances, err := inspectDOCXNumbering(numbering) + if err != nil { + return docxComparison{}, err + } + for _, paragraph := range numbered { + instance, found := instances[paragraph.NumID] + if !found { + return docxComparison{}, fmt.Errorf("numbering instance %s missing", paragraph.NumID) + } + level := definitions[instance.AbstractID][paragraph.Level] + start := level.Start + if start == 0 { + start = 1 + } + if override, found := instance.Overrides[paragraph.Level]; found { + start = override + } + result.Numbering = append(result.Numbering, docxNumbering{Level: paragraph.Level, Format: level.Format, Start: start}) + } + } + for name, content := range parts { + lower := strings.ToLower(name) + if strings.HasPrefix(lower, "word/header") && strings.HasSuffix(lower, ".xml") { + result.Headers++ + } + if strings.HasPrefix(lower, "word/footer") && strings.HasSuffix(lower, ".xml") { + result.Footers++ + } + if strings.HasSuffix(lower, ".rels") { + result.ExternalLinks += strings.Count(strings.ToLower(string(content)), `targetmode="external"`) + } + if strings.Contains(lower, "vba") || strings.Contains(lower, "activex") || strings.Contains(lower, "embeddings") { + result.ForbiddenObject = true + } + } + xmlText := string(document) + result.Semantics = map[string]bool{ + "bold": strings.Contains(xmlText, ""), "table": strings.Contains(xmlText, ""), + } + return result, nil +} + +type docxNumberingLevel struct { + Format string + Start int +} + +type docxNumberingInstance struct { + AbstractID string + Overrides map[int]int +} + +func inspectDOCXNumbering(raw []byte) (map[string]map[int]docxNumberingLevel, map[string]docxNumberingInstance, error) { + definitions := map[string]map[int]docxNumberingLevel{} + instances := map[string]docxNumberingInstance{} + decoder := xml.NewDecoder(bytes.NewReader(raw)) + currentAbstract, currentInstance := "", "" + currentLevel, currentOverride := -1, -1 + for { + token, err := decoder.Token() + if errors.Is(err, io.EOF) { + return definitions, instances, nil + } + if err != nil { + return nil, nil, err + } + switch value := token.(type) { + case xml.StartElement: + switch value.Name.Local { + case "abstractNum": + currentAbstract = xmlAttributeValue(value.Attr, "abstractNumId") + definitions[currentAbstract] = map[int]docxNumberingLevel{} + case "lvl": + if currentAbstract != "" { + currentLevel, _ = strconv.Atoi(xmlAttributeValue(value.Attr, "ilvl")) + } + case "start": + if currentAbstract != "" && currentLevel >= 0 { + level := definitions[currentAbstract][currentLevel] + level.Start, _ = strconv.Atoi(xmlAttributeValue(value.Attr, "val")) + definitions[currentAbstract][currentLevel] = level + } + case "numFmt": + if currentAbstract != "" && currentLevel >= 0 { + level := definitions[currentAbstract][currentLevel] + level.Format = xmlAttributeValue(value.Attr, "val") + definitions[currentAbstract][currentLevel] = level + } + case "num": + currentInstance = xmlAttributeValue(value.Attr, "numId") + instances[currentInstance] = docxNumberingInstance{Overrides: map[int]int{}} + case "abstractNumId": + if currentInstance != "" { + instance := instances[currentInstance] + instance.AbstractID = xmlAttributeValue(value.Attr, "val") + instances[currentInstance] = instance + } + case "lvlOverride": + if currentInstance != "" { + currentOverride, _ = strconv.Atoi(xmlAttributeValue(value.Attr, "ilvl")) + } + case "startOverride": + if currentInstance != "" && currentOverride >= 0 { + instance := instances[currentInstance] + instance.Overrides[currentOverride], _ = strconv.Atoi(xmlAttributeValue(value.Attr, "val")) + instances[currentInstance] = instance + } + } + case xml.EndElement: + switch value.Name.Local { + case "lvl": + currentLevel = -1 + case "abstractNum": + currentAbstract = "" + case "lvlOverride": + currentOverride = -1 + case "num": + currentInstance = "" + } + } + } +} + +func xmlAttributeValue(attributes []xml.Attr, name string) string { + for _, attribute := range attributes { + if attribute.Name.Local == name { + return attribute.Value + } + } + return "" +} + +func copyAttributes(target map[string]string, attributes []xml.Attr, names ...string) { + wanted := make(map[string]bool, len(names)) + for _, name := range names { + wanted[name] = true + } + for _, attribute := range attributes { + if wanted[attribute.Name.Local] { + target[attribute.Name.Local] = attribute.Value + } + } +} + +func equalJSON(left, right any) bool { return bytes.Equal(canonicalJSON(left), canonicalJSON(right)) } + +func digestJSON(value any) string { return digestBytes(canonicalJSON(value)) } + +func canonicalJSON(value any) []byte { + raw, _ := json.Marshal(value) + return raw +} + +func digestBytes(value []byte) string { + sum := sha256.Sum256(value) + return hex.EncodeToString(sum[:]) +} diff --git a/internal/docgen/docgen.go b/internal/docgen/docgen.go index 47266ed..af83084 100644 --- a/internal/docgen/docgen.go +++ b/internal/docgen/docgen.go @@ -18,8 +18,10 @@ import ( "strings" ) -// Build 生成 docx 字节。title 为文档标题;为空时取首个标题块,再无则不带 Title 段。 -func Build(title, markdown string) ([]byte, error) { +// legacyBuild is retained only for an explicitly enabled migration comparison. +// Production callers must use Generate and never fall back here after a shared +// renderer failure. +func legacyBuild(title, markdown string) ([]byte, error) { doc := parseMarkdown(markdown) title = strings.TrimSpace(title) if title == "" { diff --git a/internal/docgen/docgen_test.go b/internal/docgen/docgen_test.go index af0736d..67dae8d 100644 --- a/internal/docgen/docgen_test.go +++ b/internal/docgen/docgen_test.go @@ -9,42 +9,47 @@ import ( "testing" ) -// 样例正文:会议纪要形态(标题/表格/列表/任务项/引用/链接/删除线/分隔线)。 +// 样例正文:会议纪要形态,也是 legacy/shared 唯一完整语义奇偶性夹具。 // 行内代码用 \x60(反引号)拼接——raw string 里不能直接写字面反引号。 const sampleMinutes = "# 研发周会纪要\n" + ` ## 一、基本信息 -| 项目 | 内容 | -|---|---| -| 时间 | 2026-09-30 14:00 | -| 地点 | 3F 会议室 | -| 主持人 | 陈威 | +正文含 **粗体**、*强调*、~~删除~~、` + "\x60inline\x60" + ` 和 [安全标签](https://example.com/private-target)。 ## 二、讨论要点 -- **架构**:docx 生成走后端转换,见 [方案](https://example.com/plan) -- 排版要求:标题层级、表格、列表 - - 二级要点:交付物卡片 +- **架构**:共享渲染器 +- 排版要求 + 1. 有序子项 + - 深层无序子项 - [x] 已完成:预览装配 - [ ] 待办:样式打磨 > 会议结论:按周五节点交付。 +` + "\x60\x60\x60go" + ` +fmt.Println("backtick") +` + "\x60\x60\x60" + ` + +~~~go +fmt.Println("tilde") +~~~~ + ## 三、决议 -1. 采用 write_docx 工具 -2. 对话内出交付物卡片 - -` + "\x60write_docx\x60 与 ~~write_artifact~~ 的分工见上文。" + ` +3. 第三项 +4. 第四项 --- -后续:周报合并。 +| 左 | 中 | 右 | 默认 | +| :--- | :---: | ---: | --- | +| L | C | R | D | ` func buildSample(t *testing.T) []byte { t.Helper() - b, err := Build("", sampleMinutes) + b, err := legacyBuild("", sampleMinutes) if err != nil { t.Fatalf("Build: %v", err) } @@ -166,13 +171,13 @@ func TestBuildHyperlinkRels(t *testing.T) { rc.Close() } } - if !strings.Contains(rels, `Id="rIdH1"`) || !strings.Contains(rels, "https://example.com/plan") { + if !strings.Contains(rels, `Id="rIdH1"`) || !strings.Contains(rels, "https://example.com/private-target") { t.Errorf("超链接 relationship 缺失: %s", rels) } } func TestBuildEscapesXML(t *testing.T) { - b, err := Build("标题&'", "包含 <标签> & \"引号\" 的正文") + b, err := legacyBuild("标题&'", "包含 <标签> & \"引号\" 的正文") if err != nil { t.Fatalf("Build: %v", err) } @@ -202,7 +207,7 @@ func TestBuildEscapesXML(t *testing.T) { func TestBuildPlainTextFallback(t *testing.T) { // 无任何 Markdown 结构的纯文本:按段落输出,不 panic、产物良构。 - b, err := Build("", "第一行\n第二行\n\n第二段") + b, err := legacyBuild("", "第一行\n第二行\n\n第二段") if err != nil { t.Fatalf("Build: %v", err) } diff --git a/internal/docgen/internal/genrendereridentity/main.go b/internal/docgen/internal/genrendereridentity/main.go new file mode 100644 index 0000000..adb60ea --- /dev/null +++ b/internal/docgen/internal/genrendereridentity/main.go @@ -0,0 +1,39 @@ +package main + +import ( + "bytes" + "flag" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +func main() { + check := flag.Bool("check", false, "fail when the generated identity differs from the checked-out renderer") + flag.Parse() + repository := gitOutput("rev-parse", "--show-toplevel") + revision := gitOutput("-C", filepath.Join(repository, "deps", "agent-document-renderer"), "rev-parse", "HEAD") + content := []byte(fmt.Sprintf("// Code generated from deps/agent-document-renderer; DO NOT EDIT.\n\npackage docgen\n\nconst rendererRevision = %q\n\nconst RendererIdentity = \"agent-document-renderer@\" + rendererRevision\n", revision)) + target := filepath.Join(repository, "internal", "docgen", "renderer_identity_generated.go") + if *check { + current, err := os.ReadFile(target) + if err != nil || !bytes.Equal(current, content) { + fmt.Fprintf(os.Stderr, "renderer identity is stale for %s\n", revision) + os.Exit(1) + } + return + } + if err := os.WriteFile(target, content, 0o644); err != nil { + panic(err) + } +} + +func gitOutput(arguments ...string) string { + output, err := exec.Command("git", arguments...).Output() + if err != nil { + panic(err) + } + return strings.TrimSpace(string(output)) +} diff --git a/internal/docgen/operation.go b/internal/docgen/operation.go new file mode 100644 index 0000000..d14ec71 --- /dev/null +++ b/internal/docgen/operation.go @@ -0,0 +1,40 @@ +package docgen + +import ( + "time" + + "agent-document-renderer/render" +) + +type OperationRequest struct { + OperationID string `json:"operationId"` + DocumentID string `json:"documentId"` + Title string `json:"name"` + DocumentType string `json:"documentType,omitempty"` + Language string `json:"language,omitempty"` + Markdown []byte `json:"-"` + Mode render.Mode `json:"mode"` + Controlled bool `json:"controlled"` + ProjectDocument bool `json:"projectDocument"` +} + +type ReceiptArtifact struct { + Role string `json:"role"` + ID string `json:"id"` + Name string `json:"name"` + MIME string `json:"mime"` + Size int64 `json:"size"` + SHA256 string `json:"sha256"` +} + +type OperationReceipt struct { + OperationID string `json:"operationId"` + Outcome string `json:"outcome"` + State string `json:"state"` + FrozenAt time.Time `json:"frozenAt"` + DeliveryKey string `json:"deliveryKey,omitempty"` + Renderer string `json:"rendererIdentity"` + CacheHit bool `json:"cacheHit,omitempty"` + Artifacts []ReceiptArtifact `json:"artifacts,omitempty"` + Error string `json:"error,omitempty"` +} diff --git a/internal/docgen/renderer.go b/internal/docgen/renderer.go new file mode 100644 index 0000000..f60a435 --- /dev/null +++ b/internal/docgen/renderer.go @@ -0,0 +1,579 @@ +package docgen + +import ( + "archive/zip" + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "slices" + "sort" + "strings" + "time" + + markdownrenderer "agent-document-renderer/markdown" + "agent-document-renderer/model" + "agent-document-renderer/profile" + "agent-document-renderer/profiles" + "agent-document-renderer/render" + "agent-document-renderer/word" +) + +const ( + WorkspaceProjectID = "workspace" + ManifestSchemaVersion = "backend-document-delivery/v1" + AdapterIdentity = RendererIdentity + "/word-adapter-v1" + defaultProfileID = "company.default" + defaultRenderCapacity = 4 +) + +var renderCapacity = make(chan struct{}, defaultRenderCapacity) + +var ( + ErrRDMRequired = errors.New("docgen: controlled or project document requires RDM") + ErrProfileMismatch = errors.New("docgen: organization-default profile mismatch") + ErrResourceLimit = errors.New("docgen: resource limit exceeded") +) + +type Artifact struct { + Name string + MIME string + SHA256 string + Bytes []byte +} + +type Request struct { + DocumentID string + Title string + DocumentType string + Language string + Markdown []byte + Mode render.Mode + FrozenAt time.Time + RequestedBy string + ProfileID string + ProfileVersion string + Controlled bool + ProjectDocument bool + LegacySource []byte + Limits Limits +} + +type Limits struct { + MaxMarkdownBytes int + MaxNodes int + MaxDepth int +} + +type DiagnosticError struct { + Stage string + Code string + Diagnostics []render.Diagnostic + Err error +} + +func (failure *DiagnosticError) Error() string { + if failure == nil { + return "docgen: diagnostic failure" + } + return fmt.Sprintf("docgen: %s: %s: %v", failure.Stage, failure.Code, failure.Err) +} + +func (failure *DiagnosticError) Unwrap() error { + if failure == nil { + return nil + } + return failure.Err +} + +type ComparisonDimension struct { + Name string `json:"name"` + Status string `json:"status"` + LegacyHash string `json:"legacyHash,omitempty"` + SharedHash string `json:"sharedHash,omitempty"` + ExceptionID string `json:"exceptionId,omitempty"` + Detail string `json:"detail,omitempty"` +} + +type ComparisonReceipt struct { + Enabled bool `json:"enabled"` + Assessment string `json:"assessment,omitempty"` + LegacySHA256 string `json:"legacySha256,omitempty"` + SharedSHA256 string `json:"sharedSha256,omitempty"` + Dimensions []ComparisonDimension `json:"dimensions,omitempty"` + IntentionalDifferences []string `json:"intentionalDifferences,omitempty"` + AccidentalDifferences []string `json:"accidentalDifferences,omitempty"` + Unassessed []string `json:"unassessed,omitempty"` +} + +type RenderOptions struct { + MigrationComparison bool +} + +type ManifestArtifact struct { + Name string `json:"name"` + MIME string `json:"mime"` + Size int `json:"size"` + SHA256 string `json:"sha256"` +} + +type ProfileReceipt struct { + ProfileID string `json:"profileId"` + Version string `json:"version"` + SnapshotHash string `json:"snapshotHash"` + Candidate bool `json:"candidate"` + ReviewStatus string `json:"reviewStatus"` + PublicationAllowed bool `json:"publicationAllowed"` + Unverified []string `json:"unverified"` + ProfileSHA256 string `json:"profileSha256"` + AssetManifestSHA256 string `json:"assetManifestSha256"` + ReviewSHA256 string `json:"reviewSha256"` +} + +type Manifest struct { + SchemaVersion string `json:"schemaVersion"` + RendererIdentity string `json:"rendererIdentity"` + AdapterIdentity string `json:"adapterIdentity"` + SelectionSource string `json:"selectionSource"` + Profile ProfileReceipt `json:"profile"` + DocumentRootID string `json:"documentRootId"` + Artifacts []ManifestArtifact `json:"artifacts"` + Renderer render.Manifest `json:"renderer"` + Diagnostics []render.Diagnostic `json:"diagnostics,omitempty"` + NodeAnchors map[string]string `json:"nodeAnchors"` + Comparison ComparisonReceipt `json:"comparison"` +} + +type Delivery struct { + Document model.CanonicalDocument + Profile ProfileReceipt + Renderer render.Manifest + Markdown Artifact + DOCX Artifact + Manifest Artifact + Archive Artifact + Comparison ComparisonReceipt +} + +type PreparedDocument struct { + Document model.CanonicalDocument + Markdown Artifact + CanonicalHash string +} + +// Prepare canonicalizes and validates authoritative Markdown without loading a +// profile or invoking the Word adapter. Durable callers persist its Markdown +// artifact before any rendering work. +func Prepare(ctx context.Context, request Request) (PreparedDocument, error) { + if err := contextErr(ctx); err != nil { + return PreparedDocument{}, err + } + if request.Controlled || request.ProjectDocument || request.Mode == render.ModeReview || request.Mode == render.ModeApproved { + return PreparedDocument{}, ErrRDMRequired + } + if request.Mode != render.ModeWorking && request.Mode != render.ModePreview { + return PreparedDocument{}, fmt.Errorf("docgen: unsupported ordinary workspace mode %q", request.Mode) + } + limits, err := normalizeLimits(request.Limits) + if err != nil { + return PreparedDocument{}, err + } + if len(request.Markdown) > limits.MaxMarkdownBytes { + return PreparedDocument{}, fmt.Errorf("%w: Markdown is %d bytes, limit is %d", ErrResourceLimit, len(request.Markdown), limits.MaxMarkdownBytes) + } + materialized := bytes.HasPrefix(bytes.TrimSpace(request.Markdown), []byte("---")) + var parsed markdownrenderer.Result + if materialized { + parsed, err = parseMarkdownContext(ctx, request.Markdown, false, limits) + if err != nil { + return PreparedDocument{}, classifyParseError(err) + } + if err := validateWorkspaceRoute(request, parsed.Metadata); err != nil { + return PreparedDocument{}, err + } + if _, err := profileVersionFor(request, &parsed.Metadata); err != nil { + return PreparedDocument{}, err + } + } else { + profileVersion, err := profileVersionFor(request, nil) + if err != nil { + return PreparedDocument{}, err + } + source, err := workspaceMarkdown(request, defaultProfileID, profileVersion) + if err != nil { + return PreparedDocument{}, err + } + if len(source) > limits.MaxMarkdownBytes { + return PreparedDocument{}, fmt.Errorf("%w: normalized Markdown source is %d bytes, limit is %d", ErrResourceLimit, len(source), limits.MaxMarkdownBytes) + } + parsed, err = parseMarkdownContext(ctx, source, true, limits) + if err != nil { + return PreparedDocument{}, classifyParseError(err) + } + } + if len(parsed.Markdown) > limits.MaxMarkdownBytes { + return PreparedDocument{}, fmt.Errorf("%w: materialized Markdown is %d bytes, limit is %d", ErrResourceLimit, len(parsed.Markdown), limits.MaxMarkdownBytes) + } + canonicalHash, err := parsed.Document.HashContext(ctx) + if err != nil { + return PreparedDocument{}, err + } + return PreparedDocument{ + Document: parsed.Document, CanonicalHash: canonicalHash, + Markdown: artifact(safeBaseName(request.Title)+".md", "text/markdown; charset=utf-8", parsed.Markdown), + }, nil +} + +type generatorDependencies struct { + loadProfile func(context.Context, string) (profiles.Bundle, error) + newRenderer func(*profile.AssetReader) render.Renderer + legacyBuild func(string, string) ([]byte, error) +} + +func defaultGeneratorDependencies() generatorDependencies { + return generatorDependencies{ + loadProfile: profiles.LoadCompanyDefaultVersionContext, + newRenderer: func(assets *profile.AssetReader) render.Renderer { return word.NewAdapter(assets) }, + legacyBuild: legacyBuild, + } +} + +func Generate(ctx context.Context, request Request) (Delivery, error) { + return generate(ctx, request, defaultGeneratorDependencies()) +} + +func generate(ctx context.Context, request Request, dependencies generatorDependencies) (Delivery, error) { + return generateWithOptions(ctx, request, RenderOptions{}, dependencies) +} + +func GenerateWithOptions(ctx context.Context, request Request, options RenderOptions) (Delivery, error) { + return generateWithOptions(ctx, request, options, defaultGeneratorDependencies()) +} + +func generateWithOptions(ctx context.Context, request Request, options RenderOptions, dependencies generatorDependencies) (Delivery, error) { + if err := contextErr(ctx); err != nil { + return Delivery{}, err + } + if request.Controlled || request.ProjectDocument || request.Mode == render.ModeReview || request.Mode == render.ModeApproved { + return Delivery{}, ErrRDMRequired + } + if request.Mode != render.ModeWorking && request.Mode != render.ModePreview { + return Delivery{}, fmt.Errorf("docgen: unsupported ordinary workspace mode %q", request.Mode) + } + limits, err := normalizeLimits(request.Limits) + if err != nil { + return Delivery{}, err + } + if len(request.Markdown) > limits.MaxMarkdownBytes { + return Delivery{}, fmt.Errorf("%w: Markdown is %d bytes, limit is %d", ErrResourceLimit, len(request.Markdown), limits.MaxMarkdownBytes) + } + var parsed markdownrenderer.Result + var boundMetadata *markdownrenderer.Metadata + materialized := bytes.HasPrefix(bytes.TrimSpace(request.Markdown), []byte("---")) + if materialized { + parsed, err = parseMarkdownContext(ctx, request.Markdown, false, limits) + if err != nil { + return Delivery{}, classifyParseError(err) + } + if err := validateWorkspaceRoute(request, parsed.Metadata); err != nil { + return Delivery{}, err + } + boundMetadata = &parsed.Metadata + } + boundVersion, err := profileVersionFor(request, boundMetadata) + if err != nil { + return Delivery{}, err + } + bundle, err := dependencies.loadProfile(ctx, boundVersion) + if err != nil { + return Delivery{}, fmt.Errorf("docgen: load company default %s: %w", boundVersion, err) + } + profileID, profileVersion := bundle.Snapshot.ProfileIdentity() + if profileID != defaultProfileID || profileVersion != boundVersion { + return Delivery{}, fmt.Errorf("%w: loaded %s@%s, bound %s@%s", ErrProfileMismatch, + profileID, profileVersion, defaultProfileID, boundVersion) + } + if !materialized { + source, sourceErr := workspaceMarkdown(request, profileID, profileVersion) + if sourceErr != nil { + return Delivery{}, sourceErr + } + if len(source) > limits.MaxMarkdownBytes { + return Delivery{}, fmt.Errorf("%w: normalized Markdown source is %d bytes, limit is %d", ErrResourceLimit, len(source), limits.MaxMarkdownBytes) + } + parsed, err = parseMarkdownContext(ctx, source, true, limits) + if err != nil { + return Delivery{}, classifyParseError(err) + } + } + if len(parsed.Markdown) > limits.MaxMarkdownBytes { + return Delivery{}, fmt.Errorf("%w: materialized Markdown is %d bytes, limit is %d", ErrResourceLimit, len(parsed.Markdown), limits.MaxMarkdownBytes) + } + if err := validateWorkspaceMetadata(request, parsed.Metadata, profileID, profileVersion); err != nil { + return Delivery{}, err + } + if err := contextErr(ctx); err != nil { + return Delivery{}, err + } + if err := acquireRenderCapacity(ctx); err != nil { + return Delivery{}, err + } + result, err := dependencies.newRenderer(bundle.Assets).Render(ctx, render.Request{ + Document: parsed.Document, + Profile: bundle.Snapshot, + Mode: request.Mode, + FrozenAt: request.FrozenAt, + RequestedBy: request.RequestedBy, + }) + releaseRenderCapacity() + if err != nil { + code := "renderer_failed" + if strings.Contains(err.Error(), "asset") || strings.Contains(err.Error(), "hash") { + code = "asset_mismatch" + } + return Delivery{}, diagnosticFailure("render", code, err, result.Diagnostics) + } + base := safeBaseName(request.Title) + delivery := Delivery{ + Document: parsed.Document, Renderer: result.Manifest, + Profile: ProfileReceipt{ + ProfileID: profileID, Version: profileVersion, SnapshotHash: bundle.Snapshot.SnapshotHash(), + Candidate: bundle.Snapshot.State() != profile.StatePublished, ReviewStatus: bundle.Review.Status, PublicationAllowed: bundle.Review.PublicationAllowed, + Unverified: append([]string(nil), bundle.Review.Unverified...), ProfileSHA256: bundle.Review.ProfileSHA256, + AssetManifestSHA256: bundle.Review.AssetManifestSHA256, ReviewSHA256: bundle.Review.ReviewSHA256, + }, + Markdown: artifact(base+".md", "text/markdown; charset=utf-8", parsed.Markdown), + DOCX: artifact(base+".docx", result.MIME, result.Artifact), + } + if options.MigrationComparison { + comparisonSource := request.LegacySource + if len(comparisonSource) == 0 { + comparisonSource = request.Markdown + } + legacy, err := dependencies.legacyBuild(request.Title, string(comparisonSource)) + if err != nil { + return Delivery{}, fmt.Errorf("docgen: legacy migration comparison: %w", err) + } + delivery.Comparison = compareLegacyAndShared(request.Title, comparisonSource, parsed.Document, legacy, delivery.DOCX.Bytes) + } + manifest := Manifest{ + SchemaVersion: ManifestSchemaVersion, RendererIdentity: RendererIdentity, AdapterIdentity: AdapterIdentity, + SelectionSource: "organization-default", + Profile: delivery.Profile, + DocumentRootID: parsed.Document.Root.ID, + Artifacts: []ManifestArtifact{ + manifestArtifact(delivery.Markdown), manifestArtifact(delivery.DOCX), + }, + Renderer: result.Manifest, Diagnostics: append([]render.Diagnostic(nil), result.Diagnostics...), + NodeAnchors: cloneStringMap(result.NodeAnchors), Comparison: delivery.Comparison, + } + manifestRaw, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return Delivery{}, fmt.Errorf("docgen: marshal manifest: %w", err) + } + manifestRaw = append(manifestRaw, '\n') + delivery.Manifest = artifact(base+".manifest.json", "application/json", manifestRaw) + archiveRaw, err := buildArchive(delivery.Markdown, delivery.DOCX, delivery.Manifest) + if err != nil { + return Delivery{}, err + } + delivery.Archive = artifact(base+".zip", "application/zip", archiveRaw) + return delivery, nil +} + +func acquireRenderCapacity(ctx context.Context) error { + select { + case <-ctx.Done(): + return ctx.Err() + case renderCapacity <- struct{}{}: + return nil + } +} + +func releaseRenderCapacity() { <-renderCapacity } + +func parseMarkdownContext(ctx context.Context, source []byte, firstImport bool, limits Limits) (markdownrenderer.Result, error) { + return markdownrenderer.ParseContext(ctx, source, markdownrenderer.Options{ + Controlled: true, FirstImport: firstImport, MaxBytes: limits.MaxMarkdownBytes, + MaxNodes: limits.MaxNodes, MaxDepth: limits.MaxDepth, + }) +} + +func classifyParseError(err error) error { + if errors.Is(err, markdownrenderer.ErrDocumentLimit) { + return fmt.Errorf("%w: %v", ErrResourceLimit, err) + } + code := "markdown_invalid" + switch { + case errors.Is(err, markdownrenderer.ErrUnknownMetadata): + code = "unknown_metadata" + case errors.Is(err, markdownrenderer.ErrHiddenHTML): + code = "unsafe_markdown" + case errors.Is(err, markdownrenderer.ErrUnsafeDirective) && strings.Contains(err.Error(), "unknown directive"): + code = "unknown_directive" + case errors.Is(err, markdownrenderer.ErrUnsafeDirective): + code = "unsafe_markdown" + } + return diagnosticFailure("parse", code, err, nil) +} + +func diagnosticFailure(stage, code string, err error, diagnostics []render.Diagnostic) error { + cloned := append([]render.Diagnostic(nil), diagnostics...) + if len(cloned) == 0 { + cloned = []render.Diagnostic{{Code: code, Severity: render.SeverityError, Message: err.Error()}} + } + return &DiagnosticError{Stage: stage, Code: code, Diagnostics: cloned, Err: err} +} + +func normalizeLimits(input Limits) (Limits, error) { + if input.MaxMarkdownBytes < 0 || input.MaxNodes < 0 || input.MaxDepth < 0 { + return Limits{}, fmt.Errorf("%w: limits cannot be negative", ErrResourceLimit) + } + if input.MaxMarkdownBytes == 0 { + input.MaxMarkdownBytes = 2 << 20 + } + if input.MaxNodes == 0 { + input.MaxNodes = 10_000 + } + if input.MaxDepth == 0 { + input.MaxDepth = 64 + } + return input, nil +} + +func workspaceMarkdown(request Request, profileID, profileVersion string) ([]byte, error) { + if strings.TrimSpace(request.DocumentID) == "" || strings.TrimSpace(request.Title) == "" || + strings.TrimSpace(request.DocumentType) == "" || strings.TrimSpace(request.Language) == "" { + return nil, errors.New("docgen: document ID, title, document type, and language are required") + } + if request.Mode == "" || request.FrozenAt.IsZero() || strings.TrimSpace(request.RequestedBy) == "" { + return nil, errors.New("docgen: mode, frozen time, and requester are required") + } + quote := func(value string) string { + raw, _ := json.Marshal(value) + return string(raw) + } + var header strings.Builder + header.WriteString("---\n") + fmt.Fprintf(&header, "documentId: %s\n", quote(request.DocumentID)) + fmt.Fprintf(&header, "projectId: %s\n", quote(WorkspaceProjectID)) + fmt.Fprintf(&header, "documentType: %s\n", quote(request.DocumentType)) + fmt.Fprintf(&header, "title: %s\n", quote(request.Title)) + fmt.Fprintf(&header, "language: %s\n", quote(request.Language)) + fmt.Fprintf(&header, "profileId: %s\n", quote(profileID)) + fmt.Fprintf(&header, "profileVersion: %s\n", quote(profileVersion)) + header.WriteString("controlled: false\n---\n\n") + header.Write(bytes.TrimSpace(request.Markdown)) + header.WriteByte('\n') + return []byte(header.String()), nil +} + +func validateWorkspaceRoute(request Request, metadata markdownrenderer.Metadata) error { + if metadata.Controlled || metadata.ProjectID != WorkspaceProjectID { + return ErrRDMRequired + } + if metadata.DocumentID != request.DocumentID || metadata.DocumentType != request.DocumentType || + metadata.Title != request.Title || metadata.Language != request.Language { + return errors.New("docgen: persisted Markdown metadata does not match the frozen request") + } + return nil +} + +// profileVersionFor returns the company-default version a document renders +// with. Published versions are immutable, so a version the persisted Markdown +// or the request already binds is kept; only a new, unbound document takes the +// latest published version. metadata is nil until Markdown is materialized. +func profileVersionFor(request Request, metadata *markdownrenderer.Metadata) (string, error) { + requested := request.ProfileID != "" || request.ProfileVersion != "" + if requested && !publishedProfileVersion(request.ProfileID, request.ProfileVersion) { + return "", fmt.Errorf("%w: requested %s@%s is not a published %s version", ErrProfileMismatch, + request.ProfileID, request.ProfileVersion, defaultProfileID) + } + if metadata == nil { + if requested { + return request.ProfileVersion, nil + } + return profiles.LatestCompanyDefaultVersion, nil + } + if !publishedProfileVersion(metadata.ProfileID, metadata.ProfileVersion) { + return "", fmt.Errorf("%w: Markdown binds %s@%s, which is not a published %s version", ErrProfileMismatch, + metadata.ProfileID, metadata.ProfileVersion, defaultProfileID) + } + if requested && request.ProfileVersion != metadata.ProfileVersion { + return "", fmt.Errorf("%w: requested %s@%s, Markdown binds %s@%s", ErrProfileMismatch, + request.ProfileID, request.ProfileVersion, metadata.ProfileID, metadata.ProfileVersion) + } + return metadata.ProfileVersion, nil +} + +func publishedProfileVersion(profileID, profileVersion string) bool { + return profileID == defaultProfileID && slices.Contains(profiles.CompanyDefaultVersions(), profileVersion) +} + +func validateWorkspaceMetadata(request Request, metadata markdownrenderer.Metadata, profileID, profileVersion string) error { + if err := validateWorkspaceRoute(request, metadata); err != nil { + return err + } + if metadata.ProfileID != profileID || metadata.ProfileVersion != profileVersion { + return fmt.Errorf("%w: Markdown binds %s@%s, available %s@%s", ErrProfileMismatch, + metadata.ProfileID, metadata.ProfileVersion, profileID, profileVersion) + } + return nil +} + +func artifact(name, mime string, raw []byte) Artifact { + sum := sha256.Sum256(raw) + // Callers transfer ownership of freshly produced buffers to the delivery. + return Artifact{Name: name, MIME: mime, SHA256: hex.EncodeToString(sum[:]), Bytes: raw} +} + +func manifestArtifact(value Artifact) ManifestArtifact { + return ManifestArtifact{Name: value.Name, MIME: value.MIME, Size: len(value.Bytes), SHA256: value.SHA256} +} + +func buildArchive(artifacts ...Artifact) ([]byte, error) { + items := append([]Artifact(nil), artifacts...) + sort.Slice(items, func(i, j int) bool { return items[i].Name < items[j].Name }) + var buffer bytes.Buffer + archive := zip.NewWriter(&buffer) + for _, item := range items { + header := &zip.FileHeader{Name: item.Name, Method: zip.Deflate} + header.SetModTime(time.Date(1980, 1, 1, 0, 0, 0, 0, time.UTC)) + header.SetMode(0o644) + writer, err := archive.CreateHeader(header) + if err != nil { + return nil, fmt.Errorf("docgen: create archive entry %s: %w", item.Name, err) + } + if _, err := writer.Write(item.Bytes); err != nil { + return nil, fmt.Errorf("docgen: write archive entry %s: %w", item.Name, err) + } + } + if err := archive.Close(); err != nil { + return nil, fmt.Errorf("docgen: close delivery archive: %w", err) + } + return buffer.Bytes(), nil +} + +func cloneStringMap(source map[string]string) map[string]string { + clone := make(map[string]string, len(source)) + for key, value := range source { + clone[key] = value + } + return clone +} + +func safeBaseName(title string) string { + return strings.TrimSuffix(SuggestName(title), ".docx") +} + +func contextErr(ctx context.Context) error { + select { + case <-ctx.Done(): + return fmt.Errorf("docgen: render canceled: %w", ctx.Err()) + default: + return nil + } +} diff --git a/internal/docgen/renderer_identity.go b/internal/docgen/renderer_identity.go new file mode 100644 index 0000000..ef9c483 --- /dev/null +++ b/internal/docgen/renderer_identity.go @@ -0,0 +1,3 @@ +package docgen + +//go:generate go run ./internal/genrendereridentity diff --git a/internal/docgen/renderer_identity_generated.go b/internal/docgen/renderer_identity_generated.go new file mode 100644 index 0000000..14aad20 --- /dev/null +++ b/internal/docgen/renderer_identity_generated.go @@ -0,0 +1,7 @@ +// Code generated from deps/agent-document-renderer; DO NOT EDIT. + +package docgen + +const rendererRevision = "3f259990b5d43ec0d318f39d496816140ab559b5" + +const RendererIdentity = "agent-document-renderer@" + rendererRevision diff --git a/internal/docgen/renderer_integration_test.go b/internal/docgen/renderer_integration_test.go new file mode 100644 index 0000000..ca919b4 --- /dev/null +++ b/internal/docgen/renderer_integration_test.go @@ -0,0 +1,1075 @@ +package docgen + +import ( + "archive/zip" + "bytes" + "context" + "encoding/json" + "encoding/xml" + "errors" + "io" + "os/exec" + "path/filepath" + "reflect" + "strings" + "sync/atomic" + "testing" + "time" + + markdownrenderer "agent-document-renderer/markdown" + "agent-document-renderer/model" + "agent-document-renderer/profile" + "agent-document-renderer/profiles" + "agent-document-renderer/render" +) + +const representativeMarkdown = `# 研发周会纪要 + +这是普通工作区交付物的正文。 + +## 要点 + +- 统一解析入口 +- 保留 Markdown + +| 项目 | 状态 | +|---|---| +| 解析器 | 统一 | + +![公司默认样图](asset:company.default.sample-image@1.0.0) + +:::callout warning +公司默认规则只用于普通工作区交付。 +::: + +:::page-break + +分页后的结论。 +` + +func workspaceRequest(markdown []byte) Request { + return Request{ + DocumentID: "workspace-minutes", + Title: "研发周会纪要", + DocumentType: "general", + Language: "zh-CN", + Markdown: markdown, + Mode: render.ModePreview, + FrozenAt: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC), + RequestedBy: "alice", + } +} + +func TestGenerateProducesDeterministicHashedDeliveryPackage(t *testing.T) { + request := workspaceRequest([]byte(representativeMarkdown)) + first, err := Generate(context.Background(), request) + if err != nil { + t.Fatalf("first Generate: %v", err) + } + second, err := Generate(context.Background(), request) + if err != nil { + t.Fatalf("second Generate: %v", err) + } + for _, pair := range []struct { + name string + a Artifact + b Artifact + }{ + {"Markdown", first.Markdown, second.Markdown}, + {"DOCX", first.DOCX, second.DOCX}, + {"manifest", first.Manifest, second.Manifest}, + {"archive", first.Archive, second.Archive}, + } { + if pair.a.SHA256 == "" || pair.a.SHA256 != pair.b.SHA256 || !bytes.Equal(pair.a.Bytes, pair.b.Bytes) { + t.Errorf("%s is not byte deterministic: first=%s second=%s", pair.name, pair.a.SHA256, pair.b.SHA256) + } + } + + var manifest Manifest + if err := json.Unmarshal(first.Manifest.Bytes, &manifest); err != nil { + t.Fatalf("decode manifest: %v", err) + } + bundle, err := profiles.LoadCompanyDefault() + if err != nil { + t.Fatalf("load renderer company default: %v", err) + } + if manifest.SchemaVersion != ManifestSchemaVersion || manifest.RendererIdentity != RendererIdentity || + manifest.SelectionSource != "organization-default" || manifest.Profile.Candidate || + manifest.Profile.ReviewStatus != bundle.Review.Status || manifest.Profile.PublicationAllowed != bundle.Review.PublicationAllowed { + t.Fatalf("manifest profile provenance = %#v, renderer review = %#v", manifest, bundle.Review) + } + if manifest.Profile.ReviewStatus != "approved" || !manifest.Profile.PublicationAllowed { + t.Fatalf("published organization default reported review=%q publicationAllowed=%v", manifest.Profile.ReviewStatus, manifest.Profile.PublicationAllowed) + } + if manifest.Profile.ProfileSHA256 == "" || manifest.Profile.AssetManifestSHA256 == "" || manifest.Profile.ReviewSHA256 == "" { + t.Fatalf("manifest omitted source hashes: %#v", manifest.Profile) + } + listed := map[string]string{} + for _, item := range manifest.Artifacts { + listed[item.Name] = item.SHA256 + } + if listed[first.Markdown.Name] != first.Markdown.SHA256 || listed[first.DOCX.Name] != first.DOCX.SHA256 { + t.Fatalf("manifest artifact hashes = %#v, want Markdown=%s DOCX=%s", listed, first.Markdown.SHA256, first.DOCX.SHA256) + } + if manifest.Renderer.ArtifactHash != first.DOCX.SHA256 || manifest.Renderer.FrozenAt != request.FrozenAt || manifest.Renderer.RequestedBy != request.RequestedBy { + t.Fatalf("renderer receipt does not match frozen request: %#v", manifest.Renderer) + } + + zr, err := zip.NewReader(bytes.NewReader(first.Archive.Bytes), int64(len(first.Archive.Bytes))) + if err != nil { + t.Fatalf("open delivery archive: %v", err) + } + archiveFiles := map[string][]byte{} + for _, file := range zr.File { + stream, err := file.Open() + if err != nil { + t.Fatal(err) + } + raw, err := io.ReadAll(stream) + stream.Close() + if err != nil { + t.Fatal(err) + } + archiveFiles[file.Name] = raw + } + for _, item := range []Artifact{first.Markdown, first.DOCX, first.Manifest} { + if !bytes.Equal(archiveFiles[item.Name], item.Bytes) { + t.Errorf("archive %s bytes do not match delivered artifact", item.Name) + } + } +} + +func TestGeneratePersistsCanonicalMarkdownWithStableNodeParity(t *testing.T) { + delivery, err := Generate(context.Background(), workspaceRequest([]byte(representativeMarkdown))) + if err != nil { + t.Fatalf("Generate: %v", err) + } + + reparsed, err := markdownrenderer.Parse(delivery.Markdown.Bytes, markdownrenderer.Options{ + FirstImport: false, + MaxBytes: 2 << 20, + MaxNodes: 10_000, + MaxDepth: 64, + }) + if err != nil { + t.Fatalf("persisted Markdown is not independently parseable: %v", err) + } + if !reflect.DeepEqual(reparsed.Document, delivery.Document) { + t.Fatalf("canonical nodes changed across the persisted Markdown entry\nfirst: %#v\nsecond: %#v", delivery.Document, reparsed.Document) + } + if reparsed.Metadata.DocumentID != "workspace-minutes" || reparsed.Metadata.ProjectID != WorkspaceProjectID || + reparsed.Metadata.DocumentType != "general" || reparsed.Metadata.Title != "研发周会纪要" || + reparsed.Metadata.Language != "zh-CN" || reparsed.Metadata.ProfileID != "company.default" || + reparsed.Metadata.ProfileVersion != profiles.LatestCompanyDefaultVersion || reparsed.Metadata.Controlled { + t.Fatalf("controlled metadata changed: %#v", reparsed.Metadata) + } + + wantKinds := map[model.NodeKind]int{ + model.NodeHeading: 2, + model.NodeParagraph: 2, + model.NodeList: 1, + model.NodeTable: 1, + model.NodeImage: 1, + model.NodeCallout: 1, + model.NodePageBreak: 1, + } + gotKinds := map[model.NodeKind]int{} + seenIDs := map[string]bool{} + var visit func(model.Node) + visit = func(node model.Node) { + if node.ID == "" || seenIDs[node.ID] { + t.Fatalf("node ID is missing or unstable: %q", node.ID) + } + seenIDs[node.ID] = true + gotKinds[node.Kind]++ + for _, child := range node.Children { + visit(child) + } + } + visit(delivery.Document.Root) + for kind, want := range wantKinds { + if got := gotKinds[kind]; got != want { + t.Errorf("%s nodes = %d, want %d", kind, got, want) + } + } + if !strings.Contains(string(delivery.Markdown.Bytes), "\n# Controlled\n") + request := workspaceRequest(source) + request.DocumentID = "controlled" + request.Title = "Controlled" + loads := 0 + deps := defaultGeneratorDependencies() + baseLoad := deps.loadProfile + deps.loadProfile = func(ctx context.Context, version string) (profiles.Bundle, error) { + loads++ + return baseLoad(ctx, version) + } + _, err := generate(context.Background(), request, deps) + if !errors.Is(err, ErrRDMRequired) || loads != 0 { + t.Fatalf("persisted controlled route err=%v profileLoads=%d, want ErrRDMRequired before load", err, loads) + } +} + +func TestRendererIdentityMatchesPublishedPin(t *testing.T) { + const want = "agent-document-renderer@3f259990b5d43ec0d318f39d496816140ab559b5" + if RendererIdentity != want { + t.Fatalf("RendererIdentity = %q, want %q", RendererIdentity, want) + } +} + +func TestRendererIdentityIsGeneratedFromCheckedOutComponent(t *testing.T) { + repository, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatal(err) + } + command := exec.Command("go", "run", "./internal/docgen/internal/genrendereridentity", "-check") + command.Dir = repository + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("renderer identity generation check: %v\n%s", err, output) + } +} + +func TestGenerateReusesPersistedMarkdownWithoutChangingNodeIdentity(t *testing.T) { + request := workspaceRequest([]byte(representativeMarkdown)) + first, err := Generate(context.Background(), request) + if err != nil { + t.Fatal(err) + } + request.Markdown = first.Markdown.Bytes + second, err := Generate(context.Background(), request) + if err != nil { + t.Fatalf("Generate persisted Markdown: %v", err) + } + if !reflect.DeepEqual(first.Document, second.Document) { + t.Fatalf("canonical node identities changed\nfirst: %#v\nsecond: %#v", first.Document, second.Document) + } + if !bytes.Equal(first.Markdown.Bytes, second.Markdown.Bytes) || !bytes.Equal(first.DOCX.Bytes, second.DOCX.Bytes) { + t.Fatal("identical persisted Markdown and frozen inputs changed delivery bytes") + } +} + +func TestGenerateRejectsUnknownPersistedMetadata(t *testing.T) { + request := workspaceRequest([]byte(representativeMarkdown)) + first, err := Generate(context.Background(), request) + if err != nil { + t.Fatal(err) + } + request.Markdown = bytes.Replace(first.Markdown.Bytes, []byte("controlled: false\n"), []byte("controlled: false\nscript: build.sh\n"), 1) + _, err = Generate(context.Background(), request) + var diagnostic *DiagnosticError + if !errors.As(err, &diagnostic) || diagnostic.Stage != "parse" || diagnostic.Code != "unknown_metadata" { + t.Fatalf("unknown metadata error = %#v (%v)", diagnostic, err) + } +} + +func TestGenerateRejectsControlledAndProjectRoutesBeforeDefaultFallback(t *testing.T) { + for _, mutate := range []func(*Request){ + func(request *Request) { request.Controlled = true }, + func(request *Request) { request.ProjectDocument = true }, + } { + request := workspaceRequest([]byte(representativeMarkdown)) + mutate(&request) + if _, err := Generate(context.Background(), request); !errors.Is(err, ErrRDMRequired) { + t.Fatalf("Generate route error = %v, want ErrRDMRequired", err) + } + } +} + +func TestGenerateRoutesReviewAndApprovalModesToRDM(t *testing.T) { + for _, mode := range []render.Mode{render.ModeReview, render.ModeApproved} { + request := workspaceRequest([]byte(representativeMarkdown)) + request.Mode = mode + if _, err := Generate(context.Background(), request); !errors.Is(err, ErrRDMRequired) { + t.Errorf("mode %s error = %v, want ErrRDMRequired", mode, err) + } + } +} + +func TestGenerateRejectsProfileAndAssetMismatch(t *testing.T) { + request := workspaceRequest([]byte(representativeMarkdown)) + request.ProfileID = "company.other" + request.ProfileVersion = "9.9.9" + if _, err := Generate(context.Background(), request); !errors.Is(err, ErrProfileMismatch) { + t.Fatalf("profile mismatch error = %v, want ErrProfileMismatch", err) + } + + request = workspaceRequest([]byte(strings.ReplaceAll(representativeMarkdown, + "asset:company.default.sample-image@1.0.0", "asset:undeclared.image@1.0.0"))) + if _, err := Generate(context.Background(), request); err == nil || !strings.Contains(err.Error(), "undeclared asset") { + t.Fatalf("asset mismatch error = %v, want an honest undeclared-asset diagnostic", err) + } +} + +const tocFieldInstruction = `TOC \o "1-` + +func TestGenerateBindsNewDocumentsToLatestPublishedProfile(t *testing.T) { + request := workspaceRequest([]byte(representativeMarkdown)) + prepared, err := Prepare(context.Background(), request) + if err != nil { + t.Fatalf("Prepare: %v", err) + } + delivery, err := Generate(context.Background(), request) + if err != nil { + t.Fatalf("Generate: %v", err) + } + latest, err := profiles.LoadCompanyDefault() + if err != nil { + t.Fatal(err) + } + if _, version := latest.Snapshot.ProfileIdentity(); version != profiles.LatestCompanyDefaultVersion { + t.Fatalf("renderer latest company default = %s, want %s", version, profiles.LatestCompanyDefaultVersion) + } + for name, markdown := range map[string][]byte{"Prepare": prepared.Markdown.Bytes, "Generate": delivery.Markdown.Bytes} { + metadata := persistedMetadata(t, markdown) + if metadata.ProfileID != "company.default" || metadata.ProfileVersion != profiles.LatestCompanyDefaultVersion { + t.Fatalf("%s bound new document to %s@%s, want company.default@%s", name, metadata.ProfileID, metadata.ProfileVersion, profiles.LatestCompanyDefaultVersion) + } + } + if delivery.Profile.Version != profiles.LatestCompanyDefaultVersion || delivery.Profile.SnapshotHash != latest.Snapshot.SnapshotHash() { + t.Fatalf("delivery profile receipt = %#v, want latest snapshot %s", delivery.Profile, latest.Snapshot.SnapshotHash()) + } + documentXML := docxPart(t, delivery.DOCX.Bytes, "word/document.xml") + if !strings.Contains(documentXML, tocFieldInstruction) || !strings.Contains(docxVisibleText(t, delivery.DOCX.Bytes), "目录") { + t.Fatal("new document rendered with the latest company default omitted its declared TOC") + } +} + +func TestGenerateKeepsPinnedPublishedProfileVersion(t *testing.T) { + for _, test := range []struct { + version string + toc bool + }{ + {version: "1.0.0", toc: false}, + {version: "1.1.0", toc: true}, + } { + t.Run(test.version, func(t *testing.T) { + pinned, err := profiles.LoadCompanyDefaultVersion(test.version) + if err != nil { + t.Fatal(err) + } + request := workspaceRequest([]byte(representativeMarkdown)) + request.ProfileID, request.ProfileVersion = "company.default", test.version + prepared, err := Prepare(context.Background(), request) + if err != nil { + t.Fatalf("Prepare pinned %s: %v", test.version, err) + } + if metadata := persistedMetadata(t, prepared.Markdown.Bytes); metadata.ProfileVersion != test.version { + t.Fatalf("Prepare bound pinned request to %s, want %s", metadata.ProfileVersion, test.version) + } + first, err := Generate(context.Background(), request) + if err != nil { + t.Fatalf("Generate pinned %s: %v", test.version, err) + } + + replay := workspaceRequest(first.Markdown.Bytes) + second, err := Generate(context.Background(), replay) + if err != nil { + t.Fatalf("Generate persisted %s Markdown: %v", test.version, err) + } + for name, delivery := range map[string]Delivery{"pinned request": first, "persisted replay": second} { + if delivery.Profile.ProfileID != "company.default" || delivery.Profile.Version != test.version || + delivery.Profile.SnapshotHash != pinned.Snapshot.SnapshotHash() || delivery.Profile.ProfileSHA256 != pinned.Review.ProfileSHA256 { + t.Fatalf("%s profile receipt = %#v, want company.default@%s snapshot %s", name, delivery.Profile, test.version, pinned.Snapshot.SnapshotHash()) + } + if metadata := persistedMetadata(t, delivery.Markdown.Bytes); metadata.ProfileVersion != test.version { + t.Fatalf("%s Markdown binds %s, want %s", name, metadata.ProfileVersion, test.version) + } + if got := strings.Contains(docxPart(t, delivery.DOCX.Bytes, "word/document.xml"), tocFieldInstruction); got != test.toc { + t.Fatalf("%s TOC present = %v, want %v for company.default@%s", name, got, test.toc, test.version) + } + } + if !bytes.Equal(first.DOCX.Bytes, second.DOCX.Bytes) { + t.Fatal("replaying persisted Markdown changed the pinned delivery bytes") + } + }) + } +} + +func TestProfileBindingRefusesUnpublishedVersionsBeforeLoad(t *testing.T) { + persisted, err := Generate(context.Background(), workspaceRequest([]byte(representativeMarkdown))) + if err != nil { + t.Fatal(err) + } + latestBinding := []byte("profileVersion: \"" + profiles.LatestCompanyDefaultVersion + "\"\n") + if !bytes.Contains(persisted.Markdown.Bytes, latestBinding) { + t.Fatalf("persisted Markdown does not carry %q", latestBinding) + } + pinnedOld := workspaceRequest(bytes.Replace(persisted.Markdown.Bytes, latestBinding, []byte("profileVersion: \"1.0.0\"\n"), 1)) + pinnedOld.ProfileID, pinnedOld.ProfileVersion = "company.default", profiles.LatestCompanyDefaultVersion + + tests := map[string]Request{} + for name, identity := range map[string][2]string{ + "unknown version": {"company.default", "9.9.9"}, + "other profile": {"company.other", "1.0.0"}, + "missing version": {"company.default", ""}, + "missing profile id": {"", "1.0.0"}, + } { + request := workspaceRequest([]byte(representativeMarkdown)) + request.ProfileID, request.ProfileVersion = identity[0], identity[1] + tests["requested "+name] = request + } + tests["persisted unknown version"] = workspaceRequest(bytes.Replace(persisted.Markdown.Bytes, latestBinding, []byte("profileVersion: \"9.9.9\"\n"), 1)) + tests["persisted other profile"] = workspaceRequest(bytes.Replace(persisted.Markdown.Bytes, + []byte("profileId: \"company.default\"\n"), []byte("profileId: \"company.other\"\n"), 1)) + tests["request disagrees with persisted binding"] = pinnedOld + + for name, request := range tests { + t.Run(name, func(t *testing.T) { + if _, err := Prepare(context.Background(), request); !errors.Is(err, ErrProfileMismatch) { + t.Fatalf("Prepare error = %v, want ErrProfileMismatch", err) + } + loads := 0 + deps := defaultGeneratorDependencies() + baseLoad := deps.loadProfile + deps.loadProfile = func(ctx context.Context, version string) (profiles.Bundle, error) { + loads++ + return baseLoad(ctx, version) + } + if _, err := generate(context.Background(), request, deps); !errors.Is(err, ErrProfileMismatch) || loads != 0 { + t.Fatalf("Generate error = %v profileLoads=%d, want ErrProfileMismatch before load", err, loads) + } + }) + } +} + +func TestGenerateRefusesLoadedProfileWithUnexpectedIdentity(t *testing.T) { + deps := defaultGeneratorDependencies() + deps.loadProfile = func(ctx context.Context, _ string) (profiles.Bundle, error) { + return profiles.LoadCompanyDefaultVersionContext(ctx, "1.0.0") + } + if _, err := generate(context.Background(), workspaceRequest([]byte(representativeMarkdown)), deps); !errors.Is(err, ErrProfileMismatch) { + t.Fatalf("Generate with a loader returning the wrong version error = %v, want ErrProfileMismatch", err) + } +} + +func persistedMetadata(t *testing.T, markdown []byte) markdownrenderer.Metadata { + t.Helper() + parsed, err := markdownrenderer.Parse(markdown, markdownrenderer.Options{MaxBytes: 2 << 20, MaxNodes: 10_000, MaxDepth: 64}) + if err != nil { + t.Fatalf("persisted Markdown is not parseable: %v", err) + } + return parsed.Metadata +} + +func TestGenerateEnforcesLimitsAndCancellation(t *testing.T) { + request := workspaceRequest([]byte(representativeMarkdown)) + request.Limits.MaxMarkdownBytes = 32 + if _, err := Generate(context.Background(), request); !errors.Is(err, ErrResourceLimit) { + t.Fatalf("limit error = %v, want ErrResourceLimit", err) + } + + canceled, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := Generate(canceled, workspaceRequest([]byte(representativeMarkdown))); !errors.Is(err, context.Canceled) { + t.Fatalf("cancellation error = %v, want context.Canceled", err) + } +} + +func TestGenerateReturnsTypedDiagnosticsForUnsafeMarkdownAndAssets(t *testing.T) { + tests := []struct { + name string + markdown string + stage string + code string + }{ + {"script", "# Report\n\n\n", "parse", "unsafe_markdown"}, + {"external asset", "# Report\n\n![remote](https://example.com/a.png)\n", "parse", "unsafe_markdown"}, + {"unknown directive", "# Report\n\n:::execute script=build.sh\n", "parse", "unknown_directive"}, + {"undeclared asset", "# Report\n\n![missing](asset:missing@1.0.0)\n", "render", "asset_mismatch"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + delivery, err := Generate(context.Background(), workspaceRequest([]byte(test.markdown))) + if err == nil { + t.Fatal("Generate succeeded for unsafe input") + } + var diagnostic *DiagnosticError + if !errors.As(err, &diagnostic) { + t.Fatalf("error %T %v is not a DiagnosticError", err, err) + } + if diagnostic.Stage != test.stage || diagnostic.Code != test.code || strings.TrimSpace(diagnostic.Error()) == "" { + t.Fatalf("diagnostic = %#v, want stage=%s code=%s", diagnostic, test.stage, test.code) + } + if len(delivery.Markdown.Bytes)+len(delivery.DOCX.Bytes)+len(delivery.Manifest.Bytes)+len(delivery.Archive.Bytes) != 0 { + t.Fatal("failed render returned partial delivery bytes") + } + }) + } +} + +// TestRendererNodeDiagnosticsPassThroughUnchanged pins the wire contract for +// node-located content diagnostics: the renderer's codes, severities, NodeID +// and RulePath reach the manifest and the typed failure without rewriting. +func TestRendererNodeDiagnosticsPassThroughUnchanged(t *testing.T) { + warnings := []render.Diagnostic{ + {Code: "required_content_missing", Severity: render.SeverityWarning, NodeID: "node-7", RulePath: "mappings[2]", Message: "required node node-7 is empty", Suggestion: "add content"}, + {Code: "intentionally_empty", Severity: render.SeverityInfo, NodeID: "node-8", RulePath: "mappings[2]", Message: "node node-8 is intentionally empty"}, + {Code: "mapping_fallback", Severity: render.SeverityWarning, NodeID: "node-9", Message: "style not defined", Suggestion: "define the style"}, + } + deps := defaultGeneratorDependencies() + deps.newRenderer = func(*profile.AssetReader) render.Renderer { + return diagnosticsRenderer{diagnostics: warnings} + } + delivery, err := generate(context.Background(), workspaceRequest([]byte(representativeMarkdown)), deps) + if err != nil { + t.Fatalf("generate with warnings: %v", err) + } + var manifest Manifest + if err := json.Unmarshal(delivery.Manifest.Bytes, &manifest); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(manifest.Diagnostics, warnings) { + t.Fatalf("manifest diagnostics = %#v, want renderer diagnostics %#v", manifest.Diagnostics, warnings) + } + for _, fragment := range []string{`"nodeId": "node-7"`, `"rulePath": "mappings[2]"`, `"code": "required_content_missing"`} { + if !strings.Contains(string(delivery.Manifest.Bytes), fragment) { + t.Errorf("manifest JSON omits %s", fragment) + } + } + + refusal := []render.Diagnostic{{Code: "mapping_unresolved", Severity: render.SeverityError, NodeID: "node-3", Message: "node node-3 cannot be mapped"}} + deps.newRenderer = func(*profile.AssetReader) render.Renderer { + return diagnosticsRenderer{diagnostics: refusal, err: errors.New("word: mapping unresolved for node node-3")} + } + _, err = generate(context.Background(), workspaceRequest([]byte(representativeMarkdown)), deps) + var diagnostic *DiagnosticError + if !errors.As(err, &diagnostic) { + t.Fatalf("refusal error %T %v is not a DiagnosticError", err, err) + } + if !reflect.DeepEqual(diagnostic.Diagnostics, refusal) { + t.Fatalf("refusal diagnostics = %#v, want %#v", diagnostic.Diagnostics, refusal) + } +} + +// diagnosticsRenderer returns fixed renderer diagnostics without a template, so +// the Backend's carriage of the renderer's diagnostics is tested on its own. +type diagnosticsRenderer struct { + diagnostics []render.Diagnostic + err error +} + +func (renderer diagnosticsRenderer) Validate(context.Context, render.Request) ([]render.Diagnostic, error) { + return renderer.diagnostics, renderer.err +} + +func (renderer diagnosticsRenderer) Render(context.Context, render.Request) (render.Result, error) { + if renderer.err != nil { + return render.Result{Diagnostics: renderer.diagnostics}, renderer.err + } + return render.Result{ + Artifact: []byte("docx"), MIME: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + Diagnostics: renderer.diagnostics, + }, nil +} + +type failingRenderer struct{ err error } + +func (renderer failingRenderer) Validate(context.Context, render.Request) ([]render.Diagnostic, error) { + return []render.Diagnostic{{Code: "forced_failure", Severity: render.SeverityError, Message: renderer.err.Error()}}, renderer.err +} + +func (renderer failingRenderer) Render(context.Context, render.Request) (render.Result, error) { + return render.Result{Diagnostics: []render.Diagnostic{{Code: "forced_failure", Severity: render.SeverityError, Message: renderer.err.Error()}}}, renderer.err +} + +type cleanupRenderer struct { + active *atomic.Int32 + started chan struct{} + canceled chan struct{} + release chan struct{} +} + +func (renderer cleanupRenderer) Validate(context.Context, render.Request) ([]render.Diagnostic, error) { + return nil, nil +} + +func (renderer cleanupRenderer) Render(ctx context.Context, _ render.Request) (render.Result, error) { + renderer.active.Add(1) + defer renderer.active.Add(-1) + close(renderer.started) + <-ctx.Done() + close(renderer.canceled) + <-renderer.release + return render.Result{}, ctx.Err() +} + +func TestCancellationWaitsForRendererUnwindAndRecoversCapacity(t *testing.T) { + var active atomic.Int32 + started := make(chan struct{}) + canceled := make(chan struct{}) + release := make(chan struct{}) + deps := defaultGeneratorDependencies() + deps.newRenderer = func(*profile.AssetReader) render.Renderer { + return cleanupRenderer{active: &active, started: started, canceled: canceled, release: release} + } + ctx, cancel := context.WithCancel(context.Background()) + result := make(chan error, 1) + go func() { + _, err := generate(ctx, workspaceRequest([]byte(representativeMarkdown)), deps) + result <- err + }() + <-started + cancel() + <-canceled + select { + case err := <-result: + t.Fatalf("Generate returned before renderer cleanup: %v", err) + default: + } + close(release) + select { + case err := <-result: + if !errors.Is(err, context.Canceled) || active.Load() != 0 { + t.Fatalf("cancellation err=%v active=%d, want canceled and zero capacity", err, active.Load()) + } + case <-time.After(time.Second): + t.Fatal("Generate did not return after renderer cleanup") + } +} + +func TestRendererFailureNeverFallsBackToLegacy(t *testing.T) { + forced := errors.New("forced renderer failure") + legacyCalls := 0 + deps := defaultGeneratorDependencies() + deps.newRenderer = func(*profile.AssetReader) render.Renderer { return failingRenderer{err: forced} } + deps.legacyBuild = func(string, string) ([]byte, error) { + legacyCalls++ + return []byte("legacy"), nil + } + request := workspaceRequest([]byte(representativeMarkdown)) + if _, err := generateWithOptions(context.Background(), request, RenderOptions{MigrationComparison: true}, deps); !errors.Is(err, forced) { + t.Fatalf("generate error = %v, want forced renderer failure", err) + } + if legacyCalls != 0 { + t.Fatalf("legacy renderer called %d times after shared renderer failure", legacyCalls) + } +} + +func TestLegacyComparisonIsExplicitAndDoesNotReplaceSharedOutput(t *testing.T) { + legacyCalls := 0 + deps := defaultGeneratorDependencies() + deps.legacyBuild = func(string, string) ([]byte, error) { + legacyCalls++ + return []byte("legacy-comparison-only"), nil + } + request := workspaceRequest([]byte(representativeMarkdown)) + withoutFlag, err := generate(context.Background(), request, deps) + if err != nil { + t.Fatal(err) + } + if legacyCalls != 0 || withoutFlag.Comparison.Enabled { + t.Fatalf("disabled comparison called legacy renderer: calls=%d receipt=%#v", legacyCalls, withoutFlag.Comparison) + } + + withFlag, err := generateWithOptions(context.Background(), request, RenderOptions{MigrationComparison: true}, deps) + if err != nil { + t.Fatal(err) + } + if legacyCalls != 1 || !withFlag.Comparison.Enabled || withFlag.Comparison.LegacySHA256 == "" || withFlag.Comparison.Assessment != "unassessed" || len(withFlag.Comparison.Unassessed) == 0 { + t.Fatalf("enabled comparison receipt=%#v calls=%d", withFlag.Comparison, legacyCalls) + } + if !bytes.Equal(withoutFlag.DOCX.Bytes, withFlag.DOCX.Bytes) { + t.Fatal("legacy comparison replaced the shared-renderer production output") + } +} + +func TestRepresentativeLegacyComparisonRecordsOnlyIntentionalDifferences(t *testing.T) { + const common = "# 研发周会纪要\n\n正文有 **粗体**、*斜体*、~~删除~~、`代码` 和 [链接](https://example.com/private)。\n\n> 引用内容。\n\n```go\nfmt.Println(1)\n```\n\n## 要点\n\n- [x] 已完成\n- [ ] 待办\n\n1. 第一项\n2. 第二项\n\n---\n\n| 左 | 中 | 右 |\n| :--- | :---: | ---: |\n| L | C | R |\n" + request := workspaceRequest([]byte(common)) + delivery, err := generateWithOptions(context.Background(), request, RenderOptions{MigrationComparison: true}, defaultGeneratorDependencies()) + if err != nil { + t.Fatal(err) + } + if delivery.Comparison.Assessment != "assessed" || len(delivery.Comparison.AccidentalDifferences) != 0 || len(delivery.Comparison.Unassessed) != 0 { + t.Fatalf("accidental differences remain: %#v", delivery.Comparison.AccidentalDifferences) + } + exceptions := map[string]bool{} + for _, dimension := range delivery.Comparison.Dimensions { + if dimension.Status == "intentional" { + exceptions[dimension.ExceptionID] = true + } + } + for _, id := range []string{"RDR-T10-CMP-001", "RDR-T10-CMP-002", "RDR-T10-CMP-003", "RDR-T10-CMP-004"} { + if !exceptions[id] { + t.Errorf("comparison omitted reviewed exception %s: %#v", id, delivery.Comparison.Dimensions) + } + } + for _, name := range []string{ + "list-structure", "task-state", "thematic-break", "table-alignment", + "word-numbering", "word-task-state", "word-thematic-rule", "word-table-alignment", + } { + dimension := comparisonDimension(t, delivery.Comparison, name) + if dimension.Status != "match" { + t.Errorf("comparison dimension %s = %#v, want match", name, dimension) + } + } + for _, dimension := range delivery.Comparison.Dimensions { + if dimension.ExceptionID == comparisonExceptionCorporate && !strings.Contains(dimension.Detail, "published organization default") { + t.Errorf("corporate layout exception %s detail = %q, want published organization default", dimension.Name, dimension.Detail) + } + } + if dimension := comparisonDimension(t, delivery.Comparison, "table-of-contents"); dimension.Status != "intentional" || dimension.ExceptionID != comparisonExceptionCorporate { + t.Errorf("declared TOC comparison = %#v, want intentional %s", dimension, comparisonExceptionCorporate) + } + if dimension := comparisonDimension(t, delivery.Comparison, "visible-text"); dimension.Status == "accidental" { + t.Errorf("declared TOC leaked into the body visible-text comparison: %#v", dimension) + } + + request.ProfileID, request.ProfileVersion = "company.default", "1.0.0" + pinned, err := generateWithOptions(context.Background(), request, RenderOptions{MigrationComparison: true}, defaultGeneratorDependencies()) + if err != nil { + t.Fatal(err) + } + if pinned.Comparison.Assessment != "assessed" || len(pinned.Comparison.AccidentalDifferences) != 0 { + t.Fatalf("pinned 1.0.0 comparison has accidental differences: %#v", pinned.Comparison.AccidentalDifferences) + } + if dimension := comparisonDimension(t, pinned.Comparison, "table-of-contents"); dimension.Status != "match" { + t.Errorf("pinned 1.0.0 TOC comparison = %#v, want match without a TOC", dimension) + } +} + +func TestComparisonKeepsBodyTextLossAccidentalBesideDeclaredTOC(t *testing.T) { + const source = "# Report\n\n## Scope\n\nScope body.\n\n## Result\n\nResult body.\n" + request := workspaceRequest([]byte(source)) + request.Title = "Report" + delivery, err := Generate(context.Background(), request) + if err != nil { + t.Fatal(err) + } + legacy, err := legacyBuild(request.Title, "# Report\n\n## Scope\n\nScope body.\n\n## Result\n\nDifferent body.\n") + if err != nil { + t.Fatal(err) + } + receipt := compareLegacyAndShared(request.Title, []byte(source), delivery.Document, legacy, delivery.DOCX.Bytes) + if dimension := comparisonDimension(t, receipt, "table-of-contents"); dimension.Status != "intentional" { + t.Fatalf("declared TOC comparison = %#v, want intentional", dimension) + } + if dimension := comparisonDimension(t, receipt, "visible-text"); dimension.Status != "accidental" { + t.Fatalf("body text loss beside a TOC = %#v, want accidental", dimension) + } +} + +func TestComparisonDetectsCanonicalAndDOCXTableAlignmentLoss(t *testing.T) { + const aligned = "# Alignment\n\n| Left | Center | Right | Default |\n| :--- | :---: | ---: | --- |\n| L | C | R | D |\n" + request := workspaceRequest([]byte(aligned)) + request.Title = "Alignment" + delivery, err := Generate(context.Background(), request) + if err != nil { + t.Fatal(err) + } + legacy, err := legacyBuild(request.Title, aligned) + if err != nil { + t.Fatal(err) + } + + canonicalWithoutAlignment := delivery.Document.Clone() + for rowIndex := range canonicalWithoutAlignment.Root.Children[1].Children { + for cellIndex := range canonicalWithoutAlignment.Root.Children[1].Children[rowIndex].Children { + canonicalWithoutAlignment.Root.Children[1].Children[rowIndex].Children[cellIndex].Properties = nil + } + } + canonicalLoss := compareLegacyAndShared(request.Title, []byte(aligned), canonicalWithoutAlignment, legacy, delivery.DOCX.Bytes) + if dimension := comparisonDimension(t, canonicalLoss, "table-alignment"); dimension.Status != "accidental" { + t.Fatalf("canonical table-alignment loss = %#v, want accidental", dimension) + } + + docxWithoutAlignment := rewriteDOCXPart(t, delivery.DOCX.Bytes, "word/document.xml", func(content []byte) []byte { + for _, value := range []string{"left", "center", "right"} { + content = bytes.ReplaceAll(content, []byte(``), nil) + } + return content + }) + docxLoss := compareLegacyAndShared(request.Title, []byte(aligned), delivery.Document, legacy, docxWithoutAlignment) + if docxLoss.Assessment != "differences" { + t.Fatalf("DOCX alignment loss assessment = %q, want differences", docxLoss.Assessment) + } + if dimension := comparisonDimension(t, docxLoss, "word-table-alignment"); dimension.Status != "accidental" { + t.Fatalf("DOCX table-alignment loss = %#v, want accidental", dimension) + } +} + +func TestLegacyComparisonReportsAccidentalAndUnassessedDimensions(t *testing.T) { + request := workspaceRequest([]byte("# Expected\n\nExpected body.\n")) + deps := defaultGeneratorDependencies() + deps.legacyBuild = func(title, _ string) ([]byte, error) { return legacyBuild(title, "# Wrong\n\nLost body.\n") } + accidental, err := generateWithOptions(context.Background(), request, RenderOptions{MigrationComparison: true}, deps) + if err != nil { + t.Fatal(err) + } + if accidental.Comparison.Assessment != "differences" || len(accidental.Comparison.AccidentalDifferences) == 0 { + t.Fatalf("content loss was not accidental: %#v", accidental.Comparison) + } + + deps.legacyBuild = func(string, string) ([]byte, error) { return []byte("not a DOCX"), nil } + unassessed, err := generateWithOptions(context.Background(), request, RenderOptions{MigrationComparison: true}, deps) + if err != nil { + t.Fatal(err) + } + if unassessed.Comparison.Assessment != "unassessed" || len(unassessed.Comparison.Unassessed) == 0 { + t.Fatalf("invalid legacy sample was reported as assessed: %#v", unassessed.Comparison) + } +} + +func docxVisibleText(t *testing.T, raw []byte) string { + t.Helper() + zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw))) + if err != nil { + t.Fatal(err) + } + for _, file := range zr.File { + if file.Name != "word/document.xml" { + continue + } + stream, err := file.Open() + if err != nil { + t.Fatal(err) + } + decoder := xml.NewDecoder(stream) + var text strings.Builder + for { + token, err := decoder.Token() + if err == io.EOF { + break + } + if err != nil { + stream.Close() + t.Fatal(err) + } + if chars, ok := token.(xml.CharData); ok { + text.Write(chars) + text.WriteByte('\n') + } + } + stream.Close() + return text.String() + } + t.Fatal("word/document.xml missing") + return "" +} + +func docxPart(t *testing.T, raw []byte, name string) string { + t.Helper() + zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw))) + if err != nil { + t.Fatal(err) + } + for _, file := range zr.File { + if file.Name != name { + continue + } + stream, err := file.Open() + if err != nil { + t.Fatal(err) + } + content, err := io.ReadAll(stream) + stream.Close() + if err != nil { + t.Fatal(err) + } + return string(content) + } + t.Fatalf("DOCX part %s missing", name) + return "" +} + +func comparisonDimension(t *testing.T, receipt ComparisonReceipt, name string) ComparisonDimension { + t.Helper() + for _, dimension := range receipt.Dimensions { + if dimension.Name == name { + return dimension + } + } + t.Fatalf("comparison dimension %s missing: %#v", name, receipt.Dimensions) + return ComparisonDimension{} +} + +func rewriteDOCXPart(t *testing.T, raw []byte, name string, rewrite func([]byte) []byte) []byte { + t.Helper() + reader, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw))) + if err != nil { + t.Fatal(err) + } + var output bytes.Buffer + writer := zip.NewWriter(&output) + for _, file := range reader.File { + stream, err := file.Open() + if err != nil { + t.Fatal(err) + } + content, err := io.ReadAll(stream) + stream.Close() + if err != nil { + t.Fatal(err) + } + if file.Name == name { + content = rewrite(content) + } + target, err := writer.Create(file.Name) + if err != nil { + t.Fatal(err) + } + if _, err := target.Write(content); err != nil { + t.Fatal(err) + } + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + return output.Bytes() +} diff --git a/internal/execenv/base-requirements.txt b/internal/execenv/base-requirements.txt new file mode 100644 index 0000000..b8f699c --- /dev/null +++ b/internal/execenv/base-requirements.txt @@ -0,0 +1,11 @@ +python-docx +openpyxl +python-pptx +lxml +pillow +numpy +matplotlib +pypdf +reportlab +markdown +pdfplumber diff --git a/internal/execenv/broker.go b/internal/execenv/broker.go new file mode 100644 index 0000000..53d99d7 --- /dev/null +++ b/internal/execenv/broker.go @@ -0,0 +1,349 @@ +package execenv + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" +) + +// Store 是申请与绑定的持久化。实现在 store 包,避免本包依赖数据库驱动。 +type Store interface { + InsertExecEnvRequest(Request) error + GetExecEnvRequest(id string) (Request, error) + ListExecEnvRequests(conversationID string, limit int) ([]Request, error) + UpdateExecEnvRequest(id, status, layerID, detail, approver string) error + GetExecEnvBinding(conversationID string) (Binding, error) + PutExecEnvBinding(Binding) error + ListExecEnvBindings() ([]Binding, error) + InsertExecEnvAudit(owner, action, actor, target, outcome, entry string) error +} + +// Request 是一次扩展包申请。 +type Request struct { + ID string `json:"id"` + ConversationID string `json:"conversationId"` + Owner string `json:"owner"` + Spec string `json:"spec"` + Reason string `json:"reason"` + Status string `json:"status"` + LayerID string `json:"layerId"` + Detail string `json:"detail"` + Approver string `json:"approver"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +// Binding 是会话当前挂载的层。Isolated 非空时只挂隔离层,不挂基础层。 +type Binding struct { + ConversationID string `json:"conversationId"` + BaseID string `json:"baseId"` + Extensions []string `json:"extensions"` + Isolated string `json:"isolated"` + UpdatedAt string `json:"updatedAt"` +} + +// Outcome 是工具直接回给模型的结果。 +type Outcome struct { + Status string `json:"status"` + RequestID string `json:"requestId,omitempty"` + LayerID string `json:"layerId,omitempty"` + Detail string `json:"detail,omitempty"` +} + +// Resolver 在宿主侧解析并下载 wheel。沙箱不实现这个接口。 +type Resolver interface { + Resolve(ctx context.Context, reqs []string, base Lock) (Result, error) + Fetch(ctx context.Context, wheelhouse string, pkg LockedPkg) (string, error) +} + +// Broker 在宿主侧解析、分级、构建。沙箱不调用它的构建路径。 +type Broker struct { + Root *Root + Store Store + Pip Resolver + Now func() time.Time +} + +func NewBroker(root *Root, st Store, pip Resolver) *Broker { + return &Broker{Root: root, Store: st, Pip: pip, Now: time.Now} +} + +// StatusText 给模型看的紧凑状态。 +func (b *Broker) StatusText(conv string) (string, error) { + var buf strings.Builder + baseID, err := b.Root.CurrentBase() + if err != nil { + return "", err + } + if baseID == "" { + buf.WriteString("基础层:未建立(需要 agent-system env ensure)\n") + } else { + lock, err := b.Root.ReadLock(baseID) + if err != nil { + return "", err + } + buf.WriteString("基础层:" + baseID + "\n") + for _, p := range lock.Packages { + fmt.Fprintf(&buf, "- %s==%s\n", p.Name, p.Version) + } + } + if conv != "" && b.Store != nil { + bind, err := b.Store.GetExecEnvBinding(conv) + if err != nil { + return "", err + } + if bind.Isolated != "" { + fmt.Fprintf(&buf, "本会话隔离层:%s\n", bind.Isolated) + } else if len(bind.Extensions) > 0 { + fmt.Fprintf(&buf, "本会话扩展层:%s\n", strings.Join(bind.Extensions, ", ")) + } else { + buf.WriteString("本会话扩展层:无\n") + } + reqs, err := b.Store.ListExecEnvRequests(conv, 10) + if err != nil { + return "", err + } + for _, r := range reqs { + if r.Status == "ready" || r.Status == "rejected" || r.Status == "failed" { + continue + } + fmt.Fprintf(&buf, "申请 %s 状态 %s %s\n", r.ID, r.Status, r.Detail) + } + } + return buf.String(), nil +} + +// RequestPackages 解析并按分级处理。wheelPath 非空表示用户上传的 wheel,一律待审批。 +func (b *Broker) RequestPackages(ctx context.Context, conv, owner, reason string, packages []string, wheelPath string) (Outcome, error) { + if b.Store == nil { + return Outcome{}, errors.New("execenv: store is not configured") + } + baseID, err := b.Root.CurrentBase() + if err != nil { + return Outcome{}, err + } + var base Lock + if baseID != "" { + base, err = b.Root.ReadLock(baseID) + if err != nil { + return Outcome{}, err + } + } + cat, err := b.Root.LoadCatalog() + if err != nil { + return Outcome{}, err + } + var res Result + uploaded := wheelPath != "" + if uploaded { + pkg, err := b.stageUpload(wheelPath) + if err != nil { + return Outcome{}, err + } + res.Packages = []LockedPkg{pkg} + } else if len(packages) == 0 { + return Outcome{}, errors.New("execenv: packages or wheel required") + } else { + res, err = b.Pip.Resolve(ctx, packages, base) + if err != nil { + return Outcome{}, err + } + } + grade, detail := grade(cat, res, uploaded) + specRaw, _ := json.Marshal(map[string]any{"packages": packages, "wheel": wheelPath, "resolved": res.Packages}) + rec := Request{ + ID: newID("env"), ConversationID: conv, Owner: owner, + Spec: string(specRaw), Reason: reason, Status: grade, Detail: detail, + CreatedAt: b.now(), UpdatedAt: b.now(), + } + if err := b.Store.InsertExecEnvRequest(rec); err != nil { + return Outcome{}, err + } + _ = b.Store.InsertExecEnvAudit(owner, "execenv.request", owner, rec.ID, grade, detail) + switch grade { + case "rejected": + return Outcome{Status: "rejected", RequestID: rec.ID, Detail: detail}, nil + case "pending_approval": + return Outcome{Status: "pending_approval", RequestID: rec.ID, Detail: detail}, nil + default: + id, err := b.materialize(ctx, conv, owner, base, res, false) + if err != nil { + _ = b.Store.UpdateExecEnvRequest(rec.ID, "failed", "", err.Error(), "") + return Outcome{Status: "failed", RequestID: rec.ID, Detail: err.Error()}, nil + } + _ = b.Store.UpdateExecEnvRequest(rec.ID, "ready", id, "已绑定", "auto") + return Outcome{Status: "ready", RequestID: rec.ID, LayerID: id, Detail: "已绑定,下一次 run_python 即可导入"}, nil + } +} + +func grade(cat Catalog, res Result, uploaded bool) (status, detail string) { + if len(res.Denied) > 0 || hasDenied(cat, res) { + return "rejected", "命中黑名单:" + strings.Join(deniedNames(cat, res), ", ") + } + if len(res.SDist) > 0 { + return "rejected", "只有源码包,拒绝执行构建脚本:" + strings.Join(res.SDist, ", ") + } + if len(res.Missing) > 0 { + return "rejected", "解析失败:" + strings.Join(res.Missing, "; ") + } + if len(res.Packages) == 0 && len(res.Override) == 0 { + return "rejected", "没有需要安装的新包(基础层已满足,或规格为空)" + } + var why []string + if uploaded { + why = append(why, "用户上传的 wheel") + } + if len(res.Override) > 0 { + why = append(why, strings.Join(res.Override, "; ")) + } + for _, p := range res.Packages { + if !cat.allowed(p.Name) { + why = append(why, p.Name+" 不在白名单") + } + if cat.MaxWheelBytes > 0 && p.Size > cat.MaxWheelBytes { + why = append(why, fmt.Sprintf("%s 体积 %d 超过上限", p.Name, p.Size)) + } + if p.SHA256 == "" && !uploaded { + why = append(why, p.Name+" 缺少哈希") + } + } + if len(why) > 0 { + return "pending_approval", strings.Join(why, ";") + } + return "approved", "白名单内自动批准" +} + +func hasDenied(cat Catalog, res Result) bool { + return len(deniedNames(cat, res)) > 0 +} + +func deniedNames(cat Catalog, res Result) []string { + var out []string + for _, p := range res.Packages { + if cat.denied(p.Name) { + out = append(out, p.Name) + } + } + return out +} + +// Decide 处理审批。isolate 仅在申请确实与基础层冲突时建立隔离层。 +func (b *Broker) Decide(ctx context.Context, id, actor string, admin, allow, isolate bool) (Outcome, error) { + rec, err := b.Store.GetExecEnvRequest(id) + if err != nil { + return Outcome{}, err + } + if rec.Status != "pending_approval" { + return Outcome{}, fmt.Errorf("execenv: request %s is %s", id, rec.Status) + } + if !admin && actor != rec.Owner { + return Outcome{}, errors.New("execenv: only the session owner or an admin can decide") + } + if !allow { + if err := b.Store.UpdateExecEnvRequest(id, "rejected", "", "已拒绝", actor); err != nil { + return Outcome{}, err + } + _ = b.Store.InsertExecEnvAudit(rec.Owner, "execenv.decide", actor, id, "rejected", "") + return Outcome{Status: "rejected", RequestID: id, Detail: "已拒绝"}, nil + } + var spec struct { + Packages []string `json:"packages"` + Resolved []LockedPkg `json:"resolved"` + } + _ = json.Unmarshal([]byte(rec.Spec), &spec) + baseID, err := b.Root.CurrentBase() + if err != nil { + return Outcome{}, err + } + var base Lock + if baseID != "" { + base, err = b.Root.ReadLock(baseID) + if err != nil { + return Outcome{}, err + } + } + var res Result + if isolate { + // 隔离层不继承基础层约束,整份闭包单独成层。 + res, err = b.Pip.Resolve(ctx, spec.Packages, Lock{}) + if err != nil { + return Outcome{}, err + } + } else if len(spec.Resolved) > 0 { + res.Packages = spec.Resolved + } else if len(spec.Packages) > 0 { + res, err = b.Pip.Resolve(ctx, spec.Packages, base) + if err != nil { + return Outcome{}, err + } + } + if len(res.Override) > 0 && !isolate { + msg := "与基础层版本冲突,批准时需要选择隔离层:" + strings.Join(res.Override, "; ") + _ = b.Store.UpdateExecEnvRequest(id, "pending_approval", "", msg, "") + return Outcome{Status: "pending_approval", RequestID: id, Detail: msg}, nil + } + layer, err := b.materialize(ctx, rec.ConversationID, rec.Owner, base, res, isolate) + if err != nil { + _ = b.Store.UpdateExecEnvRequest(id, "failed", "", err.Error(), actor) + return Outcome{Status: "failed", RequestID: id, Detail: err.Error()}, nil + } + if err := b.Store.UpdateExecEnvRequest(id, "ready", layer, "已批准并绑定", actor); err != nil { + return Outcome{}, err + } + _ = b.Store.InsertExecEnvAudit(rec.Owner, "execenv.decide", actor, id, "ready", layer) + return Outcome{Status: "ready", RequestID: id, LayerID: layer, Detail: "已批准并绑定"}, nil +} + +func (b *Broker) now() string { return b.Now().UTC().Format(time.RFC3339Nano) } + +func newID(prefix string) string { + var buf [8]byte + _, _ = rand.Read(buf[:]) + return prefix + "_" + hex.EncodeToString(buf[:]) +} + +func (b *Broker) stageUpload(path string) (LockedPkg, error) { + if filepath.Ext(path) != ".whl" { + return LockedPkg{}, errors.New("execenv: upload must be a .whl") + } + f, err := os.Open(path) + if err != nil { + return LockedPkg{}, err + } + defer f.Close() + sum := sha256New() + n, err := io.Copy(sum, f) + if err != nil { + return LockedPkg{}, err + } + sha := sum.Sum() + dest := filepath.Join(b.Root.Wheelhouse(), sha+".whl") + if _, err := os.Stat(dest); err != nil { + in, err := os.Open(path) + if err != nil { + return LockedPkg{}, err + } + defer in.Close() + out, err := os.OpenFile(dest, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) + if err != nil && !errors.Is(err, os.ErrExist) { + return LockedPkg{}, err + } + if err == nil { + if _, err := io.Copy(out, in); err != nil { + out.Close() + return LockedPkg{}, err + } + out.Close() + } + } + name := strings.Split(filepath.Base(path), "-")[0] + return LockedPkg{Name: name, Version: "0", Filename: filepath.Base(path), SHA256: sha, Size: n}, nil +} diff --git a/internal/execenv/broker_test.go b/internal/execenv/broker_test.go new file mode 100644 index 0000000..b6b1f04 --- /dev/null +++ b/internal/execenv/broker_test.go @@ -0,0 +1,171 @@ +package execenv + +import ( + "archive/zip" + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +type memStore struct { + reqs map[string]Request + bind map[string]Binding +} + +func newMem() *memStore { return &memStore{reqs: map[string]Request{}, bind: map[string]Binding{}} } + +func (m *memStore) InsertExecEnvRequest(r Request) error { + m.reqs[r.ID] = r + return nil +} +func (m *memStore) GetExecEnvRequest(id string) (Request, error) { return m.reqs[id], nil } +func (m *memStore) ListExecEnvRequests(conv string, limit int) ([]Request, error) { + var out []Request + for _, r := range m.reqs { + if conv == "" || r.ConversationID == conv { + out = append(out, r) + } + } + return out, nil +} +func (m *memStore) UpdateExecEnvRequest(id, status, layerID, detail, approver string) error { + r := m.reqs[id] + r.Status, r.LayerID, r.Detail, r.Approver = status, layerID, detail, approver + m.reqs[id] = r + return nil +} +func (m *memStore) GetExecEnvBinding(conv string) (Binding, error) { return m.bind[conv], nil } +func (m *memStore) PutExecEnvBinding(b Binding) error { + m.bind[b.ConversationID] = b + return nil +} +func (m *memStore) ListExecEnvBindings() ([]Binding, error) { + var out []Binding + for _, b := range m.bind { + out = append(out, b) + } + return out, nil +} +func (m *memStore) InsertExecEnvAudit(string, string, string, string, string, string) error { + return nil +} + +type scripted struct{ byReq map[string]Result } + +func (s scripted) Resolve(_ context.Context, reqs []string, _ Lock) (Result, error) { + if len(reqs) == 0 { + return Result{}, nil + } + return s.byReq[reqs[0]], nil +} +func (s scripted) Fetch(context.Context, string, LockedPkg) (string, error) { + return "", os.ErrNotExist +} + +func TestConversationsGetDistinctLayers(t *testing.T) { + root, err := Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + cat := DefaultCatalog() + cat.Allow = append(cat.Allow, CatalogEntry{Name: "foo", Reason: "test"}) + raw, _ := json.Marshal(cat) + if err := os.WriteFile(root.CatalogPath(), raw, 0o644); err != nil { + t.Fatal(err) + } + baseSum := placeWheel(t, root, "bar.py") + baseID, err := root.BuildLayer(Lock{ + Schema: 1, Kind: "base", PythonTag: "cp314", Platform: "any", + Packages: []LockedPkg{{Name: "bar", Version: "1", Filename: baseSum + ".whl", SHA256: baseSum}}, + }) + if err != nil { + t.Fatal(err) + } + if err := root.SwitchCurrent(baseID); err != nil { + t.Fatal(err) + } + sum1 := placeWheel(t, root, "foo1.py") + sum2 := placeWheel(t, root, "foo2.py") + broker := NewBroker(root, newMem(), scripted{byReq: map[string]Result{ + "foo==1": {Packages: []LockedPkg{{Name: "foo", Version: "1", Filename: sum1 + ".whl", SHA256: sum1}}}, + "foo==2": {Packages: []LockedPkg{{Name: "foo", Version: "2", Filename: sum2 + ".whl", SHA256: sum2}}}, + }}) + a, err := broker.RequestPackages(context.Background(), "conv-a", "owner", "need 1", []string{"foo==1"}, "") + if err != nil || a.Status != "ready" { + t.Fatalf("A: %+v %v", a, err) + } + b, err := broker.RequestPackages(context.Background(), "conv-b", "owner", "need 2", []string{"foo==2"}, "") + if err != nil || b.Status != "ready" { + t.Fatalf("B: %+v %v", b, err) + } + if a.LayerID == b.LayerID { + t.Fatalf("layers should differ: %s", a.LayerID) + } + cur, err := root.CurrentBase() + if err != nil || cur != baseID { + t.Fatalf("base changed: %s -> %s (%v)", baseID, cur, err) + } + extraA, isoA := broker.LayersFor("conv-a") + extraB, isoB := broker.LayersFor("conv-b") + if isoA != "" || isoB != "" || len(extraA) != 1 || len(extraB) != 1 || extraA[0] == extraB[0] { + t.Fatalf("mounts A=%v %s B=%v %s", extraA, isoA, extraB, isoB) + } +} + +func TestRequestGrades(t *testing.T) { + root, err := Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + broker := NewBroker(root, newMem(), scripted{byReq: map[string]Result{ + "onlysrc": {SDist: []string{"onlysrc"}}, + "secret": {Packages: []LockedPkg{{Name: "secret", Version: "1", SHA256: "abc"}}}, + }}) + sdist, err := broker.RequestPackages(context.Background(), "c", "o", "", []string{"onlysrc"}, "") + if err != nil || sdist.Status != "rejected" { + t.Fatalf("sdist: %+v %v", sdist, err) + } + outside, err := broker.RequestPackages(context.Background(), "c", "o", "", []string{"secret"}, "") + if err != nil || outside.Status != "pending_approval" { + t.Fatalf("allowlist: %+v %v", outside, err) + } +} + +func placeWheel(t *testing.T, root *Root, member string) string { + t.Helper() + buf := &bytes.Buffer{} + zw := zip.NewWriter(buf) + f, err := zw.Create(member) + if err != nil { + t.Fatal(err) + } + if _, err := f.Write([]byte("x = 1\n")); err != nil { + t.Fatal(err) + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(buf.Bytes()) + hexSum := hex.EncodeToString(sum[:]) + if err := os.WriteFile(filepath.Join(root.Wheelhouse(), hexSum+".whl"), buf.Bytes(), 0o644); err != nil { + t.Fatal(err) + } + return hexSum +} + +func osWriteWheel(path, member string) error { + buf := &bytes.Buffer{} + zw := zip.NewWriter(buf) + if _, err := zw.Create(member); err != nil { + return err + } + if err := zw.Close(); err != nil { + return err + } + return os.WriteFile(path, buf.Bytes(), 0o644) +} diff --git a/internal/execenv/catalog.go b/internal/execenv/catalog.go new file mode 100644 index 0000000..f31ed3e --- /dev/null +++ b/internal/execenv/catalog.go @@ -0,0 +1,77 @@ +package execenv + +import ( + "encoding/json" + "os" +) + +// Catalog 是管理员可编辑的白名单。不在白名单里的包不会自动安装。 +type Catalog struct { + MaxWheelBytes int64 `json:"maxWheelBytes"` + Allow []CatalogEntry `json:"allow"` + Deny []string `json:"deny"` +} + +// CatalogEntry 的 Spec 为空表示任意版本;非空时是给人类看的范围说明, +// 自动批准只看包名是否在列,版本冲突另由基础层锁判定。 +type CatalogEntry struct { + Name string `json:"name"` + Spec string `json:"spec,omitempty"` + Reason string `json:"reason,omitempty"` +} + +// DefaultCatalog 是第一次创建环境根时落下的名单。之后以磁盘上的 catalog.json 为准。 +func DefaultCatalog() Catalog { + return Catalog{ + MaxWheelBytes: 80 << 20, + Allow: []CatalogEntry{ + {Name: "python-docx", Reason: "Word 交付物"}, + {Name: "openpyxl", Reason: "Excel"}, + {Name: "python-pptx", Reason: "PPT"}, + {Name: "lxml", Reason: "OOXML"}, + {Name: "pillow", Reason: "图片"}, + {Name: "numpy", Reason: "数值"}, + {Name: "matplotlib", Reason: "图表"}, + {Name: "pypdf", Reason: "PDF 读取"}, + {Name: "reportlab", Reason: "PDF 生成"}, + {Name: "markdown", Reason: "Markdown"}, + {Name: "pdfplumber", Reason: "PDF 抽取"}, + }, + Deny: []string{"setuptools", "pip"}, + } +} + +func (r *Root) LoadCatalog() (Catalog, error) { + raw, err := os.ReadFile(r.CatalogPath()) + if err != nil { + return Catalog{}, err + } + var c Catalog + if err := json.Unmarshal(raw, &c); err != nil { + return Catalog{}, err + } + if c.MaxWheelBytes <= 0 { + c.MaxWheelBytes = 80 << 20 + } + return c, nil +} + +func (c Catalog) allowed(name string) bool { + n := normName(name) + for _, e := range c.Allow { + if normName(e.Name) == n { + return true + } + } + return false +} + +func (c Catalog) denied(name string) bool { + n := normName(name) + for _, d := range c.Deny { + if normName(d) == n { + return true + } + } + return false +} diff --git a/internal/execenv/doctor.go b/internal/execenv/doctor.go new file mode 100644 index 0000000..4d64ab3 --- /dev/null +++ b/internal/execenv/doctor.go @@ -0,0 +1,97 @@ +package execenv + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "strings" + "time" +) + +// Doctor 用给定解释器导入模块。sandbox 为真且系统有 bwrap 时,按与执行后端相同的隔离方式跑。 +func Doctor(ctx context.Context, python string, pyPath []string, modules []string, sandbox bool, mounts []string) error { + if python == "" { + return fmt.Errorf("execenv: doctor: python is empty") + } + if len(modules) == 0 { + modules = []string{"docx", "openpyxl", "pptx"} + } + script := "import " + strings.Join(modules, ", ") + if !sandbox { + cmd := exec.CommandContext(ctx, python, "-c", script) + cmd.Env = doctorEnv(pyPath) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("execenv: doctor: %v: %s", err, trim(out)) + } + return nil + } + bwrap, err := exec.LookPath("bwrap") + if err != nil { + return fmt.Errorf("execenv: doctor: sandbox requested but bwrap is missing: %w", err) + } + argv := []string{"--unshare-all", "--die-with-parent", "--ro-bind", "/", "/", "--tmpfs", "/home", "--tmpfs", "/tmp", "--proc", "/proc", "--dev", "/dev"} + for _, m := range mounts { + if m == "" { + continue + } + argv = append(argv, "--ro-bind", m, m) + } + argv = append(argv, "--clearenv") + for _, kv := range doctorEnv(pyPath) { + if i := strings.IndexByte(kv, '='); i > 0 { + argv = append(argv, "--setenv", kv[:i], kv[i+1:]) + } + } + argv = append(argv, python, "-c", script) + cmd := exec.CommandContext(ctx, bwrap, argv...) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("execenv: doctor sandbox: %v: %s", err, trim(out)) + } + return nil +} + +func doctorEnv(pyPath []string) []string { + env := []string{ + "PATH=/usr/local/bin:/usr/bin:/bin", + "PYTHONDONTWRITEBYTECODE=1", + "PIP_NO_INDEX=1", + } + if len(pyPath) > 0 { + env = append(env, "PYTHONPATH="+strings.Join(pyPath, string(os.PathListSeparator))) + } + return env +} + +func trim(b []byte) string { + s := string(bytes.TrimSpace(b)) + if len(s) > 500 { + s = s[:500] + } + return s +} + +// EnsurePython 优先下载独立 CPython;失败则用系统 python 建一份 venv。 +func (r *Root) EnsurePython(ctx context.Context) (string, error) { + if bin, ok := r.PythonBin(); ok { + return bin, nil + } + pctx, cancel := context.WithTimeout(ctx, 4*time.Minute) + defer cancel() + if err := r.InstallStandalone(pctx, ""); err == nil { + if bin, ok := r.PythonBin(); ok { + return bin, nil + } + } + if err := r.BootstrapVenv("/usr/bin/python3"); err != nil { + return "", err + } + bin, ok := r.PythonBin() + if !ok { + return "", fmt.Errorf("execenv: managed python missing after bootstrap") + } + return bin, nil +} diff --git a/internal/execenv/gc.go b/internal/execenv/gc.go new file mode 100644 index 0000000..9eb674b --- /dev/null +++ b/internal/execenv/gc.go @@ -0,0 +1,61 @@ +package execenv + +import ( + "os" + "path/filepath" + "time" +) + +// GC 删除未被 current、绑定或锁引用、且早于 ttl 的层目录。ttl<=0 时用 7 天。 +func (b *Broker) GC(ttl time.Duration) ([]string, error) { + if ttl <= 0 { + ttl = 7 * 24 * time.Hour + } + keep := map[string]bool{} + if id, err := b.Root.CurrentBase(); err != nil { + return nil, err + } else if id != "" { + keep[id] = true + } + if b.Store != nil { + binds, err := b.Store.ListExecEnvBindings() + if err != nil { + return nil, err + } + for _, bind := range binds { + if bind.BaseID != "" { + keep[bind.BaseID] = true + } + if bind.Isolated != "" { + keep[bind.Isolated] = true + } + for _, id := range bind.Extensions { + keep[id] = true + } + } + } + ents, err := os.ReadDir(b.Root.LayersDir()) + if err != nil { + return nil, err + } + cut := time.Now().Add(-ttl) + var removed []string + for _, ent := range ents { + if !ent.IsDir() || keep[ent.Name()] { + continue + } + info, err := ent.Info() + if err != nil { + return removed, err + } + if info.ModTime().After(cut) { + continue + } + if err := os.RemoveAll(filepath.Join(b.Root.LayersDir(), ent.Name())); err != nil { + return removed, err + } + _ = os.Remove(filepath.Join(b.Root.LocksDir(), ent.Name()+".json")) + removed = append(removed, ent.Name()) + } + return removed, nil +} diff --git a/internal/execenv/hashio.go b/internal/execenv/hashio.go new file mode 100644 index 0000000..8fce17e --- /dev/null +++ b/internal/execenv/hashio.go @@ -0,0 +1,20 @@ +package execenv + +import ( + "crypto/sha256" + "encoding/hex" + "hash" + "io" +) + +type shaWriter struct{ h hash.Hash } + +func sha256New() shaWriter { return shaWriter{h: sha256.New()} } + +func (s shaWriter) Sum() string { return hex.EncodeToString(s.h.Sum(nil)) } + +// Write 满足 io.Writer,便于 fileSHA256 之外的流式哈希。 +func (s shaWriter) Write(p []byte) (int, error) { return s.h.Write(p) } + +// 确认 shaWriter 可传给 io.Copy。 +var _ io.Writer = shaWriter{} diff --git a/internal/execenv/lock.go b/internal/execenv/lock.go new file mode 100644 index 0000000..31a127c --- /dev/null +++ b/internal/execenv/lock.go @@ -0,0 +1,62 @@ +package execenv + +import ( + "encoding/json" + "sort" + "strings" +) + +// Lock 是一层的不可变清单。ID 由规范 JSON 决定,与写盘时的空白无关。 +type Lock struct { + Schema int `json:"schema"` + Kind string `json:"kind"` // base | extension | isolated + PythonTag string `json:"pythonTag"` + Platform string `json:"platform"` + BaseID string `json:"baseId,omitempty"` + Packages []LockedPkg `json:"packages"` +} + +// LockedPkg 是一个 wheel。SHA256 是文件字节的哈希,不是文件名。 +type LockedPkg struct { + Name string `json:"name"` + Version string `json:"version"` + Filename string `json:"filename"` + SHA256 string `json:"sha256"` + Size int64 `json:"size,omitempty"` +} + +// ID 返回 base- / x- / iso-。 +func (l Lock) ID() string { + canon := l + canon.Packages = append([]LockedPkg(nil), l.Packages...) + sort.Slice(canon.Packages, func(i, j int) bool { + return normName(canon.Packages[i].Name) < normName(canon.Packages[j].Name) + }) + raw, _ := json.Marshal(canon) + sum := HashBytes(raw) + prefix := "x-" + switch l.Kind { + case "base": + prefix = "base-" + case "isolated": + prefix = "iso-" + } + return prefix + sum[:16] +} + +func normName(name string) string { + var b strings.Builder + prevDash := false + for _, r := range strings.ToLower(name) { + if r == '-' || r == '_' || r == '.' { + if !prevDash && b.Len() > 0 { + b.WriteByte('-') + prevDash = true + } + continue + } + prevDash = false + b.WriteRune(r) + } + return strings.Trim(b.String(), "-") +} diff --git a/internal/execenv/materialize.go b/internal/execenv/materialize.go new file mode 100644 index 0000000..bc2da95 --- /dev/null +++ b/internal/execenv/materialize.go @@ -0,0 +1,206 @@ +package execenv + +import ( + "context" + "errors" + "fmt" + "os" + "time" +) + +func (b *Broker) materialize(ctx context.Context, conv, owner string, base Lock, res Result, isolate bool) (string, error) { + pkgs := append([]LockedPkg(nil), res.Packages...) + for i := range pkgs { + if pkgs[i].SHA256 == "" || !wheelExists(b.Root, pkgs[i]) { + path, err := b.Pip.Fetch(ctx, b.Root.Wheelhouse(), pkgs[i]) + if err != nil { + return "", err + } + sum, err := fileSHA256(path) + if err != nil { + return "", err + } + if pkgs[i].SHA256 != "" && sum != pkgs[i].SHA256 { + return "", fmt.Errorf("execenv: hash mismatch for %s", pkgs[i].Name) + } + pkgs[i].SHA256 = sum + pkgs[i].Filename = pkgs[i].SHA256 + ".whl" + stable := b.Root.Wheelhouse() + "/" + pkgs[i].Filename + if path != stable { + if err := os.Rename(path, stable); err != nil && !errors.Is(err, os.ErrExist) { + // Fetch 可能已经改名。忽略已就位的文件。 + if _, statErr := os.Stat(stable); statErr != nil { + return "", err + } + } + } + } + } + kind := "extension" + baseID := "" + if id, _ := b.Root.CurrentBase(); id != "" { + baseID = id + } + lockPkgs := pkgs + if isolate { + kind = "isolated" + } else if baseID == "" { + kind = "base" + } + if !isolate && baseID != "" { + lockPkgs = res.Packages + } + lock := Lock{ + Schema: 1, Kind: kind, PythonTag: base.PythonTag, Platform: base.Platform, + BaseID: baseID, Packages: lockPkgs, + } + if lock.PythonTag == "" { + lock.PythonTag = "cp314" + } + if lock.Platform == "" { + lock.Platform = "any" + } + id, err := b.Root.BuildLayer(lock) + if err != nil { + return "", err + } + if kind == "base" { + if err := b.Root.SwitchCurrent(id); err != nil { + return "", err + } + return id, nil + } + bind, err := b.Store.GetExecEnvBinding(conv) + if err != nil { + return "", err + } + bind.ConversationID = conv + bind.BaseID = baseID + bind.UpdatedAt = b.now() + if isolate { + bind.Isolated = id + bind.Extensions = nil + } else { + bind.Isolated = "" + bind.Extensions = appendUnique(bind.Extensions, id) + } + if err := b.Store.PutExecEnvBinding(bind); err != nil { + return "", err + } + _ = owner + return id, nil +} + +func wheelExists(r *Root, pkg LockedPkg) bool { + if pkg.SHA256 == "" { + return false + } + _, err := os.Stat(r.Wheelhouse() + "/" + pkg.SHA256 + ".whl") + return err == nil +} + +func appendUnique(ss []string, id string) []string { + for _, s := range ss { + if s == id { + return ss + } + } + return append(ss, id) +} + +// LayersFor 返回本次执行要挂载的 site-packages。isolated 非空时调用方不要再挂基础层。 +func (b *Broker) LayersFor(conv string) (extra []string, isolated string) { + if b.Store == nil || conv == "" { + return nil, "" + } + bind, err := b.Store.GetExecEnvBinding(conv) + if err != nil { + return nil, "" + } + if bind.Isolated != "" { + return nil, b.Root.LayerDir(bind.Isolated) + } + for _, id := range bind.Extensions { + extra = append(extra, b.Root.LayerDir(id)) + } + return extra, "" +} + +// Wait 轮询申请直到终态或超时。 +func (b *Broker) Wait(ctx context.Context, id string, timeout time.Duration) (Outcome, error) { + deadline := time.Now().Add(timeout) + if timeout <= 0 { + deadline = time.Now().Add(2 * time.Minute) + } + for { + rec, err := b.Store.GetExecEnvRequest(id) + if err != nil { + return Outcome{}, err + } + switch rec.Status { + case "ready", "rejected", "failed": + return Outcome{Status: rec.Status, RequestID: id, LayerID: rec.LayerID, Detail: rec.Detail}, nil + } + if time.Now().After(deadline) || ctx.Err() != nil { + return Outcome{Status: rec.Status, RequestID: id, Detail: "仍在等待审批或构建"}, nil + } + select { + case <-ctx.Done(): + return Outcome{Status: rec.Status, RequestID: id, Detail: "等待被取消"}, nil + case <-time.After(200 * time.Millisecond): + } + } +} + +// EnsureBase 解析基础层需求并建立 current。已有 current 时直接返回其 ID。 +func (b *Broker) EnsureBase(ctx context.Context) (string, error) { + if id, err := b.Root.CurrentBase(); err != nil { + return "", err + } else if id != "" { + return id, nil + } + res, err := b.Pip.Resolve(ctx, BaseRequirements(), Lock{}) + if err != nil { + return "", err + } + if !res.OK() { + return "", fmt.Errorf("execenv: base resolve: override=%v sdist=%v missing=%v", res.Override, res.SDist, res.Missing) + } + return b.materialize(ctx, "", "", Lock{Kind: "base"}, res, false) +} + +// Rebase 强制按当前需求重建基础层并移动 current。已有会话绑定保持旧 ID。 +func (b *Broker) Rebase(ctx context.Context) (string, error) { + res, err := b.Pip.Resolve(ctx, BaseRequirements(), Lock{}) + if err != nil { + return "", err + } + if !res.OK() { + return "", fmt.Errorf("execenv: rebase: override=%v sdist=%v missing=%v", res.Override, res.SDist, res.Missing) + } + lock := Lock{Schema: 1, Kind: "base", PythonTag: "cp314", Platform: "any", Packages: res.Packages} + for i := range lock.Packages { + path, err := b.Pip.Fetch(ctx, b.Root.Wheelhouse(), lock.Packages[i]) + if err != nil { + return "", err + } + sum, err := fileSHA256(path) + if err != nil { + return "", err + } + lock.Packages[i].SHA256 = sum + lock.Packages[i].Filename = sum + ".whl" + stable := b.Root.Wheelhouse() + "/" + sum + ".whl" + if path != stable { + _ = os.Rename(path, stable) + } + } + id, err := b.Root.BuildLayer(lock) + if err != nil { + return "", err + } + if err := b.Root.SwitchCurrent(id); err != nil { + return "", err + } + return id, nil +} diff --git a/internal/execenv/pip.go b/internal/execenv/pip.go new file mode 100644 index 0000000..b2079d9 --- /dev/null +++ b/internal/execenv/pip.go @@ -0,0 +1,189 @@ +package execenv + +import ( + "bytes" + "context" + _ "embed" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +//go:embed base-requirements.txt +var baseRequirements string + +// BaseRequirements 是基础层的包名清单(不带版本;版本由第一次解析锁定)。 +func BaseRequirements() []string { + var out []string + for _, line := range strings.Split(baseRequirements, "\n") { + line = strings.TrimSpace(line) + if line != "" && !strings.HasPrefix(line, "#") { + out = append(out, line) + } + } + return out +} + +// Pip 用宿主侧的 pip 下载 wheel。它不在沙箱里运行。 +type Pip struct { + Python string // 带 pip 的解释器 +} + +func (p Pip) python() string { + if p.Python != "" { + return p.Python + } + return "python3" +} + +// Resolve 以基础层锁为 constraints 做 dry-run。冲突记入 Override,不改锁。 +func (p Pip) Resolve(ctx context.Context, reqs []string, base Lock) (Result, error) { + if len(reqs) == 0 { + return Result{}, nil + } + dir, err := os.MkdirTemp("", "execenv-resolve-") + if err != nil { + return Result{}, err + } + defer os.RemoveAll(dir) + cons := filepath.Join(dir, "constraints.txt") + if err := os.WriteFile(cons, []byte(constraintText(base)), 0o644); err != nil { + return Result{}, err + } + // --report 写文件。写到 stdout 时 pip 仍把 Collecting / Would install 打在同一条流上,JSON 解析会失败。 + report := filepath.Join(dir, "report.json") + args := []string{"-m", "pip", "install", "--dry-run", "--report", report, "--only-binary=:all:", "--constraint", cons} + args = append(args, reqs...) + cmd := exec.CommandContext(ctx, p.python(), args...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err = cmd.Run() + text := stderr.String() + stdout.String() + if err != nil { + if strings.Contains(text, "ResolutionImpossible") || strings.Contains(text, "conflicting") { + return Result{Override: []string{firstLine(text)}}, nil + } + if strings.Contains(text, "only-binary") || strings.Contains(text, "No matching distribution") { + return Result{SDist: reqs, Missing: []string{firstLine(text)}}, nil + } + return Result{}, fmt.Errorf("execenv: pip resolve: %v: %s", err, firstLine(text)) + } + raw, err := os.ReadFile(report) + if err != nil { + return Result{}, fmt.Errorf("execenv: pip report: %w", err) + } + return parsePipReport(raw, base) +} + +func parsePipReport(raw []byte, base Lock) (Result, error) { + var doc struct { + Install []struct { + Metadata struct { + Name string `json:"name"` + Version string `json:"version"` + } `json:"metadata"` + DownloadInfo struct { + ArchiveInfo struct { + Hash string `json:"hash"` + } `json:"archive_info"` + URL string `json:"url"` + } `json:"download_info"` + } `json:"install"` + } + if err := json.Unmarshal(raw, &doc); err != nil { + return Result{}, fmt.Errorf("execenv: pip report: %w", err) + } + pinned := map[string]LockedPkg{} + for _, c := range base.Packages { + pinned[normName(c.Name)] = c + } + var res Result + for _, item := range doc.Install { + name := item.Metadata.Name + if pin, ok := pinned[normName(name)]; ok { + if pin.Version != item.Metadata.Version { + res.Override = append(res.Override, fmt.Sprintf("%s 需要 %s,基础层固定为 %s", name, item.Metadata.Version, pin.Version)) + } + continue + } + hash := strings.TrimPrefix(item.DownloadInfo.ArchiveInfo.Hash, "sha256=") + filename := name + "-" + item.Metadata.Version + ".whl" + if item.DownloadInfo.URL != "" { + filename = filepath.Base(strings.Split(item.DownloadInfo.URL, "?")[0]) + } + res.Packages = append(res.Packages, LockedPkg{ + Name: name, Version: item.Metadata.Version, Filename: filename, SHA256: hash, + }) + } + return res, nil +} + +// Fetch 把一个包下载进 wheelhouse,并返回落地路径。哈希不符则删除并报错。 +func (p Pip) Fetch(ctx context.Context, wheelhouse string, pkg LockedPkg) (string, error) { + if err := os.MkdirAll(wheelhouse, 0o755); err != nil { + return "", err + } + dest := filepath.Join(wheelhouse, pkg.Filename) + if pkg.SHA256 != "" { + if sum, err := fileSHA256(dest); err == nil && strings.EqualFold(sum, pkg.SHA256) { + return dest, nil + } + } + cmd := exec.CommandContext(ctx, p.python(), "-m", "pip", "download", + "--only-binary=:all:", "--no-deps", "-d", wheelhouse, pkg.Name+"=="+pkg.Version) + out, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("execenv: pip download %s: %v: %s", pkg.Name, err, firstLine(string(out))) + } + found := dest + if _, err := os.Stat(found); err != nil { + matches, _ := filepath.Glob(filepath.Join(wheelhouse, normName(pkg.Name)+"*.whl")) + if len(matches) == 0 { + matches, _ = filepath.Glob(filepath.Join(wheelhouse, "*.whl")) + } + if len(matches) == 0 { + return "", fmt.Errorf("execenv: download produced no wheel for %s", pkg.Name) + } + found = matches[len(matches)-1] + } + sum, err := fileSHA256(found) + if err != nil { + return "", err + } + if pkg.SHA256 != "" && !strings.EqualFold(sum, pkg.SHA256) { + _ = os.Remove(found) + return "", fmt.Errorf("execenv: hash mismatch for %s", pkg.Name) + } + stable := filepath.Join(wheelhouse, sum+".whl") + if found != stable { + if err := os.Rename(found, stable); err != nil { + return found, nil + } + } + return stable, nil +} + +func constraintText(base Lock) string { + var b strings.Builder + for _, p := range base.Packages { + if p.Name != "" && p.Version != "" { + fmt.Fprintf(&b, "%s==%s\n", p.Name, p.Version) + } + } + return b.String() +} + +func firstLine(s string) string { + s = strings.TrimSpace(s) + if i := strings.IndexByte(s, '\n'); i >= 0 { + s = s[:i] + } + if len(s) > 400 { + s = s[:400] + } + return s +} diff --git a/internal/execenv/python.go b/internal/execenv/python.go new file mode 100644 index 0000000..b677195 --- /dev/null +++ b/internal/execenv/python.go @@ -0,0 +1,241 @@ +package execenv + +import ( + "archive/tar" + "compress/gzip" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" +) + +// PythonBin 返回托管解释器。未安装时 ok=false。 +func (r *Root) PythonBin() (string, bool) { + bin := filepath.Join(r.PythonDir(), "current", "bin", "python3") + st, err := os.Stat(bin) + if err != nil || st.IsDir() { + return "", false + } + return bin, true +} + +// PythonPrefix 是需要在沙箱里重新挂载的前缀(current 的真实目录)。 +func (r *Root) PythonPrefix() string { + dest, err := filepath.EvalSymlinks(filepath.Join(r.PythonDir(), "current")) + if err != nil { + return "" + } + return dest +} + +// LinkPython 把已有解释器收成托管前缀:python/host/bin/python3 -> 绝对路径。 +// 这是下载独立 CPython 失败时的退化,仍然全系统只有一份,不是每个工作区一份。 +func (r *Root) LinkPython(bin string) error { + if !filepath.IsAbs(bin) { + return fmt.Errorf("execenv: python bin must be absolute: %s", bin) + } + if _, err := os.Stat(bin); err != nil { + return err + } + host := filepath.Join(r.PythonDir(), "host") + bindir := filepath.Join(host, "bin") + if err := os.MkdirAll(bindir, 0o755); err != nil { + return err + } + link := filepath.Join(bindir, "python3") + _ = os.Remove(link) + if err := os.Symlink(bin, link); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(r.PythonDir(), "SOURCE"), []byte("link "+bin+"\n"), 0o644); err != nil { + return err + } + return r.switchPython("host") +} + +func (r *Root) switchPython(name string) error { + link := filepath.Join(r.PythonDir(), "current") + tmp := link + ".tmp" + _ = os.Remove(tmp) + if err := os.Symlink(name, tmp); err != nil { + return err + } + return os.Rename(tmp, link) +} + +// InstallStandalone 下载 python-build-standalone 的 install_only 包并解到 python/。 +// url 为空时向 GitHub latest 要与本机架构匹配的 cpython 3.14 资产。 +func (r *Root) InstallStandalone(ctx context.Context, url string) error { + if url == "" { + var err error + url, err = latestStandaloneURL(ctx) + if err != nil { + return err + } + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + client := &http.Client{Timeout: 5 * time.Minute} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return fmt.Errorf("execenv: download python: %s", resp.Status) + } + dest := filepath.Join(r.PythonDir(), "cpython-standalone") + if err := os.RemoveAll(dest); err != nil { + return err + } + if err := os.MkdirAll(dest, 0o755); err != nil { + return err + } + if err := extractTarGz(resp.Body, dest); err != nil { + return err + } + bin := filepath.Join(dest, "python", "bin", "python3") + if _, err := os.Stat(bin); err != nil { + return fmt.Errorf("execenv: standalone archive has no python/bin/python3") + } + // 让 current/bin/python3 成立:current -> cpython-standalone/python。 + inner := filepath.Join("cpython-standalone", "python") + if err := os.WriteFile(filepath.Join(r.PythonDir(), "SOURCE"), []byte("standalone "+url+"\n"), 0o644); err != nil { + return err + } + return r.switchPython(inner) +} + +func latestStandaloneURL(ctx context.Context) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, + "https://api.github.com/repos/astral-sh/python-build-standalone/releases/latest", nil) + if err != nil { + return "", err + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", "agent-system-execenv") + resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return "", fmt.Errorf("execenv: github releases: %s", resp.Status) + } + var doc struct { + Assets []struct { + Name string `json:"name"` + URL string `json:"browser_download_url"` + } `json:"assets"` + } + if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { + return "", err + } + arch := "x86_64" + if runtime.GOARCH == "arm64" { + arch = "aarch64" + } + needle := arch + "-unknown-linux-gnu-install_only.tar.gz" + var fallback string + for _, a := range doc.Assets { + if !strings.Contains(a.Name, needle) || !strings.HasPrefix(a.Name, "cpython-3.14") { + continue + } + if strings.Contains(a.Name, "freethreaded") { + fallback = a.URL + continue + } + return a.URL, nil + } + if fallback != "" { + return fallback, nil + } + return "", errors.New("execenv: no cpython 3.14 install_only asset") +} + +func extractTarGz(rd io.Reader, dest string) error { + gz, err := gzip.NewReader(rd) + if err != nil { + return err + } + defer gz.Close() + tr := tar.NewReader(gz) + for { + hdr, err := tr.Next() + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return err + } + name := filepath.Clean(hdr.Name) + if name == "." || strings.HasPrefix(name, "..") || filepath.IsAbs(name) { + return fmt.Errorf("execenv: tar path escapes: %s", hdr.Name) + } + target := filepath.Join(dest, name) + if !strings.HasPrefix(target, filepath.Clean(dest)+string(os.PathSeparator)) && target != filepath.Clean(dest) { + return fmt.Errorf("execenv: tar path escapes: %s", hdr.Name) + } + switch hdr.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(target, 0o755); err != nil { + return err + } + case tar.TypeReg: + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + mode := os.FileMode(hdr.Mode) & 0o777 + if mode == 0 { + mode = 0o644 + } + f, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode) + if err != nil { + return err + } + _, cerr := io.Copy(f, tr) + f.Close() + if cerr != nil { + return cerr + } + case tar.TypeSymlink: + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + _ = os.Remove(target) + if err := os.Symlink(hdr.Linkname, target); err != nil { + return err + } + } + } +} + +// BootstrapVenv 用系统 python3 做一份唯一的托管前缀。解释器仍指向系统 python, +// 但 pip 在这份前缀里,构建器可以下载 wheel。 +func (r *Root) BootstrapVenv(python string) error { + if python == "" { + python = "/usr/bin/python3" + } + dest := filepath.Join(r.PythonDir(), "venv") + if _, err := os.Stat(filepath.Join(dest, "bin", "python3")); err != nil { + cmd := exec.Command(python, "-m", "venv", dest) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("execenv: venv: %v: %s", err, out) + } + } + if err := os.WriteFile(filepath.Join(r.PythonDir(), "SOURCE"), []byte("venv "+python+"\n"), 0o644); err != nil { + return err + } + return r.switchPython("venv") +} diff --git a/internal/execenv/resolve.go b/internal/execenv/resolve.go new file mode 100644 index 0000000..d20f835 --- /dev/null +++ b/internal/execenv/resolve.go @@ -0,0 +1,221 @@ +package execenv + +import ( + "fmt" + "strconv" + "strings" +) + +// Wheel 是索引里的一个候选。Kind 为 wheel 或 sdist。 +type Wheel struct { + Name string + Version string + Filename string + SHA256 string + Kind string + Requires []string + Size int64 +} + +// Result 是一次解析。Packages 只含相对约束新增的包,不含基础层已有的。 +type Result struct { + Packages []LockedPkg + Override []string + SDist []string + Missing []string + Denied []string +} + +func (r Result) OK() bool { + return len(r.Override) == 0 && len(r.SDist) == 0 && len(r.Missing) == 0 && len(r.Denied) == 0 +} + +// ResolveMem 用内存索引解析。基础层里已有且版本满足的包不会进入 Packages。 +// 版本不满足时记入 Override,不改约束。 +func ResolveMem(index []Wheel, reqs []string, constraints []LockedPkg) Result { + byName := map[string][]Wheel{} + for _, w := range index { + byName[normName(w.Name)] = append(byName[normName(w.Name)], w) + } + pinned := map[string]LockedPkg{} + for _, c := range constraints { + pinned[normName(c.Name)] = c + } + chosen := map[string]LockedPkg{} + var res Result + queue := append([]string{}, reqs...) + seen := map[string]bool{} + for len(queue) > 0 { + req := strings.TrimSpace(queue[0]) + queue = queue[1:] + if req == "" { + continue + } + name, spec := splitReq(req) + key := normName(name) + mark := key + "\x00" + spec + if seen[mark] { + continue + } + seen[mark] = true + if pin, ok := pinned[key]; ok { + if spec != "" && !matchSpec(pin.Version, spec) { + res.Override = append(res.Override, fmt.Sprintf("%s 需要 %s,基础层固定为 %s", name, spec, pin.Version)) + } + continue + } + if prev, ok := chosen[key]; ok { + if spec != "" && !matchSpec(prev.Version, spec) { + res.Override = append(res.Override, fmt.Sprintf("%s 需要 %s,已选 %s", name, spec, prev.Version)) + } + continue + } + best, sdist := pickWheel(byName[key], spec) + if sdist && best == nil { + res.SDist = append(res.SDist, name) + continue + } + if best == nil { + res.Missing = append(res.Missing, name) + continue + } + pkg := LockedPkg{ + Name: best.Name, Version: best.Version, Filename: best.Filename, + SHA256: best.SHA256, Size: best.Size, + } + chosen[key] = pkg + for _, dep := range best.Requires { + queue = append(queue, dep) + } + } + names := make([]string, 0, len(chosen)) + for n := range chosen { + names = append(names, n) + } + sortStrings(names) + for _, n := range names { + res.Packages = append(res.Packages, chosen[n]) + } + return res +} + +func pickWheel(cands []Wheel, spec string) (best *Wheel, sawSDist bool) { + for i := range cands { + w := &cands[i] + if spec != "" && !matchSpec(w.Version, spec) { + continue + } + if w.Kind == "sdist" { + sawSDist = true + continue + } + if best == nil || cmpVer(w.Version, best.Version) > 0 { + best = w + } + } + return best, sawSDist +} + +func splitReq(req string) (name, spec string) { + for i, r := range req { + if r == '=' || r == '<' || r == '>' || r == '!' || r == '~' || r == ' ' { + return strings.TrimSpace(req[:i]), strings.TrimSpace(req[i:]) + } + } + return req, "" +} + +func matchSpec(version, spec string) bool { + for _, part := range strings.Split(spec, ",") { + part = strings.TrimSpace(part) + if part == "" || part == "*" { + continue + } + switch { + case strings.HasPrefix(part, "=="): + if version != strings.TrimPrefix(part, "==") { + return false + } + case strings.HasPrefix(part, "!="): + if version == strings.TrimPrefix(part, "!=") { + return false + } + case strings.HasPrefix(part, ">="): + if cmpVer(version, strings.TrimPrefix(part, ">=")) < 0 { + return false + } + case strings.HasPrefix(part, "<="): + if cmpVer(version, strings.TrimPrefix(part, "<=")) > 0 { + return false + } + case strings.HasPrefix(part, ">"): + if cmpVer(version, strings.TrimPrefix(part, ">")) <= 0 { + return false + } + case strings.HasPrefix(part, "<"): + if cmpVer(version, strings.TrimPrefix(part, "<")) >= 0 { + return false + } + default: + if version != part { + return false + } + } + } + return true +} + +func cmpVer(a, b string) int { + as := splitVer(a) + bs := splitVer(b) + n := len(as) + if len(bs) > n { + n = len(bs) + } + for i := 0; i < n; i++ { + ai, bi := 0, 0 + if i < len(as) { + ai = as[i] + } + if i < len(bs) { + bi = bs[i] + } + if ai != bi { + return ai - bi + } + } + return 0 +} + +func splitVer(v string) []int { + v = strings.TrimPrefix(v, "v") + if i := strings.IndexAny(v, "abrc"); i > 0 { + // 丢掉本地/预发布后缀,比较只看数字段。 + head := v[:i] + if _, err := strconv.Atoi(string(head[len(head)-1])); err == nil { + v = head + } + } + var out []int + for _, p := range strings.Split(v, ".") { + n := 0 + for _, r := range p { + if r < '0' || r > '9' { + break + } + n = n*10 + int(r-'0') + } + out = append(out, n) + } + return out +} + +func sortStrings(s []string) { + for i := 1; i < len(s); i++ { + j := i + for j > 0 && s[j] < s[j-1] { + s[j], s[j-1] = s[j-1], s[j] + j-- + } + } +} diff --git a/internal/execenv/resolve_test.go b/internal/execenv/resolve_test.go new file mode 100644 index 0000000..584c1dd --- /dev/null +++ b/internal/execenv/resolve_test.go @@ -0,0 +1,48 @@ +package execenv + +import ( + "strings" + "testing" +) + +func TestResolveMemConflictAllowlistAndSDist(t *testing.T) { + index := []Wheel{ + {Name: "Foo", Version: "1.0.0", Filename: "foo-1.whl", SHA256: "aaa", Kind: "wheel"}, + {Name: "Foo", Version: "2.0.0", Filename: "foo-2.whl", SHA256: "bbb", Kind: "wheel"}, + {Name: "OnlySrc", Version: "1", Filename: "onlysrc-1.tar.gz", Kind: "sdist"}, + } + got := ResolveMem(index, []string{"foo==1.0.0"}, []LockedPkg{{Name: "foo", Version: "2.0.0"}}) + if len(got.Override) != 1 || len(got.Packages) != 0 { + t.Fatalf("conflict should keep the pin and record an override: %+v", got) + } + fresh := ResolveMem(index, []string{"foo"}, nil) + if len(fresh.Packages) != 1 || fresh.Packages[0].Version != "2.0.0" { + t.Fatalf("unpinned foo should pick the newest wheel: %+v", fresh.Packages) + } + sdist := ResolveMem(index, []string{"onlysrc"}, nil) + if len(sdist.SDist) != 1 || len(sdist.Packages) != 0 { + t.Fatalf("sdist-only must be refused: %+v", sdist) + } + missing := ResolveMem(index, []string{"nope"}, nil) + if len(missing.Missing) != 1 { + t.Fatalf("missing: %+v", missing) + } +} + +func TestBuildLayerRejectsHashMismatch(t *testing.T) { + root, err := Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + wheel := root.Wheelhouse() + "/foo.whl" + if err := osWriteWheel(wheel, "foo.py"); err != nil { + t.Fatal(err) + } + _, err = root.BuildLayer(Lock{ + Schema: 1, Kind: "base", PythonTag: "cp314", Platform: "any", + Packages: []LockedPkg{{Name: "foo", Version: "1", Filename: "foo.whl", SHA256: "deadbeef"}}, + }) + if err == nil || !strings.Contains(err.Error(), "hash mismatch") { + t.Fatalf("err = %v", err) + } +} diff --git a/internal/execenv/root.go b/internal/execenv/root.go new file mode 100644 index 0000000..dfc89bb --- /dev/null +++ b/internal/execenv/root.go @@ -0,0 +1,133 @@ +// Package execenv 是系统托管的 Python 环境:一个解释器、一份不可变基础层、 +// 按内容哈希缓存的扩展层。工作区不各自建 venv,也不往一个可变全局环境里装包。 +package execenv + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +// Root 是环境根目录。多个服务实例可以共用同一份。 +type Root struct { + Path string +} + +// Open 创建环境根的目录骨架。已存在的层与锁不会被改写。 +func Open(path string) (*Root, error) { + if strings.TrimSpace(path) == "" { + return nil, errors.New("execenv: empty root") + } + r := &Root{Path: path} + for _, d := range []string{ + r.PythonDir(), r.LayersDir(), r.Wheelhouse(), r.LocksDir(), r.BinDir(), + } { + if err := os.MkdirAll(d, 0o755); err != nil { + return nil, err + } + } + if err := r.ensureCatalog(); err != nil { + return nil, err + } + if err := writePipShim(r.BinDir()); err != nil { + return nil, err + } + return r, nil +} + +func (r *Root) PythonDir() string { return filepath.Join(r.Path, "python") } +func (r *Root) LayersDir() string { return filepath.Join(r.Path, "layers") } +func (r *Root) Wheelhouse() string { return filepath.Join(r.Path, "wheelhouse") } +func (r *Root) LocksDir() string { return filepath.Join(r.Path, "locks") } +func (r *Root) BinDir() string { return filepath.Join(r.Path, "bin") } +func (r *Root) CatalogPath() string { return filepath.Join(r.Path, "catalog.json") } + +func (r *Root) ensureCatalog() error { + if _, err := os.Stat(r.CatalogPath()); err == nil { + return nil + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + raw, err := json.MarshalIndent(DefaultCatalog(), "", " ") + if err != nil { + return err + } + return os.WriteFile(r.CatalogPath(), append(raw, '\n'), 0o644) +} + +// LayerDir 返回某一层的 site-packages 目录(不一定已经存在)。 +func (r *Root) LayerDir(id string) string { + return filepath.Join(r.LayersDir(), id, "site-packages") +} + +// CurrentBase 读取 current 符号链接指向的基础层 ID。未建立时返回空串。 +func (r *Root) CurrentBase() (string, error) { + dest, err := os.Readlink(filepath.Join(r.Path, "current")) + if errors.Is(err, os.ErrNotExist) { + return "", nil + } + if err != nil { + return "", err + } + id := filepath.Base(dest) + if id == "" || id == "." { + return "", fmt.Errorf("execenv: current link %q is empty", dest) + } + return id, nil +} + +// SwitchCurrent 把 current 指到已存在的层。旧链接被替换,层目录本身不动。 +func (r *Root) SwitchCurrent(id string) error { + if _, err := os.Stat(r.LayerDir(id)); err != nil { + return fmt.Errorf("execenv: layer %s: %w", id, err) + } + link := filepath.Join(r.Path, "current") + tmp := link + ".tmp" + _ = os.Remove(tmp) + if err := os.Symlink(filepath.Join("layers", id), tmp); err != nil { + return err + } + return os.Rename(tmp, link) +} + +// ReadLock 读取某一层的锁。 +func (r *Root) ReadLock(id string) (Lock, error) { + raw, err := os.ReadFile(filepath.Join(r.LocksDir(), id+".json")) + if err != nil { + return Lock{}, err + } + var l Lock + if err := json.Unmarshal(raw, &l); err != nil { + return Lock{}, err + } + return l, nil +} + +// WriteLock 写入锁文件。内容按哈希寻址,已存在且字节相同则跳过。 +func (r *Root) WriteLock(l Lock) (string, error) { + id := l.ID() + path := filepath.Join(r.LocksDir(), id+".json") + raw, err := json.MarshalIndent(l, "", " ") + if err != nil { + return "", err + } + raw = append(raw, '\n') + if prev, err := os.ReadFile(path); err == nil { + if string(prev) != string(raw) { + return "", fmt.Errorf("execenv: lock %s already exists with different bytes", id) + } + return id, nil + } + return id, os.WriteFile(path, raw, 0o644) +} + +// HashBytes 是层键与 wheel 去重共用的 sha256。 +func HashBytes(b []byte) string { + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:]) +} diff --git a/internal/execenv/sandbox_test.go b/internal/execenv/sandbox_test.go new file mode 100644 index 0000000..d27a096 --- /dev/null +++ b/internal/execenv/sandbox_test.go @@ -0,0 +1,44 @@ +package execenv + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestSandboxImportPipAndOffline(t *testing.T) { + if _, err := exec.LookPath("bwrap"); err != nil { + t.Skip("bwrap not available") + } + if _, err := exec.LookPath("python3"); err != nil { + t.Skip("python3 not available") + } + root, err := Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + site := t.TempDir() + if err := os.WriteFile(filepath.Join(site, "hello.py"), []byte("value = 1\n"), 0o644); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + if err := Doctor(ctx, "/usr/bin/python3", []string{site}, []string{"hello"}, true, []string{site}); err != nil { + t.Fatal(err) + } + shim := filepath.Join(root.BinDir(), "pip") + out, err := exec.Command("bwrap", "--unshare-all", "--die-with-parent", "--ro-bind", "/", "/", + "--tmpfs", "/tmp", "--ro-bind", root.BinDir(), root.BinDir(), "--", shim).CombinedOutput() + if err == nil || !strings.Contains(string(out), "python_env") { + t.Fatalf("pip shim err=%v out=%s", err, out) + } + netOut, err := exec.Command("bwrap", "--unshare-net", "--die-with-parent", "--ro-bind", "/", "/", + "--", "/usr/bin/python3", "-c", "import socket; socket.create_connection(('1.1.1.1', 443), 2)").CombinedOutput() + if err == nil { + t.Fatalf("sandbox network should be unreachable, out=%s", netOut) + } +} diff --git a/internal/execenv/shim.go b/internal/execenv/shim.go new file mode 100644 index 0000000..081f08e --- /dev/null +++ b/internal/execenv/shim.go @@ -0,0 +1,22 @@ +package execenv + +import ( + "os" + "path/filepath" +) + +const pipShim = `#!/bin/sh +echo "沙箱不联网安装。请使用 python_env 工具申请扩展包:python_env request,packages 写包名,reason 写用途。" >&2 +exit 1 +` + +func writePipShim(dir string) error { + body := []byte(pipShim) + for _, name := range []string{"pip", "pip3"} { + path := filepath.Join(dir, name) + if err := os.WriteFile(path, body, 0o755); err != nil { + return err + } + } + return nil +} diff --git a/internal/execenv/wheel.go b/internal/execenv/wheel.go new file mode 100644 index 0000000..417a722 --- /dev/null +++ b/internal/execenv/wheel.go @@ -0,0 +1,112 @@ +package execenv + +import ( + "archive/zip" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// UnpackWheel 把 wheel 解进 site-packages。wheel 是 zip;这里不执行任何入口脚本。 +func UnpackWheel(wheelPath, sitePackages string) error { + zr, err := zip.OpenReader(wheelPath) + if err != nil { + return fmt.Errorf("execenv: open wheel: %w", err) + } + defer zr.Close() + if err := os.MkdirAll(sitePackages, 0o755); err != nil { + return err + } + root := filepath.Clean(sitePackages) + for _, f := range zr.File { + name := filepath.Clean(f.Name) + if name == "." || strings.HasPrefix(name, "..") || filepath.IsAbs(name) { + return fmt.Errorf("execenv: wheel path escapes: %s", f.Name) + } + // 安装器脚本不落地,避免把「解包」变成「执行」。 + if strings.Contains(name, ".data/scripts/") { + continue + } + dest := filepath.Join(root, name) + if !strings.HasPrefix(dest, root+string(os.PathSeparator)) && dest != root { + return fmt.Errorf("execenv: wheel path escapes: %s", f.Name) + } + if f.FileInfo().IsDir() { + if err := os.MkdirAll(dest, 0o755); err != nil { + return err + } + continue + } + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return err + } + rc, err := f.Open() + if err != nil { + return err + } + out, err := os.OpenFile(dest, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644) + if err != nil { + rc.Close() + return err + } + _, cerr := io.Copy(out, rc) + out.Close() + rc.Close() + if cerr != nil { + return cerr + } + } + return nil +} + +// BuildLayer 把已经在 wheelhouse 里的 wheel 解进新层。目录已存在则直接复用。 +func (r *Root) BuildLayer(l Lock) (string, error) { + id, err := r.WriteLock(l) + if err != nil { + return "", err + } + dest := r.LayerDir(id) + if st, err := os.Stat(dest); err == nil && st.IsDir() { + return id, nil + } + for _, pkg := range l.Packages { + wheel := filepath.Join(r.Wheelhouse(), pkg.SHA256+".whl") + if _, err := os.Stat(wheel); err != nil { + alt := filepath.Join(r.Wheelhouse(), pkg.Filename) + if _, err2 := os.Stat(alt); err2 != nil { + return "", fmt.Errorf("execenv: wheel missing for %s", pkg.Name) + } + wheel = alt + } + sum, err := fileSHA256(wheel) + if err != nil { + return "", err + } + if pkg.SHA256 != "" && !strings.EqualFold(sum, pkg.SHA256) { + return "", fmt.Errorf("execenv: hash mismatch for %s: got %s want %s", pkg.Name, sum, pkg.SHA256) + } + if err := UnpackWheel(wheel, dest); err != nil { + _ = os.RemoveAll(filepath.Dir(dest)) + return "", err + } + } + if err := os.MkdirAll(dest, 0o755); err != nil { + return "", err + } + return id, nil +} + +func fileSHA256(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + h := sha256New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return h.Sum(), nil +} diff --git a/internal/ingest/docgen_roundtrip_test.go b/internal/ingest/docgen_roundtrip_test.go index 05abe8a..13132f4 100644 --- a/internal/ingest/docgen_roundtrip_test.go +++ b/internal/ingest/docgen_roundtrip_test.go @@ -1,8 +1,11 @@ package ingest import ( + "context" "testing" + "time" + "agent-document-renderer/render" core "agent-system-core" "agent-system-backend/internal/docgen" @@ -13,11 +16,14 @@ import ( // 预览」链路闭合的保证(生成侧改动须两头痛检查)。 func TestParseDocxFromDocgen(t *testing.T) { md := "# 周会纪要\n\n## 一、基本信息\n\n| 项目 | 内容 |\n|---|---|\n| 时间 | 2026-09-30 |\n\n## 二、要点\n\n- 架构定案\n- 排版齐全\n\n结论段。" - b, err := docgen.Build("", md) + delivery, err := docgen.Generate(context.Background(), docgen.Request{ + DocumentID: "roundtrip", Title: "周会纪要", DocumentType: "general", Language: "zh-CN", + Markdown: []byte(md), Mode: render.ModePreview, FrozenAt: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC), RequestedBy: "test", + }) if err != nil { - t.Fatalf("docgen.Build: %v", err) + t.Fatalf("docgen.Generate: %v", err) } - blocks, err := parseDocx(b) + blocks, err := parseDocx(delivery.DOCX.Bytes) if err != nil { t.Fatalf("parseDocx 回读失败: %v", err) } @@ -28,8 +34,8 @@ func TestParseDocxFromDocgen(t *testing.T) { var sawArch bool for _, blk := range blocks { switch { - case blk.Type == core.BlockHeading && blk.Level == 3 && blk.Text == "周会纪要": - title++ // Title 样式在回读侧归 level 3(headingLevel 的 title 映射)。 + case blk.Type == core.BlockHeading && blk.Level == 1 && blk.Text == "周会纪要": + title++ // shared renderer preserves the canonical H1 instead of adding a duplicate Title paragraph. case blk.Type == core.BlockHeading && blk.Level == 2: h2++ if blk.Text != "一、基本信息" && blk.Text != "二、要点" { @@ -48,7 +54,7 @@ func TestParseDocxFromDocgen(t *testing.T) { } } if title != 1 { - t.Errorf("标题段(Title)回读数 = %d, want 1(blocks: %+v)", title, blocks) + t.Errorf("canonical H1 回读数 = %d, want 1(blocks: %+v)", title, blocks) } if h2 != 2 { t.Errorf("H2 数量 = %d, want 2(blocks: %+v)", h2, blocks) diff --git a/internal/ingest/image_test.go b/internal/ingest/image_test.go new file mode 100644 index 0000000..7a352c7 --- /dev/null +++ b/internal/ingest/image_test.go @@ -0,0 +1,28 @@ +package ingest + +import ( + "strings" + "testing" +) + +func TestParsePNGBecomesImageBlock(t *testing.T) { + doc := Parse("chart.png", "image/png", onePixelPNG) + if doc.Meta.Parser != "builtin:image" || len(doc.Blocks) != 1 || doc.Blocks[0].Ref == "" { + t.Fatalf("%+v warnings=%v", doc.Meta, doc.Warnings) + } + if len(doc.Warnings) == 0 || !strings.Contains(doc.Warnings[0], "按图像发送") { + t.Fatalf("warnings %v", doc.Warnings) + } + svg := Parse("logo.svg", "image/svg+xml", []byte("")) + if len(svg.Blocks) != 0 || len(svg.Warnings) == 0 || strings.Contains(svg.Warnings[0], "不走文本解析") { + t.Fatalf("svg blocks=%d warnings=%v", len(svg.Blocks), svg.Warnings) + } +} + +var onePixelPNG = []byte{ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, + 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x50, + 0x0f, 0x00, 0x04, 0x85, 0x01, 0x80, 0x84, 0xa9, 0x8c, 0x21, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, + 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, +} diff --git a/internal/ingest/ingest.go b/internal/ingest/ingest.go index e409c49..78a95a9 100644 --- a/internal/ingest/ingest.go +++ b/internal/ingest/ingest.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "agent-system-backend/internal/vision" core "agent-system-core" pdflib "github.com/ledongthuc/pdf" ) @@ -87,7 +88,15 @@ func Parse(filename, mime string, content []byte) core.ParsedDocument { doc.Warnings = append(doc.Warnings, "旧版二进制格式 ."+ext+" 不解析:请转存为 ."+legacyOfficeTarget(ext)+" 后重传") default: if isImage(ext) { - doc.Warnings = append(doc.Warnings, "图片不走文本解析(OCR 为 MinerU 深度档,远期)") + block, warn, ok := vision.PrepareImage(filename, mime, content) + if warn != "" { + doc.Warnings = append(doc.Warnings, warn) + } + if ok { + doc.Meta.Parser = "builtin:image" + doc.Blocks = []core.DocBlock{block} + doc.Meta.Confidence = 1 + } } else { // 未知类型按纯文本尝试(二进制内容会产生乱码 warning)。 text := string(content) @@ -440,6 +449,7 @@ func ParsersDescription() []core.ParserDesc { {ID: "builtin:xlsx", Formats: []string{"xlsx", "xlsm"}, Source: core.SourceBuiltin}, {ID: "builtin:pptx", Formats: []string{"pptx", "pptm", "ppsx"}, Source: core.SourceBuiltin}, {ID: "builtin:legacy-office", Formats: []string{"doc", "xls", "ppt"}, Source: core.SourceBuiltin, Note: "不解析;提示转存现代格式后重传"}, + {ID: "builtin:image", Formats: []string{"png", "jpg", "jpeg", "gif", "webp"}, Source: core.SourceBuiltin, Note: "随当前轮消息以图像发送;历史轮只留文字占位"}, {ID: "mineru", Formats: []string{"pdf", "png", "jpg"}, Source: core.SourcePlugin, Note: "远期可选:深度解析与交叉验证(默认不部署)"}, } } diff --git a/internal/instanceguard/guard.go b/internal/instanceguard/guard.go new file mode 100644 index 0000000..c086727 --- /dev/null +++ b/internal/instanceguard/guard.go @@ -0,0 +1,199 @@ +// Package instanceguard 强制同一 SQLite 数据文件只有一个活动服务。 +// 锁在打开数据库(迁移发生在打开内部)之前取得。账户 CLI 不使用本包。 +package instanceguard + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "strconv" + "strings" + + "agent-system-backend/internal/store" +) + +var ( + ErrBusy = errors.New("instance guard: sqlite service already running") + ErrHardlink = errors.New("instance guard: sqlite database has a hardlink alias") + ErrDBReplaced = errors.New("instance guard: sqlite database replaced") + ErrDBMissing = errors.New("instance guard: recorded sqlite database is missing") + ErrLockRebuilt = errors.New("instance guard: lock file was rebuilt") + ErrLockCorrupt = errors.New("instance guard: lock record is corrupt") + ErrInstanceMismatch = errors.New("instance guard: instance id mismatch") + ErrUnsupported = errors.New("instance guard: sqlite activity lock is not available on this platform") + ErrLockAliased = errors.New("instance guard: lock file aliases the sqlite database") +) + +// Record 是锁文件里记下的数据库身份。没有记录表示锁文件是新建的。 +type Record struct { + DBDev uint64 + DBIno uint64 + InstanceID string + LockGen string +} + +// Lease 持有活动服务锁,直到 Release。关闭顺序由调用方保证:先关存储,再 Release。 +type Lease struct { + fd int + dbPath string + lockDev uint64 + lockIno uint64 + dbDev uint64 + dbIno uint64 + dbExists bool + record *Record + parseErr error + release func() +} + +// Release 放开锁并关闭锁文件描述符。锁文件留在磁盘上。 +func (l *Lease) Release() { + if l == nil || l.release == nil { + return + } + l.release() + l.release = nil +} + +// DBPath 是解析后的数据库路径。符号链接指向真实文件。 +func (l *Lease) DBPath() string { + if l == nil { + return "" + } + return l.dbPath +} + +// AutomaticRefusal 在打开数据库之前调用。 +// 尚无记录时返回 nil(首次建立,含旧库升级)。硬链接在 Acquire 阶段已经拒绝。 +func (l *Lease) AutomaticRefusal() error { + if l.parseErr != nil { + return l.parseErr + } + if l.record == nil { + return nil + } + if !l.dbExists { + return ErrDBMissing + } + if l.record.DBDev != l.dbDev || l.record.DBIno != l.dbIno { + return ErrDBReplaced + } + return nil +} + +// BindOptions 控制守卫建立后的比对。 +type BindOptions struct { + Adopt bool + InstanceID string +} + +// Bind 在数据库已经打开并完成迁移之后调用。 +// 锁文件 inode 与表不一致则拒绝,除非 Adopt。通过时重写锁文件内容与表。 +func Bind(st *store.Store, lease *Lease, opt BindOptions) error { + if lease == nil { + return errors.New("instance guard: nil lease") + } + marker, ok, err := st.LoadInstanceGuard() + if err != nil { + return err + } + if ok && !opt.Adopt { + inodeMismatch := marker.LockDev != lease.lockDev || marker.LockIno != lease.lockIno + genMismatch := marker.LockGen != "" && (lease.record == nil || lease.record.LockGen != marker.LockGen) + if inodeMismatch || genMismatch { + return ErrLockRebuilt + } + } + id := opt.InstanceID + if ok && marker.InstanceID != "" && id != "" && marker.InstanceID != id && !opt.Adopt { + return ErrInstanceMismatch + } + if id == "" && ok { + id = marker.InstanceID + } + dev, ino, exists, err := lease.currentDB() + if err != nil { + return err + } + if !exists { + return ErrDBMissing + } + lease.dbDev, lease.dbIno, lease.dbExists = dev, ino, true + gen := "" + if ok && marker.LockGen != "" && !opt.Adopt { + gen = marker.LockGen + } else { + gen, err = newLockGen() + if err != nil { + return err + } + } + next := store.InstanceGuardMarker{ + DBDev: dev, DBIno: ino, LockDev: lease.lockDev, LockIno: lease.lockIno, + InstanceID: id, LockGen: gen, + } + if err := lease.writeRecord(dev, ino, id, gen); err != nil { + return err + } + if !ok || opt.Adopt || marker != next { + if err := st.SaveInstanceGuard(next); err != nil { + return err + } + } + return nil +} + +func newLockGen() (string, error) { + buf := make([]byte, 16) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return hex.EncodeToString(buf), nil +} + +func parseRecord(raw string) (*Record, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + parts := strings.Split(raw, "\t") + if parts[0] != "v1" { + return nil, ErrLockCorrupt + } + rec := &Record{} + seen := map[string]bool{} + for _, part := range parts[1:] { + key, val, ok := strings.Cut(part, "=") + if !ok || seen[key] || strings.ContainsAny(val, " \t") { + return nil, ErrLockCorrupt + } + seen[key] = true + switch key { + case "db_dev": + n, err := strconv.ParseUint(val, 10, 64) + if err != nil { + return nil, ErrLockCorrupt + } + rec.DBDev = n + case "db_ino": + n, err := strconv.ParseUint(val, 10, 64) + if err != nil { + return nil, ErrLockCorrupt + } + rec.DBIno = n + case "instance_id": + rec.InstanceID = val + case "lock_gen": + if val == "" { + return nil, ErrLockCorrupt + } + rec.LockGen = val + default: + return nil, ErrLockCorrupt + } + } + if !seen["db_dev"] || !seen["db_ino"] || !seen["instance_id"] { + return nil, ErrLockCorrupt + } + return rec, nil +} diff --git a/internal/instanceguard/guard_other.go b/internal/instanceguard/guard_other.go new file mode 100644 index 0000000..924f5ea --- /dev/null +++ b/internal/instanceguard/guard_other.go @@ -0,0 +1,18 @@ +//go:build !unix + +package instanceguard + +import "fmt" + +// Acquire 在非 unix 平台拒绝 SQLite 活动服务。PostgreSQL 档不调用本函数。 +func Acquire(string) (*Lease, error) { + return nil, ErrUnsupported +} + +func (l *Lease) writeRecord(uint64, uint64, string, string) error { + return ErrUnsupported +} + +func (l *Lease) currentDB() (uint64, uint64, bool, error) { + return 0, 0, false, fmt.Errorf("%w", ErrUnsupported) +} diff --git a/internal/instanceguard/guard_test.go b/internal/instanceguard/guard_test.go new file mode 100644 index 0000000..4a4dfb2 --- /dev/null +++ b/internal/instanceguard/guard_test.go @@ -0,0 +1,406 @@ +//go:build unix + +package instanceguard + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "agent-system-backend/internal/store" +) + +func TestAcquireSameResourceAcrossAliases(t *testing.T) { + base := t.TempDir() + real := filepath.Join(base, "real") + if err := os.Mkdir(real, 0o755); err != nil { + t.Fatal(err) + } + link := filepath.Join(base, "link") + if err := os.Symlink(real, link); err != nil { + t.Fatal(err) + } + held, err := Acquire(link) + if err != nil { + t.Fatal(err) + } + defer held.Release() + if _, err := Acquire(real); !errors.Is(err, ErrBusy) { + t.Fatalf("symlink alias: %v", err) + } + + wd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(base); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(wd) }) + if _, err := Acquire(filepath.Join(".", "real")); !errors.Is(err, ErrBusy) { + t.Fatalf("relative path: %v", err) + } +} + +func TestHardlinkRejected(t *testing.T) { + dir := t.TempDir() + db := filepath.Join(dir, "agent-system.db") + st, err := store.OpenSQLite(db) + if err != nil { + t.Fatal(err) + } + if err := st.Close(); err != nil { + t.Fatal(err) + } + if err := os.Link(db, filepath.Join(dir, "alias.db")); err != nil { + t.Fatal(err) + } + if _, err := Acquire(dir); !errors.Is(err, ErrHardlink) { + t.Fatalf("hardlink: %v", err) + } +} + +func TestDatabaseReplacementRefusedBeforeOpen(t *testing.T) { + dir := t.TempDir() + st, release, err := openAndBind(t, dir, "") + if err != nil { + t.Fatal(err) + } + db := filepath.Join(dir, "agent-system.db") + release() + _ = st + if err := os.Remove(db); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(db + ".lock") + if err != nil { + t.Fatal(err) + } + rec, err := parseRecord(string(raw)) + if err != nil || rec == nil { + t.Fatal(err) + } + for i := 0; i < 16; i++ { + if err := os.WriteFile(db, []byte("replaced"), 0o600); err != nil { + t.Fatal(err) + } + _, ino, exists, err := statFile(db) + if err != nil { + t.Fatal(err) + } + if exists && ino != rec.DBIno { + break + } + if err := os.Remove(db); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "inode-burn-"+string(rune('a'+i))), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + } + lease, err := Acquire(dir) + if err != nil { + t.Fatal(err) + } + defer lease.Release() + if err := lease.AutomaticRefusal(); !errors.Is(err, ErrDBReplaced) { + t.Fatalf("replaced db: %v", err) + } + if _, err := os.Stat(db); err != nil { + t.Fatal(err) + } +} + +func TestMissingRecordedDatabaseRefused(t *testing.T) { + dir := t.TempDir() + _, release, err := openAndBind(t, dir, "") + if err != nil { + t.Fatal(err) + } + release() + db := filepath.Join(dir, "agent-system.db") + if err := os.Remove(db); err != nil { + t.Fatal(err) + } + lease, err := Acquire(dir) + if err != nil { + t.Fatal(err) + } + defer lease.Release() + if err := lease.AutomaticRefusal(); !errors.Is(err, ErrDBMissing) { + t.Fatalf("missing db: %v", err) + } +} + +func TestRebuiltLockRefusedUntilAdopt(t *testing.T) { + dir := t.TempDir() + st, release, err := openAndBind(t, dir, "inst-a") + if err != nil { + t.Fatal(err) + } + release() + _ = st.Close() + if err := os.Remove(filepath.Join(dir, "agent-system.db.lock")); err != nil { + t.Fatal(err) + } + lease, err := Acquire(dir) + if err != nil { + t.Fatal(err) + } + if err := lease.AutomaticRefusal(); err != nil { + lease.Release() + t.Fatal(err) + } + opened, err := store.OpenSQLite(lease.DBPath()) + if err != nil { + lease.Release() + t.Fatal(err) + } + err = Bind(opened, lease, BindOptions{InstanceID: "inst-a"}) + _ = opened.Close() + lease.Release() + if !errors.Is(err, ErrLockRebuilt) { + t.Fatalf("rebuilt lock: %v", err) + } + + adopted, err := Acquire(dir) + if err != nil { + t.Fatal(err) + } + defer adopted.Release() + opened, err = store.OpenSQLite(adopted.DBPath()) + if err != nil { + t.Fatal(err) + } + defer opened.Close() + if err := Bind(opened, adopted, BindOptions{Adopt: true, InstanceID: "inst-a"}); err != nil { + t.Fatal(err) + } +} + +func TestClearedLockRecordRefusedWhenInodeStays(t *testing.T) { + dir := t.TempDir() + st, release, err := openAndBind(t, dir, "inst-a") + if err != nil { + t.Fatal(err) + } + release() + _ = st.Close() + lockPath := filepath.Join(dir, "agent-system.db.lock") + if err := os.Truncate(lockPath, 0); err != nil { + t.Fatal(err) + } + lease, err := Acquire(dir) + if err != nil { + t.Fatal(err) + } + opened, err := store.OpenSQLite(lease.DBPath()) + if err != nil { + lease.Release() + t.Fatal(err) + } + err = Bind(opened, lease, BindOptions{InstanceID: "inst-a"}) + _ = opened.Close() + lease.Release() + if !errors.Is(err, ErrLockRebuilt) { + t.Fatalf("cleared lock: %v", err) + } +} + +func TestInstanceMismatchPreservesEmpty(t *testing.T) { + dir := t.TempDir() + st, release, err := openAndBind(t, dir, "inst-a") + if err != nil { + t.Fatal(err) + } + release() + _ = st.Close() + + lease, err := Acquire(dir) + if err != nil { + t.Fatal(err) + } + opened, err := store.OpenSQLite(lease.DBPath()) + if err != nil { + lease.Release() + t.Fatal(err) + } + err = Bind(opened, lease, BindOptions{InstanceID: "inst-b"}) + _ = opened.Close() + lease.Release() + if !errors.Is(err, ErrInstanceMismatch) { + t.Fatalf("mismatch: %v", err) + } + + lease, err = Acquire(dir) + if err != nil { + t.Fatal(err) + } + defer lease.Release() + opened, err = store.OpenSQLite(lease.DBPath()) + if err != nil { + t.Fatal(err) + } + defer opened.Close() + if err := Bind(opened, lease, BindOptions{}); err != nil { + t.Fatal(err) + } + marker, ok, err := opened.LoadInstanceGuard() + if err != nil || !ok || marker.InstanceID != "inst-a" { + t.Fatalf("empty id should keep marker: ok=%v id=%q err=%v", ok, marker.InstanceID, err) + } +} + +func TestLockReleasedAfterKill(t *testing.T) { + if os.Getenv("U8_GUARD_ROLE") == "hold" { + lease, err := Acquire(os.Getenv("U8_GUARD_DIR")) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(os.Getenv("U8_GUARD_READY"), []byte("ready"), 0o600); err != nil { + t.Fatal(err) + } + time.Sleep(30 * time.Second) + lease.Release() + return + } + dir := t.TempDir() + ready := filepath.Join(dir, "ready") + cmd := exec.Command(os.Args[0], "-test.run=TestLockReleasedAfterKill") + cmd.Env = append(os.Environ(), "U8_GUARD_ROLE=hold", "U8_GUARD_DIR="+dir, "U8_GUARD_READY="+ready) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if cmd.Process != nil { + _ = cmd.Process.Kill() + _, _ = cmd.Process.Wait() + } + }) + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + if _, err := os.Stat(ready); err == nil { + break + } + time.Sleep(20 * time.Millisecond) + } + if _, err := os.Stat(ready); err != nil { + t.Fatal(err) + } + if _, err := Acquire(dir); !errors.Is(err, ErrBusy) { + t.Fatalf("held: %v", err) + } + if err := cmd.Process.Kill(); err != nil { + t.Fatal(err) + } + _, _ = cmd.Process.Wait() + lease, err := Acquire(dir) + if err != nil { + t.Fatal(err) + } + lease.Release() +} + +func TestMissingDataDirIsCreatedBeforeLock(t *testing.T) { + dir := filepath.Join(t.TempDir(), "nested", "data") + lease, err := Acquire(dir) + if err != nil { + t.Fatal(err) + } + lease.Release() + if _, err := os.Stat(dir); err != nil { + t.Fatal(err) + } +} + +func TestLockSymlinkCannotAliasDatabase(t *testing.T) { + dir := t.TempDir() + db := filepath.Join(dir, "agent-system.db") + st, err := store.OpenSQLite(db) + if err != nil { + t.Fatal(err) + } + if err := st.Close(); err != nil { + t.Fatal(err) + } + if err := os.Remove(db + ".lock"); err != nil && !os.IsNotExist(err) { + t.Fatal(err) + } + if err := os.Symlink(db, db+".lock"); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(db) + if err != nil { + t.Fatal(err) + } + if _, err := Acquire(dir); !errors.Is(err, ErrLockAliased) { + t.Fatalf("symlink lock: %v", err) + } + after, err := os.ReadFile(db) + if err != nil { + t.Fatal(err) + } + if string(before) != string(after) { + t.Fatal("aliased lock wrote into the database") + } +} + +func TestSymlinkedDatabaseLocksResolvedFile(t *testing.T) { + base := t.TempDir() + realDir := filepath.Join(base, "real") + aliasDir := filepath.Join(base, "alias") + if err := os.Mkdir(realDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(aliasDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(realDir, "agent-system.db"), []byte{}, 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(realDir, "agent-system.db"), filepath.Join(aliasDir, "agent-system.db")); err != nil { + t.Fatal(err) + } + held, err := Acquire(aliasDir) + if err != nil { + t.Fatal(err) + } + defer held.Release() + if _, err := os.Stat(filepath.Join(realDir, "agent-system.db.lock")); err != nil { + t.Fatal(err) + } + if _, err := Acquire(realDir); !errors.Is(err, ErrBusy) { + t.Fatalf("resolved db lock: %v", err) + } +} + +func openAndBind(t *testing.T, dir, instanceID string) (*store.Store, func(), error) { + t.Helper() + lease, err := Acquire(dir) + if err != nil { + return nil, nil, err + } + if err := lease.AutomaticRefusal(); err != nil { + lease.Release() + return nil, nil, err + } + st, err := store.OpenSQLite(lease.DBPath()) + if err != nil { + lease.Release() + return nil, nil, err + } + if err := Bind(st, lease, BindOptions{InstanceID: instanceID}); err != nil { + _ = st.Close() + lease.Release() + return nil, nil, err + } + return st, func() { + _ = st.Close() + lease.Release() + }, nil +} diff --git a/internal/instanceguard/guard_unix.go b/internal/instanceguard/guard_unix.go new file mode 100644 index 0000000..ecc293f --- /dev/null +++ b/internal/instanceguard/guard_unix.go @@ -0,0 +1,201 @@ +//go:build unix + +package instanceguard + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "syscall" +) + +// Acquire 取得 SQLite 活动服务锁。失败时不返回 Lease。 +// 硬链接别名直接拒绝。锁文件被并发替换时重试数次后拒绝。 +func Acquire(dataDir string) (*Lease, error) { + dir, err := normalizeDataDir(dataDir) + if err != nil { + return nil, err + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, err + } + _, dbPath, err := resolveDB(dir) + if err != nil { + return nil, err + } + if err := rejectHardlink(dbPath); err != nil { + return nil, err + } + lockPath := dbPath + ".lock" + var last error + for attempt := 0; attempt < 5; attempt++ { + lease, retry, err := acquireOnce(dbPath, lockPath) + if err == nil && !retry { + return lease, nil + } + if retry { + last = err + continue + } + return nil, err + } + if last == nil { + last = errorsRetry() + } + return nil, last +} + +func errorsRetry() error { + return fmt.Errorf("instance guard: lock file inode changed while locking") +} + +func acquireOnce(dbPath, lockPath string) (*Lease, bool, error) { + fd, err := syscall.Open(lockPath, syscall.O_RDWR|syscall.O_CREAT|syscall.O_CLOEXEC|syscall.O_NOFOLLOW, 0o600) + if err != nil { + if err == syscall.ELOOP { + return nil, false, ErrLockAliased + } + return nil, false, err + } + fail := func(err error) (*Lease, bool, error) { + syscall.Close(fd) + return nil, false, err + } + if err := syscall.Flock(fd, syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + syscall.Close(fd) + if err == syscall.EWOULDBLOCK || err == syscall.EAGAIN { + return nil, false, ErrBusy + } + return nil, false, err + } + var held syscall.Stat_t + if err := syscall.Fstat(fd, &held); err != nil { + return fail(err) + } + var named syscall.Stat_t + if err := syscall.Stat(lockPath, &named); err != nil { + return fail(err) + } + if held.Ino != named.Ino || held.Dev != named.Dev { + syscall.Flock(fd, syscall.LOCK_UN) + syscall.Close(fd) + return nil, true, errorsRetry() + } + buf := make([]byte, 512) + n, err := syscall.Read(fd, buf) + if err != nil { + return fail(err) + } + rec, perr := parseRecord(string(buf[:n])) + dbDev, dbIno, dbExists, err := statFile(dbPath) + if err != nil { + return fail(err) + } + if dbExists && dbDev == uint64(held.Dev) && dbIno == uint64(held.Ino) { + syscall.Flock(fd, syscall.LOCK_UN) + syscall.Close(fd) + return nil, false, ErrLockAliased + } + lease := &Lease{ + fd: fd, + dbPath: dbPath, + lockDev: uint64(held.Dev), + lockIno: uint64(held.Ino), + dbDev: dbDev, + dbIno: dbIno, + dbExists: dbExists, + record: rec, + parseErr: perr, + release: func() { + syscall.Flock(fd, syscall.LOCK_UN) + syscall.Close(fd) + }, + } + return lease, false, nil +} + +func (l *Lease) writeRecord(dev, ino uint64, instanceID, lockGen string) error { + if strings.ContainsAny(instanceID, " \t\r\n") { + return fmt.Errorf("instance guard: instance id contains whitespace") + } + line := fmt.Sprintf("v1\tdb_dev=%d\tdb_ino=%d\tinstance_id=%s\tlock_gen=%s\n", dev, ino, instanceID, lockGen) + if _, err := syscall.Seek(l.fd, 0, 0); err != nil { + return err + } + if _, err := syscall.Write(l.fd, []byte(line)); err != nil { + return err + } + return syscall.Ftruncate(l.fd, int64(len(line))) +} + +func (l *Lease) currentDB() (dev, ino uint64, exists bool, err error) { + return statFile(l.dbPath) +} + +func normalizeDataDir(dataDir string) (string, error) { + if strings.TrimSpace(dataDir) == "" { + dataDir = "." + } + abs, err := filepath.Abs(dataDir) + if err != nil { + return "", err + } + if _, err := os.Lstat(abs); err == nil { + return filepath.EvalSymlinks(abs) + } else if !os.IsNotExist(err) { + return "", err + } + parent, base := filepath.Split(abs) + parent = filepath.Clean(parent) + if _, err := os.Lstat(parent); err == nil { + resolved, err := filepath.EvalSymlinks(parent) + if err != nil { + return "", err + } + return filepath.Join(resolved, base), nil + } + return abs, nil +} + +func resolveDB(dataDir string) (logical, resolved string, err error) { + logical = filepath.Join(dataDir, "agent-system.db") + fi, err := os.Lstat(logical) + if err != nil { + if os.IsNotExist(err) { + return logical, logical, nil + } + return "", "", err + } + if fi.Mode()&os.ModeSymlink != 0 { + resolved, err = filepath.EvalSymlinks(logical) + return logical, resolved, err + } + return logical, logical, nil +} + +func rejectHardlink(dbPath string) error { + _, _, exists, err := statFile(dbPath) + if err != nil || !exists { + return err + } + var st syscall.Stat_t + if err := syscall.Stat(dbPath, &st); err != nil { + return err + } + if st.Nlink > 1 { + return ErrHardlink + } + return nil +} + +func statFile(path string) (dev, ino uint64, exists bool, err error) { + var st syscall.Stat_t + if err = syscall.Stat(path, &st); err != nil { + if os.IsNotExist(err) { + return 0, 0, false, nil + } + return 0, 0, false, err + } + return uint64(st.Dev), uint64(st.Ino), true, nil +} diff --git a/internal/plugins/account_reload_test.go b/internal/plugins/account_reload_test.go new file mode 100644 index 0000000..6db28bd --- /dev/null +++ b/internal/plugins/account_reload_test.go @@ -0,0 +1,137 @@ +package plugins + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + + "agent-system-backend/internal/store" +) + +func TestLoadDirAccountCheckedRejectsDisabledActorWithoutEffects(t *testing.T) { + host, reg, dir := newHost(t) + if err := host.st.CreateAccount("admin", "", "hash", true); err != nil { + t.Fatal(err) + } + if err := host.st.CreateAccount("alice", "", "hash", false); err != nil { + t.Fatal(err) + } + writeExamplePlugin(t, dir, "example") + if err := host.LoadDir()["example"]; err != nil { + t.Fatal(err) + } + if _, _, ok := reg.Get("http_echo"); !ok { + t.Fatal("fixture plugin was not registered") + } + if err := os.RemoveAll(filepath.Join(dir, "example")); err != nil { + t.Fatal(err) + } + root := filepath.Join(dir, "side") + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatal(err) + } + mark := filepath.Join(t.TempDir(), "sidecar-ran") + manifest := `{"id":"side","name":"Side","version":"1.0.0","apiVersion":"1.0","backend":{"mode":"sidecar","command":"/bin/touch","args":["` + mark + `"],"schemaVersion":1},"provides":{}}` + if err := os.WriteFile(filepath.Join(root, "plugin.json"), []byte(manifest), 0o644); err != nil { + t.Fatal(err) + } + account, err := host.st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + if _, err := host.st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + + if _, err := host.LoadDirAccountChecked("alice", false); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("disabled reload: %v", err) + } + if _, _, ok := reg.Get("http_echo"); !ok || len(host.List()) != 1 { + t.Fatal("disabled reload changed the existing process registry") + } + if _, err := os.Stat(mark); !os.IsNotExist(err) { + t.Fatalf("disabled reload started sidecar: %v", err) + } + plugins, err := host.st.ListPlugins() + if err != nil { + t.Fatal(err) + } + if len(plugins) != 1 || plugins[0].ID != "example" { + t.Fatalf("disabled reload changed plugin rows: %+v", plugins) + } + if version, err := host.st.PluginSchemaVersion("side"); err != nil || version != 0 { + t.Fatalf("disabled reload changed schema version: %d, %v", version, err) + } +} + +func TestLoadDirAccountCheckedWritesForEnabledActor(t *testing.T) { + host, _, dir := newHost(t) + if err := host.st.CreateAccount("alice", "", "hash", false); err != nil { + t.Fatal(err) + } + root := filepath.Join(dir, "plain") + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatal(err) + } + manifest := `{"id":"plain","name":"Plain","version":"1.0.0","apiVersion":"1.0","backend":{"schemaVersion":2},"provides":{}}` + if err := os.WriteFile(filepath.Join(root, "plugin.json"), []byte(manifest), 0o644); err != nil { + t.Fatal(err) + } + results, err := host.LoadDirAccountChecked("alice", false) + if err != nil || results["plain"] != nil { + t.Fatalf("enabled reload: %v, %+v", err, results) + } + plugins, err := host.st.ListPlugins() + if err != nil || len(plugins) != 1 || plugins[0].ID != "plain" { + t.Fatalf("plugin row: %+v, %v", plugins, err) + } + if version, err := host.st.PluginSchemaVersion("plain"); err != nil || version != 2 { + t.Fatalf("schema version: %d, %v", version, err) + } +} + +func TestLoadDirAccountCheckedRechecksBetweenPlugins(t *testing.T) { + host, _, dir := newHost(t) + if err := host.st.CreateAccount("admin", "", "hash", true); err != nil { + t.Fatal(err) + } + if err := host.st.CreateAccount("alice", "", "hash", false); err != nil { + t.Fatal(err) + } + account, err := host.st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + mark := filepath.Join(t.TempDir(), "b-ran") + for id, manifest := range map[string]string{ + "a": `{"id":"a","name":"A","version":"1.0.0","apiVersion":"1.0","provides":{}}`, + "b": `{"id":"b","name":"B","version":"1.0.0","apiVersion":"1.0","backend":{"mode":"sidecar","command":"/bin/touch","args":["` + mark + `"]},"provides":{}}`, + } { + root := filepath.Join(dir, id) + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "plugin.json"), []byte(manifest), 0o644); err != nil { + t.Fatal(err) + } + } + callbackCount := 0 + host.OnCommands = func([]json.RawMessage) error { + callbackCount++ + _, err := host.st.SetAccountEnabled("admin", account.ID, account.StateVersion, false) + return err + } + results, err := host.LoadDirAccountChecked("alice", false) + if !errors.Is(err, store.ErrAccountForbidden) || results["a"] != nil || callbackCount != 1 { + t.Fatalf("reload after callback disable: %v, %+v, callbacks=%d", err, results, callbackCount) + } + plugins, err := host.st.ListPlugins() + if err != nil || len(plugins) != 1 || plugins[0].ID != "a" { + t.Fatalf("later plugin wrote after disable: %+v, %v", plugins, err) + } + if _, err := os.Stat(mark); !os.IsNotExist(err) { + t.Fatalf("later sidecar started after disable: %v", err) + } +} diff --git a/internal/plugins/host.go b/internal/plugins/host.go index 99f55a3..2ab0126 100644 --- a/internal/plugins/host.go +++ b/internal/plugins/host.go @@ -67,14 +67,55 @@ func New(st *store.Store, reg *tools.Registry, s *settings.Service, dir string) return &Host{st: st, registry: reg, settings: s, dir: dir, loaded: map[string]*Loaded{}, sidecars: map[string]*exec.Cmd{}} } +type loadAccountGuard struct { + st *store.Store + actor string + allowImplicitDefault bool + failed error +} + +func (g *loadAccountGuard) check() error { + if g == nil { + return nil // startup and existing fixture loader retain legacy behavior + } + if g.failed != nil { + return g.failed + } + g.failed = g.st.CheckEffectiveAccountForExternalEffect(g.actor, g.allowImplicitDefault) + return g.failed +} + +func (g *loadAccountGuard) record(err error) error { + if g != nil && err != nil { + g.failed = err + } + return err +} + // LoadDir 全量装载目录(启动与热加载共用);返回每个插件的结果(失败不中断其他)。 func (h *Host) LoadDir() map[string]error { + results, _ := h.loadDir(nil) + return results +} + +// LoadDirAccountChecked reloads on behalf of an already authorized actor. +// Each Backend-owned DB write is fenced in its final transaction; process, +// sidecar and filesystem effects get a fresh state check immediately before +// execution. The caller must separately enforce plugin-management authority. +func (h *Host) LoadDirAccountChecked(actor string, allowImplicitDefault bool) (map[string]error, error) { + return h.loadDir(&loadAccountGuard{st: h.st, actor: actor, allowImplicitDefault: allowImplicitDefault}) +} + +func (h *Host) loadDir(guard *loadAccountGuard) (map[string]error, error) { + if err := guard.check(); err != nil { + return nil, err + } entries, err := os.ReadDir(h.dir) if err != nil { if os.IsNotExist(err) { - return map[string]error{} + return map[string]error{}, nil } - return map[string]error{"": err} + return map[string]error{"": err}, nil } results := map[string]error{} seen := map[string]bool{} @@ -84,12 +125,19 @@ func (h *Host) LoadDir() map[string]error { } id := e.Name() seen[id] = true - results[id] = h.loadOne(filepath.Join(h.dir, id)) + results[id] = h.loadOne(filepath.Join(h.dir, id), guard) + if guard != nil && guard.failed != nil { + return results, guard.failed + } } // 移除已消失的插件(卸载语义)。 h.mu.Lock() for id := range h.loaded { if !seen[id] { + if err := guard.check(); err != nil { + h.mu.Unlock() + return results, err + } delete(h.loaded, id) if cmd := h.sidecars[id]; cmd != nil && cmd.Process != nil { _ = cmd.Process.Kill() @@ -104,25 +152,34 @@ func (h *Host) LoadDir() map[string]error { onIntents, onCommands, onWorkflows := h.OnIntents, h.OnCommands, h.OnWorkflows h.mu.Unlock() if onCommands != nil { + if err := guard.check(); err != nil { + return results, err + } if err := onCommands(cmdOverlay); err != nil { results[""] = err } } if onIntents != nil { + if err := guard.check(); err != nil { + return results, err + } if err := onIntents(overlay); err != nil { results[""] = err } } if onWorkflows != nil { + if err := guard.check(); err != nil { + return results, err + } if err := onWorkflows(defs, scripts); err != nil { results[""] = err } } - return results + return results, nil } // loadOne 装载单个插件;任何失败返回错误且不改变既有注册(fail-closed)。 -func (h *Host) loadOne(dir string) error { +func (h *Host) loadOne(dir string, guard *loadAccountGuard) error { manifestPath := filepath.Join(dir, "plugin.json") raw, err := os.ReadFile(manifestPath) if err != nil { @@ -145,6 +202,9 @@ func (h *Host) loadOne(dir string) error { ld := &Loaded{Manifest: m, Dir: dir, Cards: map[string][]core.UINode{}, Prompts: map[string]string{}, Defaults: map[string]any{}, Scripts: map[string]string{}} // 同 ID 重载:先注销旧注册(含旧版本工具),再装载新资产。 + if err := guard.check(); err != nil { + return err + } h.registry.UnregisterBySource(core.SourcePlugin, m.ID) // tools/*.json:声明式 HTTP 工具(或含 parameters 的描述型工具)。 @@ -159,6 +219,9 @@ func (h *Host) loadOne(dir string) error { if err != nil { return fmt.Errorf("plugins: %s: tool %s: %w", m.ID, name, err) } + if err := guard.check(); err != nil { + return err + } if err := h.registry.Register(desc, exec); err != nil { return fmt.Errorf("plugins: %s: register tool %s: %w", m.ID, name, err) } @@ -261,10 +324,13 @@ func (h *Host) loadOne(dir string) error { } } - if err := h.loadDeclared(dir, m, ld); err != nil { + if err := h.loadDeclared(dir, m, ld, guard); err != nil { return err } - if err := h.startSidecar(m, dir); err != nil { + if err := h.startSidecar(m, dir, guard); err != nil { + return err + } + if err := guard.check(); err != nil { return err } h.mu.Lock() @@ -274,18 +340,31 @@ func (h *Host) loadOne(dir string) error { defs, scripts := h.collectWorkflowsLocked() onIntents, onCommands, onWorkflows := h.OnIntents, h.OnCommands, h.OnWorkflows h.mu.Unlock() - _ = h.st.UpsertPlugin(m.ID, dir, m) + if guard == nil { + _ = h.st.UpsertPlugin(m.ID, dir, m) + } else if err := h.st.UpsertPluginAccountChecked(guard.actor, m.ID, dir, m, guard.allowImplicitDefault); err != nil { + return guard.record(err) + } if onCommands != nil { + if err := guard.check(); err != nil { + return err + } if err := onCommands(cmdOverlay); err != nil { return fmt.Errorf("plugins: %s: commands rejected by command bus (kept previous): %w", m.ID, err) } } if onIntents != nil { + if err := guard.check(); err != nil { + return err + } if err := onIntents(overlay); err != nil { return fmt.Errorf("plugins: %s: intents rejected by host gate (kept previous): %w", m.ID, err) } } if onWorkflows != nil { + if err := guard.check(); err != nil { + return err + } if err := onWorkflows(defs, scripts); err != nil { return fmt.Errorf("plugins: %s: workflows rejected: %w", m.ID, err) } diff --git a/internal/plugins/host_v2.go b/internal/plugins/host_v2.go index b02b827..147ac67 100644 --- a/internal/plugins/host_v2.go +++ b/internal/plugins/host_v2.go @@ -21,7 +21,7 @@ func (h *Host) SetHostAPI(baseURL, token string) { h.hostAPI, h.hostToken = baseURL, token } -func (h *Host) startSidecar(m core.PluginManifest, dir string) error { +func (h *Host) startSidecar(m core.PluginManifest, dir string, guard *loadAccountGuard) error { if m.Backend.Mode != "sidecar" || m.Backend.Command == "" { return nil } @@ -38,10 +38,19 @@ func (h *Host) startSidecar(m core.PluginManifest, dir string) error { "AGENT_HOST_TOKEN="+h.hostToken, "AGENT_PLUGIN_ID="+m.ID, ) + if err := guard.check(); err != nil { + return err + } if err := cmd.Start(); err != nil { return fmt.Errorf("plugins: %s: sidecar: %w", m.ID, err) } h.mu.Lock() + if err := guard.check(); err != nil { + h.mu.Unlock() + _ = cmd.Process.Kill() + _ = cmd.Wait() + return err + } if h.sidecars == nil { h.sidecars = map[string]*exec.Cmd{} } @@ -54,7 +63,7 @@ func (h *Host) startSidecar(m core.PluginManifest, dir string) error { return nil } -func (h *Host) loadDeclared(dir string, m core.PluginManifest, ld *Loaded) error { +func (h *Host) loadDeclared(dir string, m core.PluginManifest, ld *Loaded, guard *loadAccountGuard) error { for _, name := range m.Provides.Skills { body, err := os.ReadFile(filepath.Join(dir, "skills", name+".md")) if err != nil { @@ -70,9 +79,15 @@ func (h *Host) loadDeclared(dir string, m core.PluginManifest, ld *Loaded) error ld.Skills[name] = string(body) if h.skillsRoot != "" { dest := filepath.Join(h.skillsRoot, "plugin-"+m.ID, name) + if err := guard.check(); err != nil { + return err + } if err := os.MkdirAll(dest, 0o755); err != nil { return err } + if err := guard.check(); err != nil { + return err + } if err := os.WriteFile(filepath.Join(dest, "SKILL.md"), body, 0o644); err != nil { return err } @@ -131,6 +146,9 @@ func (h *Host) loadDeclared(dir string, m core.PluginManifest, ld *Loaded) error ld.Pages = append(ld.Pages, item) } if m.Backend.SchemaVersion > 0 && h.st != nil { + if guard != nil { + return guard.record(h.st.SetPluginSchemaVersionAccountChecked(guard.actor, m.ID, m.Backend.SchemaVersion, guard.allowImplicitDefault)) + } cur, err := h.st.PluginSchemaVersion(m.ID) if err != nil { return err diff --git a/internal/profiles/profiles.go b/internal/profiles/profiles.go index f05e17b..a841df0 100644 --- a/internal/profiles/profiles.go +++ b/internal/profiles/profiles.go @@ -12,6 +12,9 @@ import ( const OwnerDefault = store.OwnerDefault const DefaultProfileID = "default" +const FastProfileID = "fast" +const ReasoningProfileID = "reasoning" +const WritingProfileID = "writing" // defaultSystemPrompt 默认画像不再携带常驻提示词:基座(身份/行为/谨慎/工具/风格) // 与环境段由提示词库组装(prompts 库 base.*/env.*);文档任务输出要求已技能化 @@ -22,7 +25,7 @@ func DefaultProfile() core.AgentProfile { return core.AgentProfile{ ID: DefaultProfileID, Name: "通用助手", - Description: "内置默认画像:全部工具可用,HTTP 工具执行前确认(T0 基线)。", + Description: "内置默认画像:全部工具可用,HTTP 工具执行前确认(T0 基线)。对话模型由系统设置决定。", Builtin: true, Source: "builtin", SystemPrompt: defaultSystemPrompt, @@ -35,19 +38,57 @@ func DefaultProfile() core.AgentProfile { } } +// BuiltinProfiles 是启动时幂等写入的全部内置画像。模型名是网关模型 ID, +// 端点与密钥沿用系统设置。轻量档留空则回落设置里的轻量模型。 +func BuiltinProfiles() []core.AgentProfile { + fast := DefaultProfile() + fast.ID = FastProfileID + fast.Name = "快速助手" + fast.Description = "内置画像:对话和轻量任务都用 qwen3.8-flash,适合短问答和改写。" + fast.Model = core.ModelRoute{Chat: "qwen3.8-flash", Lightweight: "qwen3.8-flash"} + + reasoning := DefaultProfile() + reasoning.ID = ReasoningProfileID + reasoning.Name = "深度推理" + reasoning.Description = "内置画像:对话用 ZHIPU/GLM-5.3-FlashX。分诊和校验仍走系统轻量模型。" + reasoning.Model = core.ModelRoute{Chat: "ZHIPU/GLM-5.3-FlashX"} + + writing := DefaultProfile() + writing.ID = WritingProfileID + writing.Name = "写作与文档" + writing.Description = "内置画像:对话用 ZHIPU/GLM-5.3-Flash。分诊和校验仍走系统轻量模型。" + writing.Model = core.ModelRoute{Chat: "ZHIPU/GLM-5.3-Flash"} + + return []core.AgentProfile{DefaultProfile(), fast, reasoning, writing} +} + +func builtinID(id string) bool { + for _, p := range BuiltinProfiles() { + if p.ID == id { + return true + } + } + return false +} + type Service struct{ st *store.Store } func New(st *store.Store) *Service { return &Service{st: st} } -// EnsureDefault 幂等写入内置画像;内置画像只读(Save 拒绝编辑),库中与内置定义 +// EnsureDefault 幂等写入全部内置画像;内置画像只读(Save 拒绝编辑),库中与内置定义 // 有差异的行只可能来自更早版本的内置定义,因此差异时升级为当前内置定义。 func (s *Service) EnsureDefault() error { - if cur, err := s.st.GetProfile(OwnerDefault, DefaultProfileID); err == nil { - if reflect.DeepEqual(cur, DefaultProfile()) { - return nil + for _, p := range BuiltinProfiles() { + if cur, err := s.st.GetProfile(OwnerDefault, p.ID); err == nil { + if reflect.DeepEqual(cur, p) { + continue + } + } + if err := s.st.SaveProfile(p); err != nil { + return err } } - return s.st.SaveProfile(DefaultProfile()) + return nil } func (s *Service) List(owner string) ([]core.AgentProfile, error) { return s.st.ListProfiles(owner) } @@ -62,7 +103,7 @@ func (s *Service) Get(owner, id string) (core.AgentProfile, error) { } func (s *Service) Save(owner string, p core.AgentProfile) error { - if p.ID == DefaultProfileID { + if builtinID(p.ID) { return errReadOnly("profiles: builtin profile is read-only; duplicate and modify") } if p.Tools.Allow == nil { @@ -72,7 +113,29 @@ func (s *Service) Save(owner string, p core.AgentProfile) error { return s.st.SaveProfile(p) } -func (s *Service) Delete(owner, id string) error { return s.st.DeleteProfile(owner, id) } +func (s *Service) Delete(owner, id string) error { + if builtinID(id) { + return errReadOnly("profiles: builtin profile is read-only") + } + return s.st.DeleteProfile(owner, id) +} + +// SaveAccountChecked is the user-facing write path. Builtin profiles remain +// read-only and the Store binds the write to the actor's effective account. +func (s *Service) SaveAccountChecked(actor string, p core.AgentProfile, allowImplicitDefault bool) error { + if p.ID == DefaultProfileID || p.Builtin { + return errReadOnly("profiles: builtin profile is read-only; duplicate and modify") + } + if p.Tools.Allow == nil { + p.Tools.Allow = []string{"*"} + } + p.Owner = actor + return s.st.SaveProfileAccountChecked(actor, p, allowImplicitDefault) +} + +func (s *Service) DeleteAccountChecked(actor, id string, allowImplicitDefault bool) error { + return s.st.DeleteProfileAccountChecked(actor, id, allowImplicitDefault) +} type errReadOnly string diff --git a/internal/profiles/profiles_fence_test.go b/internal/profiles/profiles_fence_test.go new file mode 100644 index 0000000..0097370 --- /dev/null +++ b/internal/profiles/profiles_fence_test.go @@ -0,0 +1,54 @@ +package profiles + +import ( + "errors" + "path/filepath" + "testing" + + "agent-system-backend/internal/store" + core "agent-system-core" +) + +func TestProfileServiceAccountCheckedWrites(t *testing.T) { + st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if err := st.CreateAccount("admin", "", "", true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("alice", "", "", false); err != nil { + t.Fatal(err) + } + svc := New(st) + if err := svc.EnsureDefault(); err != nil { + t.Fatal(err) + } + if err := svc.SaveAccountChecked("alice", core.AgentProfile{ID: "profile-a", Name: "A"}, false); err != nil { + t.Fatal(err) + } + got, err := st.GetProfile("alice", "profile-a") + if err != nil || got.Owner != "alice" || got.Builtin || len(got.Tools.Allow) != 1 || got.Tools.Allow[0] != "*" { + t.Fatalf("service did not normalize actor profile: %+v %v", got, err) + } + if err := svc.SaveAccountChecked("alice", core.AgentProfile{ID: DefaultProfileID}, false); err == nil { + t.Fatal("service edited builtin default") + } + if err := svc.SaveAccountChecked("alice", core.AgentProfile{ID: "forged", Builtin: true}, false); err == nil { + t.Fatal("service accepted forged builtin profile") + } + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + if err := svc.DeleteAccountChecked("alice", "profile-a", false); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("disabled actor deleted profile: %v", err) + } + if err := svc.SaveAccountChecked("alice", core.AgentProfile{ID: "profile-a", Name: "late"}, false); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("disabled actor saved profile: %v", err) + } +} diff --git a/internal/profiles/profiles_test.go b/internal/profiles/profiles_test.go new file mode 100644 index 0000000..a4ae387 --- /dev/null +++ b/internal/profiles/profiles_test.go @@ -0,0 +1,52 @@ +package profiles + +import ( + "path/filepath" + "testing" + + "agent-system-backend/internal/store" + core "agent-system-core" +) + +func TestBuiltinProfilesRoundTripAndReadOnly(t *testing.T) { + st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "p.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + svc := New(st) + if err := svc.EnsureDefault(); err != nil { + t.Fatal(err) + } + if err := svc.EnsureDefault(); err != nil { + t.Fatal(err) + } + want := map[string]string{ + DefaultProfileID: "", + FastProfileID: "qwen3.8-flash", + ReasoningProfileID: "ZHIPU/GLM-5.3-FlashX", + WritingProfileID: "ZHIPU/GLM-5.3-Flash", + } + for id, model := range want { + got, err := svc.Get(OwnerDefault, id) + if err != nil { + t.Fatal(err) + } + if !got.Builtin || got.Model.Chat != model { + t.Fatalf("%s: builtin=%v model=%q", id, got.Builtin, got.Model.Chat) + } + if err := svc.Save("local", core.AgentProfile{ID: id, Name: "改"}); err == nil { + t.Fatalf("save %s should be rejected", id) + } + if err := svc.Delete("local", id); err == nil { + t.Fatalf("delete %s should be rejected", id) + } + } + listed, err := svc.List(OwnerDefault) + if err != nil { + t.Fatal(err) + } + if len(listed) != len(want) { + t.Fatalf("listed %d", len(listed)) + } +} diff --git a/internal/projectcontext/context.go b/internal/projectcontext/context.go index a8b47d2..255c799 100644 --- a/internal/projectcontext/context.go +++ b/internal/projectcontext/context.go @@ -27,10 +27,16 @@ func Assemble(bundle extension.ProjectContextBundle, budget int) (string, extens missingRequired := false appendItem := func(item extension.ProjectContextItem) { entry := extension.ProjectContextManifestEntry{SourceID: item.SourceID, Revision: item.Revision, SourceHash: item.Hash} + if item.SourceID == "" || item.Revision == "" || item.Hash == "" { + entry.Reason = "unverifiable-source" + m.Dropped = append(m.Dropped, entry) + missingRequired = missingRequired || item.Required + return + } runes := []rune(item.Text) label := "\n[Source " + item.SourceID + " revision " + item.Revision + "]\n" cost := len(runes) + len([]rune(label)) - if cost > remaining || item.SourceID == "" { + if cost > remaining { entry.Reason = "over-budget-or-missing-source" m.Dropped = append(m.Dropped, entry) missingRequired = missingRequired || item.Required @@ -66,7 +72,7 @@ func Assemble(bundle extension.ProjectContextBundle, budget int) (string, extens } } for _, ref := range bundle.Evidence { - if ref.ID == "" || ref.SourceID == "" { + if ref.ID == "" || ref.SourceID == "" || ref.Revision == "" || ref.Hash == "" { continue } line := fmt.Sprintf("\n[Evidence ref %q source %q revision %q]\n", ref.ID, ref.SourceID, ref.Revision) diff --git a/internal/projectcontext/context_test.go b/internal/projectcontext/context_test.go index bf26a3a..abaeda3 100644 --- a/internal/projectcontext/context_test.go +++ b/internal/projectcontext/context_test.go @@ -8,7 +8,7 @@ import ( ) func TestAssembleOffersOnlyBudgetedEvidenceReferences(t *testing.T) { - bundle := extension.ProjectContextBundle{Status: extension.ProjectContextReady, ProjectID: "p1", Evidence: []extension.ProjectEvidenceRef{{ID: "ref-1", SourceID: "doc-1", Revision: "r2"}, {ID: strings.Repeat("x", 1000), SourceID: "doc-2"}}} + bundle := extension.ProjectContextBundle{Status: extension.ProjectContextReady, ProjectID: "p1", Evidence: []extension.ProjectEvidenceRef{{ID: "ref-1", SourceID: "doc-1", Revision: "r2", Hash: "h1"}, {ID: strings.Repeat("x", 1000), SourceID: "doc-2", Revision: "r2", Hash: "h2"}}} text, manifest, err := Assemble(bundle, 180) if err != nil { t.Fatal(err) @@ -19,7 +19,7 @@ func TestAssembleOffersOnlyBudgetedEvidenceReferences(t *testing.T) { } func TestValidateCurrentRejectsWithdrawnOfferedReference(t *testing.T) { - ref := extension.ProjectEvidenceRef{ID: "ref-1", SourceID: "doc-1", Revision: "r2"} + ref := extension.ProjectEvidenceRef{ID: "ref-1", SourceID: "doc-1", Revision: "r2", Hash: "h1"} old := extension.ProjectContextBundle{Status: extension.ProjectContextReady, ProjectID: "p1", Evidence: []extension.ProjectEvidenceRef{ref}} _, manifest, err := Assemble(old, 180) if err != nil { @@ -41,3 +41,10 @@ func TestAssembleIncludesUnresolvedMissingStatus(t *testing.T) { t.Fatalf("missing status hidden from model or receipt: text=%q manifest=%+v", text, manifest) } } + +func TestAssembleRejectsRequiredSourceWithoutStableRevisionAndHash(t *testing.T) { + bundle := extension.ProjectContextBundle{Status: extension.ProjectContextReady, ProjectID: "p1", Items: []extension.ProjectContextItem{{SourceID: "legacy", Text: "private", Required: true}}} + if _, _, err := Assemble(bundle, 900); err == nil { + t.Fatal("legacy source without revision/hash entered recoverable project model input") + } +} diff --git a/internal/prompts/library/agent-squad-reviewer.md b/internal/prompts/library/agent-squad-reviewer.md index 8c5e829..e88e752 100644 --- a/internal/prompts/library/agent-squad-reviewer.md +++ b/internal/prompts/library/agent-squad-reviewer.md @@ -7,6 +7,6 @@ description: 终审交付文档,必须给出通过或修改意见 --- 你是小队编排中的审校员,对交付文档做终审。 -- 审查维度:一致性(结论与证据、前后文、术语)、完整性(覆盖目标全部要求)、风险(可能误导读者的表述、越出证据的断言)、版式与规范(标题层级、空节、手打符号、日期和术语是否统一;Office 文件必须附 housestyle.lint 的 JSON 结果,有 error 即不通过)。 +- 审查维度:一致性(结论与证据、前后文、术语)、完整性(覆盖目标全部要求)、风险(可能误导读者的表述、越出证据的断言)、版式与规范(标题层级、空节、日期和术语是否统一;Word 必须附 renderer manifest 与哈希,Excel/PPT 必须附 housestyle.lint JSON;有 error 即不通过)。 - 逐维度给出结论与具体位置;引用原文片段说明问题。 - 结尾必须给出明确裁决:通过 / 需修改(列修改清单,按重要性排序)。不写"基本可以"这类模糊结论。 diff --git a/internal/prompts/library/agent-squad-writer.md b/internal/prompts/library/agent-squad-writer.md index 38fdd0b..2ddaa2c 100644 --- a/internal/prompts/library/agent-squad-writer.md +++ b/internal/prompts/library/agent-squad-writer.md @@ -10,4 +10,4 @@ description: 把规划要点与调研结论整理为交付文档 - 交付文档结构完整(标题、摘要、正文分节、结论与建议、附录);摘要让只读摘要者能掌握全貌。 - 全文以调研结论为事实来源:调研未确认的内容不得写成确定事实;置信度低的关键结论在文中保留标注。 - 文风统一、术语一致;成文质量按"可直接交付"标准,不是草稿质量。 -- 版式按 house-style:真实标题层级、表格和列表,不用空段落或手打符号。文件交付前运行 housestyle.lint,有 error 先修。 +- 版式使用真实标题层级、表格和列表,不用空段落或手打符号。普通工作区 Word 走 `render_document` 并核对 manifest;Excel/PPT 运行 housestyle.lint,有 error 先修。 diff --git a/internal/prompts/library/agent-sub-verify.md b/internal/prompts/library/agent-sub-verify.md index fb88230..6c76f8f 100644 --- a/internal/prompts/library/agent-sub-verify.md +++ b/internal/prompts/library/agent-sub-verify.md @@ -12,7 +12,7 @@ model: lightweight - 按验收标准逐项核对,每项给出证据(引用交付物原文/命令输出/对照数据),不接受"看起来没问题"。 - 主动做变体与边界检查:正常情况之外,至少检查一个边界条件和一个异常路径。 - 区分"验证过"与"推断应该没问题":没有实际核对的项归入未验证。 -- 版式与规范是验收项:Office 交付物必须实际运行 housestyle.lint,把 JSON 结果写入证据。存在 error 则该项 FAIL。只看过文件名不算验证过版式。 +- 版式与规范是验收项:Word 核对共享 renderer manifest、哈希和诊断;Excel/PPT 实际运行 housestyle.lint。存在 error 则该项 FAIL。只看过文件名不算验证过版式。 输出必须以如下裁决行结尾(供程序解析): VERDICT: PASS | FAIL | PARTIAL diff --git a/internal/prompts/library/agent-sub-writer.md b/internal/prompts/library/agent-sub-writer.md index 5eecda1..af8593e 100644 --- a/internal/prompts/library/agent-sub-writer.md +++ b/internal/prompts/library/agent-sub-writer.md @@ -4,7 +4,7 @@ layer: agent version: 2 title: 撰稿子代理 description: 把要点与调研结论整理为完整成文的交付文档 -tools: knowledge_search, web_search, tavily_search, time_now, list_conversations, read_conversation, fs_read_file, fs_list_dir, write_artifact, quality_review, run_python, register_artifact +tools: knowledge_search, web_search, tavily_search, time_now, list_conversations, read_conversation, fs_read_file, fs_list_dir, write_artifact, render_document, quality_review, run_python, register_artifact model: chat --- 你是撰稿子代理,负责把前序要点与调研结论整理成完整的交付文档。 @@ -13,4 +13,4 @@ model: chat - 忠于输入材料:材料里的结论、数据、限定词原样保留;材料冲突时标注冲突而不是择一隐瞒。 - 缺失的部分显式标注"待补充:<缺什么>",不用编造的内容填空。 - 按任务要求的文体与受众写作;未指定时采用正式、克制的书面语。 -- 成文遵守专业交付标准:结论先行、术语一致、不写空节。需要文件时用 house-style 构件,登记前通过 lint。 +- 成文遵守专业交付标准:结论先行、术语一致、不写空节。普通工作区 Word 用 `render_document`;项目或受控 Word 交给 RDM;Excel/PPT 用 house-style 构件并在登记前通过 lint。 diff --git a/internal/prompts/library/base-deliverables.md b/internal/prompts/library/base-deliverables.md index 2235dcc..d934a69 100644 --- a/internal/prompts/library/base-deliverables.md +++ b/internal/prompts/library/base-deliverables.md @@ -1,19 +1,19 @@ --- id: base.deliverables layer: base -version: 7 +version: 12 title: 交付物工作流 description: 生成文档、表格、演示稿的工作流、样式决策与技能入口(常驻基座) --- -交付物(Word / Excel / PPT / PDF / 图片)由你亲自生成:用 run_python 或 run_shell 产出文件,再登记为工件。系统不代写。 +交付物内容由你负责。普通工作区 Word 统一调用 `render_document`;Excel / PPT / PDF / 图片仍用 run_python 或 run_shell 产出文件后登记。 工作流: -1. **定样式**,按下面的优先级只取第一个命中项:本轮用户的明确指令(例如改成公文、换成品牌色)→ 本轮上传的模板(.docx/.dotx/.pptx/.potx/.xlsx/.xltx)→ 本轮要求仿制的参考文档(`from housestyle.extract import extract`,抽取字体、字号、色板、页边距,得到自定义主题)→ 本会话已经确定的样式 → 设置中的默认模板 → 设置中的 deliverables.style(modern / formal / minimal)和强调色。未指定时用环境段给出的默认样式。 +1. **先判路由**:组织默认规则已发布(负责人 2026-10-16 批准,manifest 评审状态 `approved`),但只用于普通、非受控的工作区 Word;项目文档、受控文档、专用模板或审签输出必须交给 RDM,禁止调用 Backend 默认规则回退。Excel / PPT / 图片再按用户指令、模板、参考文档、会话样式和默认主题的顺序定样式。 2. **规划版面**:先定读者、结构和每一节的内容,再写脚本。不要边生成边补空节。 -3. **生成(Word 交付物优先走源码化)**:先写 Markdown 源文件(`write_file 方案.md`,约定见 `python3 -m housestyle.build` 的 docstring:`#`标题/`##`-`####` 三级、`-`/`1.` 列表、`| |` 表格、`>` 引用、`!!! 标签 文本` 强调块、首行 `` 可选主题),再 `python3 -m housestyle.build 方案.md` 确定性渲染成 docx——build 自动跑 lint 并回读结构(写入即诊断),有 error 就改 .md 源的对应段落再重建,不要整段重写。修改已有交付物 = `edit_file` 改 .md 源 + 重新 build。Excel/PPT/图片等其他类型仍用 `run_python` + 对应 kit。文件都在会话工作区;模板和参考原件在 `inputs/`(环境段列出相对路径)。 -4. **自检**:`python3 -m housestyle.lint <文件>`。输出为 JSON;存在 severity=error 时必须修好再继续,不要带着 error 登记。然后用 `python3 -m housestyle.outline_cli <文件>` 回读标题、表格和页数,核对与规划一致。 -5. **登记**:`register_artifact`(path 传相对路径)。登记后对话里出现可预览的卡片。 +3. **生成 Word**:调用 `render_document`,传本次交付稳定且重试复用的 `operationId`、稳定 `documentId`、标题 `name`、Markdown、语言和 `working|preview` 模式,并显式传 `controlled=false`、`projectDocument=false`;任何一项不是 false 就停止并交给 RDM。时间与 requester 由宿主冻结,禁止传入或声称自定 `frozenAt`。Markdown 用 `#`-`######` 标题、段落、引用、围栏/行内代码、粗体、斜体、删除线、安全链接标签、`-`/`1.` 列表、GFM 表格、`asset:` 图片、`:::callout warning`/`:::` 提示块和 `:::page-break`。工具先保存带稳定 node ID 的 Markdown,再生成确定性 DOCX、manifest 和完整 zip;返回值逐项列出稳定 ID 与 SHA-256。失败时按回执状态重试同一 operationId,绝不调用 legacy Word 路径回退。 +4. **生成其他格式并自检**:Excel/PPT/图片用 `run_python` + house-style 对应 kit;用 lint/outline 回读。Word 的 OPC、安全、锚点与回读校验已经由统一 renderer 执行,复核 manifest 中的诊断、组织默认规则评审状态和 Markdown/DOCX 哈希。 +5. **登记**:`render_document` 已登记 Markdown、DOCX、manifest 和 zip,不要重复登记。其他格式继续用 `register_artifact`。 6. **收尾**:回复只写结论摘要,并说明这次用的样式(主题、是否模板、是否仿制)以及用户还能怎么调。不要在回复里复述全文,也不要只口头说文件名。 检索与读取的工具结果末尾带【src_N|工具名】来源标记:成文时数字与关键事实的出处就是这些带标记的结果,逐项核对;交付物里的每一个百分比、日期和大额数字,都必须能在本轮上传材料或你自己的工具/计算输出里找到出处;找不到出处的,要么删除,要么用「目标 / 预计 / 假设」等词在文中显式标注为规划量(如「目标 85%」「预计三个月」)。终轮会对无出处且未标注的数字拦截两轮;仍无法回溯又拒绝标注的,会被系统如实记为未核实。 @@ -34,10 +34,12 @@ description: 生成文档、表格、演示稿的工作流、样式决策与技 技能与路径: - 技能目录是环境变量 `AGENT_SKILLS_DIR`(环境段会写出绝对路径)。`PYTHONPATH` 已包含其中的 `house-style`,脚本里直接 `import housestyle`。 -- **house-style**:设计系统、三套主题、模板与仿制、lint。先读 `$AGENT_SKILLS_DIR/house-style/SKILL.md`。 +- **house-style**:Excel/PPT/图片设计系统及 Word 迁移对照工具。普通 Word 生产路径仍以 `render_document` 为唯一入口;先读 `$AGENT_SKILLS_DIR/house-style/SKILL.md`。 - **docx-author / xlsx-author / pptx-author**:各类交付物的版面范式和最小示例。 +- **frontend-design / canvas-design / copywriting / copy-editing**(第三方 vendor,溯源见各自 UPSTREAM.md):视觉方向与排版审美、海报/信息图等 png·pdf 视觉产物(PIL 渲染,字体已随包)、营销/商务文案的撰写改写与编辑润色。交付物主题与配色仍以 house-style 为权威;需要超出主题规范的视觉判断或文案功力时 `load_skill` 对应技能。 - 小队角色用 `load_skill`;其他路径用 `run_shell` 查看技能文件,例如 `cat "$AGENT_SKILLS_DIR/house-style/SKILL.md"`。不要假设工作区里有 `skills/` 目录。 -- 缺库时:`pip install --user python-docx openpyxl python-pptx`。PDF 仿制可选 `pdfplumber`;没装就说明只能近似,并请用户确认主色和字体。 +- 缺库时用 `python_env`:先 `status` 看基础层是否已有,没有再 `request` 并写明理由。不要 `pip install`。PDF 仿制可用基础层里的 `pdfplumber`;基础层没有且申请未通过时说明只能近似,并请用户确认主色和字体。 +- 缺库又装不上时,如实告知用户当前环境无法生成带排版的 Office 交付物并说明缺什么;禁止改用 zipfile/字符串手工拼 OOXML 充数——那样生成的包缺母版与主题,结构自检会拒绝登记。 要求与边界: diff --git a/internal/prompts/library/base-quality.md b/internal/prompts/library/base-quality.md index 8cf616b..e8d9597 100644 --- a/internal/prompts/library/base-quality.md +++ b/internal/prompts/library/base-quality.md @@ -42,4 +42,4 @@ description: 对回复、文档、表格、演示、代码与数据一律适用 - 完整:要求的部分都在,没有空节。 - 准确:人名、时间、数字、引用与材料一致;不确定处已标注。 - 一致:术语、日期格式、称谓、口径前后相同。 -- 版式:Office 文件必须先过 housestyle.lint,存在 error 先修再登记。 +- 版式:Word 必须有共享 renderer manifest 且无 error 诊断;Excel/PPT 必须先过 housestyle.lint,存在 error 先修再登记。 diff --git a/internal/prompts/library/base-tools.md b/internal/prompts/library/base-tools.md index 724f889..5a03fea 100644 --- a/internal/prompts/library/base-tools.md +++ b/internal/prompts/library/base-tools.md @@ -7,7 +7,7 @@ description: 何时用工具、如何并行调用、如何处理工具结果 --- 使用工具时遵循: -- 优先专用工具而非通用手段:读文件用 read_file(行号分页)、找内容用 grep、找文件用 glob、改文件用 edit_file/multi_edit(须先读过、old_string 唯一);会话工作区内的文件操作一律用这些工具,不用 run_shell 的 cat/sed 代替。检索知识用检索工具、查时间用时间工具;能用结构化工具完成的,不绕道自由文本。 +- 优先专用工具而非通用手段:读文件用 read_file(行号分页)、找内容用 grep、找文件用 glob、改文件用 edit_file/multi_edit(须先读过、old_string 唯一);会话工作区内的文件操作一律用这些工具,不用 run_shell 的 cat/sed 代替。检索项目资料用 knowledge_search;法规、标准、标准号(GB、YY、YY/T、IEC 等)、指导原则类问题优先用 regulations_search,需条款原文再用 regulation_evidence;查时间用时间工具;能用结构化工具完成的,不绕道自由文本。 - 相互独立的调用并行发起;有依赖的调用按依赖顺序串行。 - 调用前确认参数完整、类型正确;不确定参数含义时先查阅工具描述,不猜测字段。 - 工具结果是事实来源:基于结果作答并如实转述;结果为空或报错时说明情况,不假装拿到了数据。 diff --git a/internal/prompts/library/task-doc-minutes.md b/internal/prompts/library/task-doc-minutes.md index f3c23a3..f53af31 100644 --- a/internal/prompts/library/task-doc-minutes.md +++ b/internal/prompts/library/task-doc-minutes.md @@ -1,7 +1,7 @@ --- id: task.doc-minutes layer: task -version: 3 +version: 4 title: 会议纪要生成 description: 依据上传的会议记录/录音稿生成结构完整的会议纪要 priority: 70 @@ -28,4 +28,4 @@ triggers: - 待办事项若原文未指明负责人或期限,写"未指明",不要编造。 - 版面:标题下用键值表写会议信息(时间、地点、主持人、参会人、缺席)。讨论按议题分节,每节含要点、分歧和结论。决议用真编号列表,不要再手写“1.”。待办用表格,列:序号、事项、负责人、期限、状态;原文没有的写“未指明”。发言人用统一称谓(职务或姓名);原文只有“说话人X”时保留并在文末说明。引语只留关键句,并标注是谁说的。 - 日期和数字全文一种格式。不要空节、空段落和手打项目符号。 -- 交付形态:按「交付物工作流」用 housestyle.docx_kit 生成「<主题>会议纪要.docx」,lint 无 error 后再 register_artifact。对话里只简述结论和所用样式,不要复述全文。 +- 交付形态:普通工作区纪要按「交付物工作流」调用 `render_document`,生成「<主题>会议纪要.md/.docx/.manifest.json/.zip」;项目或受控纪要交给 RDM。对话里只简述结论、所用组织默认规则和可调项,不要复述全文。 diff --git a/internal/prompts/library/task-report.md b/internal/prompts/library/task-report.md index 3fe6b48..106291b 100644 --- a/internal/prompts/library/task-report.md +++ b/internal/prompts/library/task-report.md @@ -1,7 +1,7 @@ --- id: task.report layer: task -version: 3 +version: 4 title: 报告撰写 description: 把材料组织成正式报告/公文(含执行摘要与附录建议) priority: 55 @@ -25,4 +25,4 @@ triggers: - 结构服务于阅读动线:先结论后论证;每章开头一句话说清本章要点。 - 篇幅完整不注水:覆盖必要内容,删掉不改变结论的空话。 - 版面:封面(标题、受众、日期)、正文从执行摘要开始。执行摘要用强调块,控制在契约规定的篇幅内。章标题用标题样式,章首一句点明本章要点。数据进表格或附录,不埋在段落里。需要时在正文前放由标题生成的目录说明(页码以文件为准)。 -- 交付 Word 时走交付物工作流:housestyle.docx_kit 生成,lint 无 error 后登记。回复说明样式,不复述全文。 +- 交付普通工作区 Word 时走交付物工作流的 `render_document`,同时保留 Markdown、manifest 和归档;项目或受控报告交给 RDM。回复说明所用组织默认规则,不复述全文。 diff --git a/internal/prompts/library/tool-knowledge-search.md b/internal/prompts/library/tool-knowledge-search.md index 61e1c34..4e859cc 100644 --- a/internal/prompts/library/tool-knowledge-search.md +++ b/internal/prompts/library/tool-knowledge-search.md @@ -5,4 +5,4 @@ version: 1 title: knowledge_search 工具描述 description: 知识检索工具的模型侧说明文案 --- -在项目知识与插件知识库中做全文检索,返回命中片段。用法:一次一个明确的检索意图;换角度检索时改关键词再查,不重复同一查询。命中为空说明库中没有该表述,可换同义词再试一到两次;仍为空就承认未命中,不虚构结果。回答"项目里有没有/是什么"类问题优先用本工具。 +在项目知识与插件知识库中做全文检索,返回命中片段。用法:一次一个明确的检索意图;换角度检索时改关键词再查,不重复同一查询。命中为空说明库中没有该表述,可换同义词再试一到两次;仍为空就承认未命中,不虚构结果。回答"项目里有没有/是什么"类项目资料问题优先用本工具;法规、标准、标准号(GB、YY、YY/T、IEC 等)或指导原则类问题优先用 regulations_search,再用 regulation_evidence 取条款原文。 diff --git a/internal/prompts/library/tool-render-document.md b/internal/prompts/library/tool-render-document.md new file mode 100644 index 0000000..ceed6a1 --- /dev/null +++ b/internal/prompts/library/tool-render-document.md @@ -0,0 +1,10 @@ +--- +id: tool.render_document +layer: tool +version: 2 +title: render_document 工具描述 +description: 普通工作区 Markdown 到统一 Word 交付包的安全渲染入口 +--- +把普通、非受控的工作区 Markdown 交给共享 canonical parser、已发布的组织默认 profile 和 Word adapter。必须传稳定且重试复用的 operationId,并显式传 `controlled=false` 和 `projectDocument=false`;缺任一字段即拒绝。frozenAt 与 requester 由宿主首次操作时创建并持久化,调用方不能提交。成功回执包含 Markdown、DOCX、manifest、zip 的稳定 ID 与 SHA-256;失败回执明确 partial 或 unknown,重试必须沿用同一 operationId。 + +只允许 working 或 preview。项目文档、受控文档、专用模板、审阅稿或批准稿必须交给 RDM;失败不得改走 legacy Word builder。公司默认规则已发布(负责人 2026-10-16 批准,manifest 评审状态 `approved`),但仍只用于普通、非受控的工作/预览交付,不能代替 RDM 的项目或受控规则。 diff --git a/internal/prompts/library/tool-run-python.md b/internal/prompts/library/tool-run-python.md index 4e24b92..dfa5c8d 100644 --- a/internal/prompts/library/tool-run-python.md +++ b/internal/prompts/library/tool-run-python.md @@ -1,8 +1,8 @@ --- id: tool.run_python layer: tool -version: 1 +version: 4 title: run_python 工具描述 description: Python 执行原语的模型侧说明文案 --- -执行完整 Python 脚本(python3,工作目录=会话工作区)。适用:数据处理、生成 Excel/PPT/图片(housestyle kit)、批量操作。Word 交付物优先写 .md 源后用 run_shell 跑 `python3 -m housestyle.build`,不要在脚本里手工拼 docx。报错先读退出码与 traceback 定位行号再改,同一错误不要原样重跑。生成带排式的文件(xlsx/pptx)先读 `$AGENT_SKILLS_DIR/house-style/SKILL.md` 的构件表,不确定签名先 `inspect.signature`。 +执行完整 Python 脚本(python3,工作目录=会话工作区)。适用:数据处理、生成 Excel/PPT/图片(housestyle kit)、批量操作。普通 Word 生产交付不用 Python,调用 `render_document`;受控或项目 Word 交给 RDM。任何 Office 交付物都禁止用 zipfile 或字符串手工拼 OOXML。基础层已有 openpyxl、python-pptx 等;缺包用 python_env,禁止 pip install。失败先读退出码与 traceback,不原样盲试。生成 xlsx/pptx 前读 `$AGENT_SKILLS_DIR/house-style/SKILL.md`。 diff --git a/internal/prompts/library/tool-run-shell.md b/internal/prompts/library/tool-run-shell.md index ad989d8..581d5e7 100644 --- a/internal/prompts/library/tool-run-shell.md +++ b/internal/prompts/library/tool-run-shell.md @@ -1,8 +1,8 @@ --- id: tool.run_shell layer: tool -version: 1 +version: 2 title: run_shell 工具描述 description: bash 执行原语的模型侧说明文案 --- -在会话工作区内执行 bash 脚本(环境最小化:PATH/HOME/LANG;AGENT_SKILLS_DIR 与 PYTHONPATH 已注入)。适用:housestyle build/lint、pip install --user、文件批操作。读文件用 read_file、找内容用 grep——不要用 cat/sed/grep 命令行代替专用工具。同一命令失败两次换方法。 +在会话工作区内执行 bash 脚本(环境最小化:PATH/HOME/LANG;AGENT_SKILLS_DIR 与 PYTHONPATH 已注入)。适用:Excel/PPT 的 housestyle lint、文件批操作。普通 Word 用 `render_document`,不要运行 legacy housestyle Word builder;受控或项目 Word 交给 RDM。Python 缺包用 python_env,不要 pip install。读文件用 read_file、找内容用 grep;同一命令失败两次换方法。 diff --git a/internal/prompts/library/tool-write-artifact.md b/internal/prompts/library/tool-write-artifact.md index dbbcc0f..55e64ea 100644 --- a/internal/prompts/library/tool-write-artifact.md +++ b/internal/prompts/library/tool-write-artifact.md @@ -9,4 +9,4 @@ description: 工件写入工具的模型侧说明文案 Markdown 交付物同样遵守专业交付标准:标题层级连续、对比用表格、不写空节、术语和日期格式全文一致。它不是把纯文本改一个扩展名。 -分工:本工具只收字符串内容,**不要用它写 .docx/.xlsx/.pptx 等二进制文件**(纯文本改扩展名是无排版的伪文件)。带排版的 Office/PDF/图片交付物由你用 run_python(或 run_shell)生成到工作区后,用 register_artifact 登记交付——配方见交付物工作流(base.deliverables)。 +分工:本工具只收字符串内容,**不要用它写 .docx/.xlsx/.pptx 等二进制文件**。普通 Word 用 `render_document` 一次生成并登记 Markdown、DOCX、manifest 和归档;受控或项目 Word 交给 RDM。Excel/PPT/PDF/图片用 run_python 或 run_shell 生成后再用 register_artifact 登记。 diff --git a/internal/prompts/prompts_test.go b/internal/prompts/prompts_test.go index 973696b..9a4c8d8 100644 --- a/internal/prompts/prompts_test.go +++ b/internal/prompts/prompts_test.go @@ -96,6 +96,58 @@ func TestResolveSystemDefaultBase(t *testing.T) { } } +func TestBaseDeliverablesRoutesWordThroughUnifiedRenderer(t *testing.T) { + body, found, err := mustService(t).ModuleBody("", "base.deliverables") + if err != nil || !found { + t.Fatalf("base.deliverables: found=%v err=%v", found, err) + } + for _, required := range []string{"render_document", "Markdown", "manifest", "SHA-256", "已发布", "2026-10-16", "approved", "RDM", "禁止调用 Backend 默认规则回退"} { + if !strings.Contains(body, required) { + t.Errorf("base.deliverables missing %q", required) + } + } + for _, stale := range []string{"pending-human-visual-review", "组织默认候选", "不能描述成已发布"} { + if strings.Contains(body, stale) { + t.Errorf("base.deliverables still describes the organization default as unpublished: %q", stale) + } + } + if strings.Contains(body, "python3 -m housestyle.build") { + t.Fatal("base.deliverables still routes production Word through legacy housestyle.build") + } +} + +func TestRenderDocumentToolDescribesPublishedDefaultWithoutWideningRoute(t *testing.T) { + body, found, err := mustService(t).ModuleBody("", "tool.render_document") + if err != nil || !found { + t.Fatalf("tool.render_document: found=%v err=%v", found, err) + } + for _, required := range []string{"已发布", "2026-10-16", "approved", "working 或 preview", "RDM", "不得改走 legacy Word builder"} { + if !strings.Contains(body, required) { + t.Errorf("tool.render_document missing %q", required) + } + } + for _, stale := range []string{"pending-human-visual-review", "默认候选", "不能称为已发布"} { + if strings.Contains(body, stale) { + t.Errorf("tool.render_document still describes the organization default as unpublished: %q", stale) + } + } +} + +func TestWordTaskPromptsDoNotReportOrganizationDefaultAsCandidate(t *testing.T) { + for _, id := range []string{"task.report", "task.doc-minutes"} { + body, found, err := mustService(t).ModuleBody("", id) + if err != nil || !found { + t.Fatalf("%s: found=%v err=%v", id, found, err) + } + if strings.Contains(body, "规则候选状态") { + t.Errorf("%s still asks the reply to report a candidate rule status", id) + } + if !strings.Contains(body, "RDM") { + t.Errorf("%s no longer routes project or controlled Word to RDM", id) + } + } +} + func TestEnvDeliverablePlaceholders(t *testing.T) { svc := mustService(t) blocks, _, err := svc.ResolveSystem(core.AgentProfile{}, TriggerCtx{Text: "你好"}, EnvData{ diff --git a/internal/prompts/service.go b/internal/prompts/service.go index 948e9aa..aad16e1 100644 --- a/internal/prompts/service.go +++ b/internal/prompts/service.go @@ -228,6 +228,27 @@ func (s *Service) Save(owner string, m core.PromptModule) error { // Delete 撤销用户覆盖(回到内置)。 func (s *Service) Delete(owner, id string) error { return s.st.DeletePromptModule(owner, id) } +// SaveAccountChecked applies the same user override rules as Save and uses +// the Store's final actor-state transaction for the database write. +func (s *Service) SaveAccountChecked(actor string, m core.PromptModule, allowImplicitDefault bool) error { + if err := validateSubagentOverride(m); err != nil { + return err + } + m.Source = string(core.SourceUser) + m.Owner = actor + for _, b := range s.builtin { + if b.ID == m.ID { + m.BasedOnVersion = b.Version + break + } + } + return s.st.SavePromptModuleAccountChecked(actor, m, allowImplicitDefault) +} + +func (s *Service) DeleteAccountChecked(actor, id string, allowImplicitDefault bool) error { + return s.st.DeletePromptModuleAccountChecked(actor, id, allowImplicitDefault) +} + // MatchCommand 命令触发匹配:文本以某技能命令开头时返回该模块与剩余文本。 func (s *Service) MatchCommand(owner, text string) (core.PromptModule, string, bool, error) { mods, err := s.Effective(owner) diff --git a/internal/prompts/service_fence_test.go b/internal/prompts/service_fence_test.go new file mode 100644 index 0000000..8d32690 --- /dev/null +++ b/internal/prompts/service_fence_test.go @@ -0,0 +1,52 @@ +package prompts + +import ( + "errors" + "path/filepath" + "testing" + + "agent-system-backend/internal/store" + core "agent-system-core" +) + +func TestPromptServiceAccountCheckedWrites(t *testing.T) { + st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if err := st.CreateAccount("admin", "", "", true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("alice", "", "", false); err != nil { + t.Fatal(err) + } + svc, err := NewService(st, []core.PromptModule{{ID: "task.example", Layer: core.LayerTask, Version: 2}}) + if err != nil { + t.Fatal(err) + } + module := core.PromptModule{ID: "task.example", Layer: core.LayerTask, Body: "Alice"} + if err := svc.SaveAccountChecked("alice", module, false); err != nil { + t.Fatal(err) + } + got, err := st.GetPromptModule("alice", module.ID) + if err != nil || got.Owner != "alice" || got.Source != string(core.SourceUser) || got.BasedOnVersion != 2 { + t.Fatalf("service did not normalize actor module: %+v %v", got, err) + } + if err := svc.SaveAccountChecked("missing", module, false); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("missing actor saved prompt: %v", err) + } + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + if err := svc.DeleteAccountChecked("alice", module.ID, false); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("disabled actor deleted prompt: %v", err) + } + if err := svc.SaveAccountChecked("alice", module, false); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("disabled actor saved prompt: %v", err) + } +} diff --git a/internal/rag/account_index_test.go b/internal/rag/account_index_test.go new file mode 100644 index 0000000..49cda4e --- /dev/null +++ b/internal/rag/account_index_test.go @@ -0,0 +1,83 @@ +package rag + +import ( + "context" + "errors" + "testing" + + "agent-system-backend/internal/store" + core "agent-system-core" +) + +type checkedKnowledgeIndexer interface { + IndexDocumentAccountChecked(ctx context.Context, owner, projectID core.ID, source, title, ref, plainText string, allowImplicitDefault bool) error + IndexChunksAccountChecked(ctx context.Context, owner core.ID, docs []core.KnowledgeDoc, allowImplicitDefault bool) error + PublishChunksAccountChecked(ctx context.Context, owner core.ID, pluginID string, scoped bool, docs []core.KnowledgeDoc, allowImplicitDefault bool) error +} + +func checkedIndexer(t *testing.T, p *Pipeline) checkedKnowledgeIndexer { + t.Helper() + i, ok := any(p).(checkedKnowledgeIndexer) + if !ok { + t.Fatal("Pipeline is missing account-checked indexing APIs") + } + return i +} + +type disableDuringEmbed struct{ st *store.Store } + +func (e disableDuringEmbed) Embed(_ context.Context, texts []string) ([][]float32, error) { + if _, err := e.st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner='alice'`); err != nil { + return nil, err + } + vecs := make([][]float32, len(texts)) + for i := range vecs { + vecs[i] = []float32{1, 0} + } + return vecs, nil +} + +func TestCheckedIndexStopsVectorWriteAfterEmbeddingDisablesAccount(t *testing.T) { + p, st := newTestPipeline(t, nil, nil) + i := checkedIndexer(t, p) + if err := st.EnsureUser("alice", ""); err != nil { + t.Fatal(err) + } + p.SetEmbedder(disableDuringEmbed{st: st}) + err := i.IndexChunksAccountChecked(context.Background(), "alice", []core.KnowledgeDoc{ + {ID: "knw-during-embed", Owner: "alice", Ref: "embedded", Source: "manual", Chunk: "A searchable document"}, + }, false) + if !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("vector write after disable: got %v, want ErrAccountForbidden", err) + } + var lexical, vectors int + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM knowledge WHERE owner='alice' AND ref='embedded'`).Scan(&lexical); err != nil { + t.Fatal(err) + } + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM knowledge_vectors WHERE owner='alice' AND ref='embedded'`).Scan(&vectors); err != nil { + t.Fatal(err) + } + if lexical != 1 || vectors != 0 { + t.Fatalf("expected pre-disable lexical commit and no late vector: lexical=%d vectors=%d", lexical, vectors) + } +} + +func TestCheckedIndexDocumentAndPublishUseGuardedKnowledge(t *testing.T) { + p, st := newTestPipeline(t, nil, nil) + i := checkedIndexer(t, p) + if err := st.EnsureUser("alice", ""); err != nil { + t.Fatal(err) + } + if err := i.IndexDocumentAccountChecked(context.Background(), "alice", "p1", "upload", "minutes", "upload-ref", "A document with enough searchable text", false); err != nil { + t.Fatal(err) + } + if err := i.PublishChunksAccountChecked(context.Background(), "alice", "alpha", true, []core.KnowledgeDoc{ + {ID: "knw-published", Owner: "alice", ProjectID: "p1", Ref: "published", Source: "plugin:alpha", Chunk: "A published document"}, + }, false); err != nil { + t.Fatal(err) + } + var n int + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM knowledge WHERE owner='alice'`).Scan(&n); err != nil || n != 2 { + t.Fatalf("guarded index and publish rows=%d, %v", n, err) + } +} diff --git a/internal/rag/hybrid.go b/internal/rag/hybrid.go index e9438d2..f22c3fa 100644 --- a/internal/rag/hybrid.go +++ b/internal/rag/hybrid.go @@ -4,6 +4,7 @@ import ( "context" "crypto/sha256" "encoding/hex" + "errors" "log" "agent-system-backend/internal/settings" @@ -85,6 +86,25 @@ func (p *Pipeline) IndexDocument(owner, projectID core.ID, source, title, ref, p return p.IndexChunks(context.Background(), owner, docs) } +// IndexDocumentAccountChecked is the upload/artifact indexing entry when the +// caller has an authenticated actor. Chunking takes no DB connection; every +// later lexical/vector write has its own final account transaction check. +func (p *Pipeline) IndexDocumentAccountChecked(ctx context.Context, owner, projectID core.ID, source, title, ref, plainText string, allowImplicitDefault bool) error { + if plainText == "" { + return nil + } + doc := core.ParsedDocument{Blocks: []core.DocBlock{{Type: core.BlockPara, Text: plainText}}} + chunks := p.chunker.Chunk(doc) + if len(chunks) == 0 { + return nil + } + docs := make([]core.KnowledgeDoc, len(chunks)) + for i, c := range chunks { + docs[i] = core.KnowledgeDoc{Owner: owner, ProjectID: projectID, Source: source, Title: title, Ref: ref, Chunk: c.Text} + } + return p.IndexChunksAccountChecked(ctx, owner, docs, allowImplicitDefault) +} + // IndexChunks 写入已构造好的知识分块并建向量(EXT-1 KnowledgeWrite embed=true 的路径)。 // AddKnowledge 原地回填 docs[i].ID;embedding 失败降级为纯全文(记日志,不阻断写入)。 func (p *Pipeline) IndexChunks(ctx context.Context, owner core.ID, docs []core.KnowledgeDoc) error { @@ -118,6 +138,74 @@ func (p *Pipeline) IndexChunks(ctx context.Context, owner core.ID, docs []core.K return nil } +// IndexChunksAccountChecked preserves batch atomicity for lexical rows and +// fences each vector write after the external embedding call. Embedding cannot +// share the DB transaction; a disable during that call leaves already +// committed lexical rows but blocks subsequent vector publication. +func (p *Pipeline) IndexChunksAccountChecked(ctx context.Context, owner core.ID, docs []core.KnowledgeDoc, allowImplicitDefault bool) error { + if len(docs) == 0 { + return nil + } + if err := p.st.AddKnowledgeAccountChecked(string(owner), allowImplicitDefault, docs...); err != nil { + return err + } + return p.indexVectorsAccountChecked(ctx, owner, docs, allowImplicitDefault) +} + +// PublishChunksAccountChecked replaces all affected refs in one guarded Store +// transaction, then builds vectors through individually guarded writes. +func (p *Pipeline) PublishChunksAccountChecked(ctx context.Context, owner core.ID, pluginID string, scoped bool, docs []core.KnowledgeDoc, allowImplicitDefault bool) error { + if len(docs) == 0 { + return nil + } + if err := p.st.ReplaceKnowledgeAccountChecked(ctx, string(owner), pluginID, scoped, allowImplicitDefault, docs...); err != nil { + return err + } + return p.indexVectorsAccountChecked(ctx, owner, docs, allowImplicitDefault) +} + +func (p *Pipeline) indexVectorsAccountChecked(ctx context.Context, owner core.ID, docs []core.KnowledgeDoc, allowImplicitDefault bool) error { + if p.embedder == nil { + return nil + } + if err := ctx.Err(); err != nil { + return err + } + account, err := p.st.GetAccountByOwner(string(owner)) + if errors.Is(err, store.ErrNotFound) && allowImplicitDefault && owner == store.OwnerDefault { + err = nil + account.Enabled = true + } + if err != nil { + return err + } + if !account.Enabled { + return store.ErrAccountForbidden + } + texts := make([]string, len(docs)) + for i, d := range docs { + texts[i] = d.Chunk + } + vecs, err := p.embedder.Embed(ctx, texts) + if err != nil { + log.Printf("[rag] embedding 降级为纯全文(ref=%s): %v", firstRef(docs), err) + return nil + } + model := embedderModel(p.embedder) + for i, v := range vecs { + if i >= len(docs) { + break + } + if len(v) == 0 { + continue + } + if err := p.st.UpsertKnowledgeVectorExtAccountChecked(ctx, string(owner), string(docs[i].ID), docs[i].Ref, model, contentHash(docs[i].Chunk), v, allowImplicitDefault); err != nil { + return err + } + } + return nil +} + // embedderModel 模型身份(可选接口:EmbeddingProvider 契约未含,实现侧 // 声明;未声明=空=legacy 无身份行,向后兼容)。 func embedderModel(e core.EmbeddingProvider) string { diff --git a/internal/runmgr/account_guard_test.go b/internal/runmgr/account_guard_test.go new file mode 100644 index 0000000..ef363c7 --- /dev/null +++ b/internal/runmgr/account_guard_test.go @@ -0,0 +1,145 @@ +package runmgr + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "agent-system-backend/internal/store" + core "agent-system-core" +) + +// A missing domain provider must not make a registered account guard optional. +func TestAccountGuardBlocksRunToolAndQueuedHITL(t *testing.T) { + c, st := newExecEnv(t) + if err := st.CreateAccount("alice", "Alice", "", false); err != nil { + t.Fatal(err) + } + c.mgr.SetAccountEffectiveAuthorization(func(_ context.Context, owner string) (bool, error) { + a, err := st.GetAccountByOwner(owner) + return err == nil && a.Enabled, err + }) + c.run.ID = store.NewID("run") + c.run.Status = core.RunWaitingHITL + if err := st.CreateRun(&c.run); err != nil { + t.Fatal(err) + } + h := &runHandle{hitlCh: make(chan hitlAnswer, 1)} + c.mgr.active = map[string]*runHandle{c.run.ID: h} + card := core.HITLCard{ID: "disabled-card", RunID: c.run.ID, ConversationID: c.run.ConversationID} + if err := st.SaveHITLCard("alice", &card); err != nil { + t.Fatal(err) + } + if _, err := st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner='alice'`); err != nil { + t.Fatal(err) + } + conv, err := st.GetConversation("alice", c.run.ConversationID) + if err != nil { + t.Fatal(err) + } + if _, err := c.mgr.StartConversationRun(conv, core.Message{}, nil, nil); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("disabled account started run without domain provider: %v", err) + } + res := c.ExecuteTool(core.ToolCallRequest{CallID: "disabled-tool", Name: "write_artifact", Arguments: json.RawMessage(`{"name":"denied.txt","content":"x"}`)}) + if res.Status != "error" { + t.Fatalf("disabled account executed tool: %+v", res) + } + arts, err := st.ListArtifacts("alice", c.run.ProjectID) + if err != nil || len(arts) != 0 { + t.Fatalf("disabled account wrote artifact: %+v %v", arts, err) + } + + if _, _, err := c.mgr.ResolveHITL("alice", card.ID, core.HITLResponse{ChoiceID: "yes"}); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("disabled account resolved HITL: %v", err) + } + persisted, err := st.GetHITLCard("alice", card.ID) + if err != nil || persisted.Resolved || len(h.hitlCh) != 0 { + t.Fatalf("disabled HITL changed state: %+v channel=%d err=%v", persisted, len(h.hitlCh), err) + } + payload, _ := json.Marshal(HITLCommandPayload{Owner: "alice", CardID: card.ID, Kind: "yes"}) + c.mgr.applyHITLCommand(ownedCommand{CommandID: "queued-disabled", Payload: string(payload)}) + if len(h.hitlCh) != 0 { + t.Fatal("queued approval delivered to disabled run") + } +} + +func TestAccountGuardStoreReadFailureBlocksOutput(t *testing.T) { + c, st := newExecEnv(t) + runCtx, cancel := context.WithCancel(context.Background()) + defer cancel() + c.ctx = runCtx + c.mgr.active = map[string]*runHandle{"active": {owner: "alice", cancel: cancel}} + readErr := errors.New("account store unavailable") + c.mgr.SetAccountEffectiveAuthorization(func(context.Context, string) (bool, error) { return false, readErr }) + if err := c.Emit(core.EvToolOutput, core.ToolOutputPayload{Text: "secret"}); !errors.Is(err, readErr) { + t.Fatalf("store failure emitted output: %v", err) + } + if runCtx.Err() == nil { + t.Fatal("account-state read failure left active local run executing") + } + if err := c.SetAssistantParts([]core.Part{{Type: core.PartText, Text: "secret"}}); !errors.Is(err, readErr) { + t.Fatalf("store failure retained assistant output: %v", err) + } + evs, err := st.EventsAfter(c.run.ConversationID, 0, 100) + if err != nil || len(evs) != 0 { + t.Fatalf("store failure persisted output events: %+v %v", evs, err) + } +} + +func TestAccountGuardBlocksRestoredNoticeOutput(t *testing.T) { + c, st := newExecEnv(t) + if err := st.CreateAccount("alice", "Alice", "", false); err != nil { + t.Fatal(err) + } + c.mgr.SetAccountEffectiveAuthorization(func(_ context.Context, owner string) (bool, error) { + a, err := st.GetAccountByOwner(owner) + return err == nil && a.Enabled, err + }) + if _, err := st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner='alice'`); err != nil { + t.Fatal(err) + } + if err := c.mgr.startNoticeRun(c.run.ConversationID, []bgNotice{{owner: "alice", name: "restored", payload: core.AgentResultPayload{Summary: "must not deliver"}}}); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("restored notice delivered to disabled account: %v", err) + } + msgs, err := st.ListMessages(c.run.ConversationID) + if err != nil || len(msgs) != 0 { + t.Fatalf("restored notice persisted output: %+v %v", msgs, err) + } +} + +func TestAccountGuardDiscardsComposedOutputAfterDisable(t *testing.T) { + c, st := newExecEnv(t) + if err := st.CreateAccount("alice", "Alice", "", false); err != nil { + t.Fatal(err) + } + c.mgr.SetAccountEffectiveAuthorization(func(_ context.Context, owner string) (bool, error) { + a, err := st.GetAccountByOwner(owner) + return err == nil && a.Enabled, err + }) + c.run.ID = store.NewID("run") + c.run.Status = core.RunRunning + if err := st.CreateRun(&c.run); err != nil { + t.Fatal(err) + } + c.h = &runHandle{assistant: []core.Part{{Type: core.PartText, Text: "must not persist"}}} + if _, err := st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner='alice'`); err != nil { + t.Fatal(err) + } + c.mgr.finishRun(c, core.RunCompleted, core.Usage{}, nil) + got, err := st.GetRunByID(c.run.ID) + if err != nil || got.Status == core.RunCompleted { + t.Fatalf("disabled run completed: %+v %v", got, err) + } + msgs, err := st.ListMessages(c.run.ConversationID) + if err != nil { + t.Fatal(err) + } + for _, msg := range msgs { + for _, part := range msg.Parts { + if part.Text == "must not persist" { + t.Fatal("previously composed assistant output persisted after disable") + } + } + } +} diff --git a/internal/runmgr/admission.go b/internal/runmgr/admission.go index a261845..08c989a 100644 --- a/internal/runmgr/admission.go +++ b/internal/runmgr/admission.go @@ -70,16 +70,23 @@ func (g *admissionGate) QueueFull() bool { } // acquire 阻塞获取执行许可(ctx 取消则放弃排队)。预算关闭时零开销直通。 -func (g *admissionGate) acquire(ctx context.Context, st *store.Store, runID, owner string) error { +func (g *admissionGate) acquire(ctx context.Context, st *store.Store, runID, owner string, accountGuard ...bool) error { if g == nil { return nil } budget := g.budget() if budget.Off() { - return nil + return ctx.Err() } for { - if err := st.TryAcquireExecutionSlot(runID, owner, budget); err == nil { + var err error + if len(accountGuard) > 0 && accountGuard[0] { + allowImplicit := len(accountGuard) > 1 && accountGuard[1] + err = st.TryAcquireExecutionSlotAccountChecked(owner, runID, budget, allowImplicit) + } else { + err = st.TryAcquireExecutionSlot(runID, owner, budget) + } + if err == nil { return nil } else if !errors.Is(err, store.ErrBudgetExceeded) { return err // 存储故障:不放行(fail-closed,宁可拒绝执行) diff --git a/internal/runmgr/background_card_test.go b/internal/runmgr/background_card_test.go index 3f8f0d1..0cc9e7d 100644 --- a/internal/runmgr/background_card_test.go +++ b/internal/runmgr/background_card_test.go @@ -8,28 +8,45 @@ import ( "agent-system-backend/internal/cardsvc" "agent-system-backend/internal/runtime" + "agent-system-backend/internal/store" core "agent-system-core" ) // newCardSpawnEnv 在后台派发环境上接线收件箱卡片服务,并捕获发出的卡片事件 // (任务快慢双速:慢通道进度卡 → 完成回投的验证环境)。 -func newCardSpawnEnv(t *testing.T, runner runtime.Runner) (*runCtx, *[]string) { - t.Helper() - c, _ := newSpawnEnv(t, runner) - var mu sync.Mutex - var events []string - c.mgr.cards = cardsvc.New(c.mgr.st, func(conversationID, runID string, typ core.StreamEventType, payload any) error { - mu.Lock() - defer mu.Unlock() - events = append(events, string(typ)) - return nil - }) - return c, &events +type cardEventLog struct { + mu sync.Mutex + events []string } -func cardEvents(list *[]string) []string { +func newCardSpawnEnv(t *testing.T, runner runtime.Runner) (*runCtx, *cardEventLog) { + t.Helper() + c, _ := newSpawnEnv(t, runner) + events := &cardEventLog{} + emit := func(conversationID, runID string, typ core.StreamEventType, payload any) error { + events.mu.Lock() + events.events = append(events.events, string(typ)) + events.mu.Unlock() + return nil + } + svc := cardsvc.New(c.mgr.st, emit) + c.mgr.SetCards(svc) + svc.TaskCommitter = func(w store.TaskProjectionWrite) ([]core.StreamEvent, error) { + evs, e := c.mgr.events.commitTask(w) + if e == nil { + for _, ev := range evs { + _ = emit(ev.ConversationID, ev.RunID, ev.Type, ev.Payload) + } + } + return evs, e + } + return c, events +} +func cardEvents(list *cardEventLog) []string { + list.mu.Lock() + defer list.mu.Unlock() var out []string - for _, e := range *list { + for _, e := range list.events { if strings.HasPrefix(e, "card.") { out = append(out, e) } @@ -47,15 +64,7 @@ func TestBackgroundCardLifecycleOnSuccess(t *testing.T) { if _, err := c.SpawnBackground("agent.sub.general", "深度解析员", "逐页解析上传材料", ""); err != nil { t.Fatal(err) } - waitUntil(t, func() bool { - if len(systemTexts(t, c)) != 1 { - return false - } - c.mgr.convMu.Lock() - busy := c.mgr.convActive[c.run.ConversationID] - c.mgr.convMu.Unlock() - return !busy - }) + waitBackgroundIdle(t, c) cards, err := c.mgr.cards.List(c.run.Owner, c.run.ConversationID) if err != nil { t.Fatal(err) @@ -76,14 +85,14 @@ func TestBackgroundCardLifecycleOnSuccess(t *testing.T) { md = n.Markdown } } - if !strings.Contains(md, "深度解析完成") || !strings.Contains(md, "解析摘要.md") { + if !strings.Contains(md, "深度解析完成") { t.Fatalf("result body = %q", md) } if card.SourceRunID == "" { t.Fatal("card not linked to child run") } got := cardEvents(events) - for _, want := range []string{"card.created", "card.updated", "card.status"} { + for _, want := range []string{"card.created", "card.updated"} { found := false for _, e := range got { if e == want { @@ -103,15 +112,7 @@ func TestBackgroundCardLifecycleOnFailure(t *testing.T) { if _, err := c.SpawnBackground("agent.sub.general", "深度解析员", "逐页解析上传材料", ""); err != nil { t.Fatal(err) } - waitUntil(t, func() bool { - if len(systemTexts(t, c)) != 1 { - return false - } - c.mgr.convMu.Lock() - busy := c.mgr.convActive[c.run.ConversationID] - c.mgr.convMu.Unlock() - return !busy - }) + waitBackgroundIdle(t, c) cards, err := c.mgr.cards.List(c.run.Owner, c.run.ConversationID) if err != nil { t.Fatal(err) @@ -143,6 +144,10 @@ func TestBackgroundCardLifecycleOnFailure(t *testing.T) { if statusLabel != core.TaskSummaryStatusFailed { t.Fatalf("status kv = %q, want %q", statusLabel, core.TaskSummaryStatusFailed) } + failed, err := c.mgr.st.GetRunByID(card.SourceRunID) + if err != nil || failed.Error == nil || failed.Error.Message != runner.err.Error() { + t.Fatalf("failure details were lost from the execution record: %+v %v", failed.Error, err) + } } func TestBackgroundCardCreateUsesL1SkeletonNotRawTask(t *testing.T) { @@ -195,7 +200,10 @@ func TestBackgroundCardSkippedWithoutInbox(t *testing.T) { if _, err := c.SpawnBackground("agent.sub.general", "后台", "任务", ""); err != nil { t.Fatal(err) } - waitUntil(t, func() bool { return len(systemTexts(t, c)) == 1 }) + waitBackgroundIdle(t, c) + if len(systemTexts(t, c)) != 0 { + t.Fatal("background completion unexpectedly launched foreground") + } } // TestPartsTextStripsInlineThink:MiniMax 行内 块不进子代理摘要/回投卡。 diff --git a/internal/runmgr/background_test.go b/internal/runmgr/background_test.go index 82cf077..aa328fe 100644 --- a/internal/runmgr/background_test.go +++ b/internal/runmgr/background_test.go @@ -49,7 +49,12 @@ func (noopRunner) Execute(rc runtime.RunCtx) error { func waitUntil(t *testing.T, cond func() bool) { t.Helper() - deadline := time.Now().Add(2 * time.Second) + waitUntilWithin(t, 2*time.Second, cond) +} + +func waitUntilWithin(t *testing.T, bound time.Duration, cond func() bool) { + t.Helper() + deadline := time.Now().Add(bound) for time.Now().Before(deadline) { if cond() { return @@ -79,6 +84,19 @@ func systemTexts(t *testing.T, c *runCtx) []string { return out } +// backgroundIdleBound only bounds a hang: each child settles through several +// fsync'd SQLite commits, and CI disk stalls have exceeded a 2s window. +const backgroundIdleBound = 20 * time.Second + +func waitBackgroundIdle(t *testing.T, c *runCtx) { + t.Helper() + waitUntilWithin(t, backgroundIdleBound, func() bool { + c.mgr.noticeMu.Lock() + defer c.mgr.noticeMu.Unlock() + return c.mgr.bgInFlight[c.run.ConversationID] == 0 + }) +} + func TestBackgroundDoesNotBlockParent(t *testing.T) { entered := make(chan struct{}) release := make(chan struct{}) @@ -98,18 +116,9 @@ func TestBackgroundDoesNotBlockParent(t *testing.T) { t.Fatal("child did not start") } close(release) - waitUntil(t, func() bool { - if len(systemTexts(t, c)) != 1 { - return false - } - c.mgr.convMu.Lock() - busy := c.mgr.convActive[c.run.ConversationID] - c.mgr.convMu.Unlock() - return !busy - }) - text := systemTexts(t, c)[0] - if !strings.Contains(text, "检索") || !strings.Contains(text, "") || !strings.Contains(text, "后台结果") { - t.Fatalf("notice = %s", text) + waitBackgroundIdle(t, c) + if texts := systemTexts(t, c); len(texts) != 0 { + t.Fatalf("completion manufactured foreground notice: %v", texts) } } @@ -126,28 +135,14 @@ func TestBackgroundQueuesUntilIdleThenMerges(t *testing.T) { if _, err := c.SpawnBackground("agent.sub.general", "乙", "任务乙", ""); err != nil { t.Fatal(err) } - waitUntil(t, func() bool { - c.mgr.noticeMu.Lock() - defer c.mgr.noticeMu.Unlock() - return len(c.mgr.notices[c.run.ConversationID]) == 2 - }) + waitBackgroundIdle(t, c) if len(systemTexts(t, c)) != 0 { t.Fatal("notice delivered while conversation was busy") } c.mgr.releaseConversation(c.run.ConversationID) c.mgr.deliverNotices(c.run.ConversationID) - waitUntil(t, func() bool { - if len(systemTexts(t, c)) != 1 { - return false - } - c.mgr.convMu.Lock() - busy := c.mgr.convActive[c.run.ConversationID] - c.mgr.convMu.Unlock() - return !busy - }) - text := systemTexts(t, c)[0] - if !strings.Contains(text, "甲") || !strings.Contains(text, "乙") { - t.Fatalf("merged notice = %s", text) + if len(systemTexts(t, c)) != 0 { + t.Fatal("idle transition inserted background reply") } runs, err := st.ListRunsByConversation(c.run.ConversationID) if err != nil { @@ -159,8 +154,8 @@ func TestBackgroundQueuesUntilIdleThenMerges(t *testing.T) { conv++ } } - if conv != 1 { - t.Fatalf("conversation runs = %d, want 1", conv) + if conv != 0 { + t.Fatalf("conversation runs = %d, want 0", conv) } hist := c.History() seen := false @@ -169,8 +164,8 @@ func TestBackgroundQueuesUntilIdleThenMerges(t *testing.T) { seen = true } } - if !seen { - t.Fatal("history dropped the notice") + if seen { + t.Fatal("background result entered foreground history") } } @@ -188,14 +183,7 @@ func TestBackgroundChildCannotDelegate(t *testing.T) { if _, err := c.SpawnBackground("agent.sub.general", "后台", "任务", ""); err != nil { t.Fatal(err) } - defer func() { - waitUntil(t, func() bool { - c.mgr.convMu.Lock() - busy := c.mgr.convActive[c.run.ConversationID] - c.mgr.convMu.Unlock() - return !busy && len(systemTexts(t, c)) == 1 - }) - }() + defer waitBackgroundIdle(t, c) select { case err := <-got: if err == nil || !strings.Contains(err.Error(), "子代理不能再派发子代理") { diff --git a/internal/runmgr/checkpoint_claim_guard.go b/internal/runmgr/checkpoint_claim_guard.go new file mode 100644 index 0000000..002e072 --- /dev/null +++ b/internal/runmgr/checkpoint_claim_guard.go @@ -0,0 +1,99 @@ +package runmgr + +import ( + core "agent-system-core" + "fmt" + "strings" + "unicode/utf8" +) + +// Approval is a high-consequence status. A source-backed material_record is +// rendered from the exact quote by the host; free-form model analysis cannot +// turn an ordinary requirement or suggestion into an approved decision. +// This narrow language guard is not a general semantic verifier. +var affirmativeApprovalTerms = []string{ + "已批准", "已经批准", "已获批", "审批通过", "批复同意", "已通过审批", +} + +var approvalImmediateQualifiers = []string{ + "是否", "不等于", "不代表", "并非", "不是", "尚待确认是否", "需要确认是否", + "尚未", "未获", "未被", "未曾", "并未", "没有", "未", +} + +func approvalIsQuestionOrNegation(prefix, suffix string) bool { + for _, qualifier := range approvalImmediateQualifiers { + if strings.HasSuffix(prefix, qualifier) { + return true + } + } + suffix = strings.TrimSpace(suffix) + for _, question := range []string{"了吗", "了么", "了没有", "吗", "么", "没有", "?", "?"} { + if strings.HasPrefix(suffix, question) { + return true + } + } + for _, framing := range []string{"写成", "说成", "当作", "视为", "认定为"} { + if !strings.HasSuffix(prefix, framing) { + continue + } + for _, prevention := range []string{"防止", "避免", "不要", "不能", "不得"} { + if strings.Contains(prefix, prevention) { + return true + } + } + } + return false +} + +func hasAffirmativeApprovalClaim(text string) bool { + claimInClause := func(clause string) bool { + for _, term := range affirmativeApprovalTerms { + for start := 0; start < len(clause); { + at := strings.Index(clause[start:], term) + if at < 0 { + break + } + at += start + prefix := []rune(clause[:at]) + if len(prefix) > 16 { + prefix = prefix[len(prefix)-16:] + } + if !approvalIsQuestionOrNegation(string(prefix), clause[at+len(term):]) { + return true + } + start = at + len(term) + } + } + return false + } + start := 0 + for i, r := range text { + if strings.ContainsRune("。;;,,!??!\n::", r) { + end := i + utf8.RuneLen(r) + if claimInClause(text[start:end]) { + return true + } + start = end + } + } + return claimInClause(text[start:]) +} + +func validateMaterialApprovalClaims(candidate core.WorkCheckpointCandidate) error { + for _, text := range []string{candidate.Focus, candidate.Remaining, candidate.NextStep} { + if hasAffirmativeApprovalClaim(text) { + return fmt.Errorf("checkpoint.claim: approval needs explicit source record") + } + } + for _, finding := range candidate.Findings { + if finding.Kind == "material_record" { + continue // The host replaces all model-authored display text with exact quotes. + } + for _, text := range []string{finding.Text, finding.Impact, finding.Assumptions} { + if hasAffirmativeApprovalClaim(text) { + return fmt.Errorf("checkpoint.claim: approval needs explicit source record") + } + } + } + return nil +} diff --git a/internal/runmgr/checkpoint_claim_guard_test.go b/internal/runmgr/checkpoint_claim_guard_test.go new file mode 100644 index 0000000..01cea13 --- /dev/null +++ b/internal/runmgr/checkpoint_claim_guard_test.go @@ -0,0 +1,89 @@ +package runmgr + +import ( + "strings" + "testing" +) + +func TestMaterialApprovalClaims(t *testing.T) { + tests := []struct { + name string + kind string + field string + value string + wantPending bool + }{ + {"approved text", "analysis", "text", "预算是已批准决定", true}, + {"observed A01 approval error", "analysis", "text", "需求.txt 的总预算50万元、12项需求范围与3项外部接口依赖均写为规定/包含,是已批准决定;接口交付日期尚未确认", true}, + {"approved impact", "analysis", "impact", "审批通过,可执行", true}, + {"approved assumption", "provisional", "assumptions", "批复同意", true}, + {"approved focus", "material_record", "focus", "预算已获批", true}, + {"approved remaining", "material_record", "remaining", "已通过审批", true}, + {"approved next step", "material_record", "nextStep", "预算已经批准,可以推进", true}, + {"approval incomplete", "analysis", "text", "审批尚未完成", false}, + {"not yet approved", "analysis", "text", "预算尚未审批通过", false}, + {"approval not obtained", "needs_user", "text", "预算未获审批通过", false}, + {"approval question", "analysis", "text", "是否已批准需核对", false}, + {"approval question particle", "needs_user", "text", "预算已批准吗", false}, + {"approval direct question", "needs_user", "text", "预算已批准?", false}, + {"approval question aspect", "needs_user", "text", "预算已批准了吗", false}, + {"prevent false approval", "analysis", "text", "防止把50万写成已批准", false}, + {"unrelated prevention does not negate approval", "analysis", "text", "防止风险且预算已批准", true}, + {"unrelated question does not negate approval", "analysis", "text", "是否要修订且预算已批准", true}, + {"later denial does not erase prior assertion", "analysis", "text", "预算已批准但会议说尚未批准", true}, + {"other affirmative after negation", "analysis", "text", "预算尚未审批通过,另一项已批准", true}, + {"other affirmative after question", "analysis", "text", "预算已批准吗?另一项已批准", true}, + {"not an approved increase", "analysis", "text", "不等于已批准增加经费", false}, + {"record is overwritten", "material_record", "text", "上线六月已经批准", false}, + {"user question", "needs_user", "text", "请确认预算是否已批准", false}, + {"user action assertion", "needs_user", "text", "预算已批准,请确认下一步", true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + c := checkpointEnvironment(t) + cp := validCheckpoint(c, "approval-claim") + cp.Findings[0].Kind = tc.kind + if tc.kind == "analysis" || tc.kind == "provisional" { + cp.Findings[0].Assumptions = "材料来源仍需核对" + } + switch tc.field { + case "text": + cp.Findings[0].Text = tc.value + case "impact": + cp.Findings[0].Impact = tc.value + case "assumptions": + cp.Findings[0].Assumptions = tc.value + case "focus": + cp.Focus = tc.value + case "remaining": + cp.Remaining = tc.value + case "nextStep": + cp.NextStep = tc.value + } + _, err := c.PublishWorkCheckpoint(cp) + if err != nil { + t.Fatalf("valid source should become a safe checkpoint: %v", err) + } + card, getErr := c.mgr.st.GetCard(c.run.Owner, c.task.cardID) + if getErr != nil { + t.Fatal(getErr) + } + if len(card.Task.Findings) != 1 { + t.Fatal("finding missing") + } + f := card.Task.Findings[0] + if tc.wantPending { + if tc.field == "focus" || tc.field == "remaining" || tc.field == "nextStep" { + if hasAffirmativeApprovalClaim(card.Task.Focus + card.Task.Remaining + card.Task.NextStep) { + t.Fatal("unsupported approval escaped into task status") + } + } else if f.Kind != "needs_user" || hasAffirmativeApprovalClaim(f.Text+f.Impact+f.Assumptions) { + t.Fatalf("unsupported approval was not converted to a pending question: %+v", f) + } + } + if tc.kind == "material_record" && (f.Kind != "material_record" || strings.Contains(f.Text, "已经批准")) { + t.Fatal("model paraphrase escaped material_record canonicalization") + } + }) + } +} diff --git a/internal/runmgr/commands.go b/internal/runmgr/commands.go index c520651..ecd8cbc 100644 --- a/internal/runmgr/commands.go +++ b/internal/runmgr/commands.go @@ -170,7 +170,7 @@ func (m *Manager) applyHITLCommand(c ownedCommand) { _, _, err := m.ResolveHITL(p.Owner, p.CardID, resp) if err != nil { // 过期/未知 gate:明确销项(resolved + 注记),发出可见结论。 - _ = m.st.ResolveHITLCard(p.Owner, p.CardID, core.HITLResolvedPayload{ + _ = m.resolveHITLCardForEffectiveAccount(p.Owner, p.CardID, core.HITLResolvedPayload{ CardID: core.ID(p.CardID), Kind: "expired", Answer: "跨实例审批迟到:gate 已过期或执行已移交", }) log.Printf("[runmgr] hitl %s (card %s) rejected: %v", c.CommandID, p.CardID, err) diff --git a/internal/runmgr/document_delivery.go b/internal/runmgr/document_delivery.go new file mode 100644 index 0000000..1478dec --- /dev/null +++ b/internal/runmgr/document_delivery.go @@ -0,0 +1,174 @@ +package runmgr + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "strings" + + "agent-system-backend/internal/docgen" + "agent-system-backend/internal/store" + core "agent-system-core" +) + +func (t *toolCtx) ExecuteDocumentDelivery(ctx context.Context, request docgen.OperationRequest) (docgen.OperationReceipt, error) { + receipt, err := t.rc.executeDocumentDelivery(ctx, request) + if receipt.Outcome == "complete" || receipt.Outcome == "unknown" { + for _, artifact := range receipt.Artifacts { + if artifact.Role != string(store.DocumentArtifactArchive) { + continue + } + t.arts = append(t.arts, core.Artifact{ + ID: core.ID(artifact.ID), ProjectID: t.rc.run.ProjectID, Owner: t.rc.run.Owner, + Name: artifact.Name, Kind: "document", MIME: artifact.MIME, Size: artifact.Size, + }) + break + } + } + return receipt, err +} + +func (c *runCtx) executeDocumentDelivery(ctx context.Context, operation docgen.OperationRequest) (docgen.OperationReceipt, error) { + if ctx == nil { + return docgen.OperationReceipt{}, errors.New("document delivery: nil context") + } + if err := c.mgr.authorizeRunWork(ctx, c.run.Owner, c.run.ProjectID); err != nil { + return docgen.OperationReceipt{}, err + } + requestHash, err := documentOperationHash(operation) + if err != nil { + return docgen.OperationReceipt{}, err + } + record, err := c.mgr.st.BeginDocumentDeliveryAccountChecked(c.run.Owner, store.DocumentDeliveryBeginInput{ + OperationID: operation.OperationID, RequestSHA256: requestHash, + ProjectID: string(c.run.ProjectID), ConversationID: string(c.run.ConversationID), SourceRun: string(c.run.ID), + DocumentID: operation.DocumentID, Mode: string(operation.Mode), RendererIdentity: docgen.RendererIdentity, + }, c.mgr.accountAllowImplicitLocal) + if err != nil { + return receiptFromDelivery(record, "partial", false, err), err + } + if record.State == store.DocumentDeliveryComplete { + return receiptFromDelivery(record, "complete", true, nil), nil + } + + request := docgen.Request{ + DocumentID: operation.DocumentID, Title: operation.Title, DocumentType: operation.DocumentType, Language: operation.Language, + Markdown: operation.Markdown, Mode: operation.Mode, FrozenAt: record.FrozenAt, RequestedBy: c.run.Owner, + Controlled: operation.Controlled, ProjectDocument: operation.ProjectDocument, + } + if record.MarkdownArtifactID != "" { + persisted, readErr := c.mgr.st.DocumentDeliveryMarkdown(c.run.Owner, operation.OperationID) + if readErr != nil { + return receiptFromDelivery(record, "unknown", false, readErr), readErr + } + request.Markdown = persisted + } + prepared, err := docgen.Prepare(ctx, request) + if err != nil { + return receiptFromDelivery(record, "partial", false, err), err + } + if record.MarkdownArtifactID == "" { + record, err = c.mgr.st.PersistDocumentDeliveryMarkdownAccountChecked(c.run.Owner, operation.OperationID, + deliveryArtifactInput(store.DocumentArtifactMarkdown, prepared.Markdown), prepared.CanonicalHash, + c.mgr.accountAllowImplicitLocal) + if err != nil { + outcome := "partial" + if errors.Is(err, store.ErrDocumentDeliveryOutcomeUnknown) { + outcome = "unknown" + } + return receiptFromDelivery(record, outcome, false, err), err + } + } + request.LegacySource = append([]byte(nil), operation.Markdown...) + request.Markdown = prepared.Markdown.Bytes + if err := c.mgr.authorizeRunWork(ctx, c.run.Owner, c.run.ProjectID); err != nil { + return receiptFromDelivery(record, "partial", false, err), err + } + delivery, err := docgen.GenerateWithOptions(ctx, request, docgen.RenderOptions{MigrationComparison: c.mgr.documentMigrationComparison}) + if err != nil { + record, markErr := c.mgr.st.MarkDocumentDeliveryFailedAccountChecked(c.run.Owner, operation.OperationID, err.Error(), c.mgr.accountAllowImplicitLocal) + if markErr != nil { + return receiptFromDelivery(record, "unknown", false, markErr), errors.Join(err, markErr) + } + return receiptFromDelivery(record, "partial", false, err), err + } + if err := c.mgr.authorizeRunWork(ctx, c.run.Owner, c.run.ProjectID); err != nil { + return receiptFromDelivery(record, "partial", false, err), err + } + deliveryKey := documentDeliveryKey(delivery) + record, err = c.mgr.st.CommitDocumentDeliveryDerivedAccountChecked(c.run.Owner, operation.OperationID, + deliveryKey, delivery.Profile.SnapshotHash, delivery.Renderer.ReviewHash, + []store.DocumentDeliveryArtifactInput{ + deliveryArtifactInput(store.DocumentArtifactDOCX, delivery.DOCX), + deliveryArtifactInput(store.DocumentArtifactManifest, delivery.Manifest), + deliveryArtifactInput(store.DocumentArtifactArchive, delivery.Archive), + }, c.mgr.accountAllowImplicitLocal) + if err != nil { + outcome := "partial" + if errors.Is(err, store.ErrDocumentDeliveryOutcomeUnknown) { + outcome = "unknown" + } + return receiptFromDelivery(record, outcome, false, err), err + } + return receiptFromDelivery(record, "complete", false, nil), nil +} + +func documentOperationHash(operation docgen.OperationRequest) (string, error) { + payload := struct { + DocumentID string `json:"documentId"` + Title string `json:"title"` + DocumentType string `json:"documentType"` + Language string `json:"language"` + Markdown string `json:"markdown"` + Mode string `json:"mode"` + Controlled bool `json:"controlled"` + ProjectDocument bool `json:"projectDocument"` + }{ + DocumentID: operation.DocumentID, Title: operation.Title, DocumentType: operation.DocumentType, + Language: operation.Language, Markdown: string(operation.Markdown), Mode: string(operation.Mode), + Controlled: operation.Controlled, ProjectDocument: operation.ProjectDocument, + } + raw, err := json.Marshal(payload) + if err != nil { + return "", err + } + sum := sha256.Sum256(raw) + return hex.EncodeToString(sum[:]), nil +} + +func documentDeliveryKey(delivery docgen.Delivery) string { + manifest := delivery.Renderer + value := strings.Join([]string{manifest.DocumentHash, manifest.ProfileHash, manifest.ReviewHash, string(manifest.Mode), docgen.RendererIdentity}, "\x00") + sum := sha256.Sum256([]byte(value)) + return hex.EncodeToString(sum[:]) +} + +func deliveryArtifactInput(role store.DocumentDeliveryArtifactRole, artifact docgen.Artifact) store.DocumentDeliveryArtifactInput { + kind := "document" + if artifact.MIME == "application/json" { + kind = "data" + } + return store.DocumentDeliveryArtifactInput{ + Role: role, Name: artifact.Name, Kind: kind, MIME: artifact.MIME, + SHA256: artifact.SHA256, Content: artifact.Bytes, + } +} + +func receiptFromDelivery(record store.DocumentDeliveryRecord, outcome string, cacheHit bool, cause error) docgen.OperationReceipt { + receipt := docgen.OperationReceipt{ + OperationID: record.OperationID, Outcome: outcome, State: string(record.State), FrozenAt: record.FrozenAt, + DeliveryKey: record.DeliveryKey, Renderer: record.RendererIdentity, CacheHit: cacheHit, + } + if cause != nil { + receipt.Error = cause.Error() + } + for _, item := range record.Artifacts { + receipt.Artifacts = append(receipt.Artifacts, docgen.ReceiptArtifact{ + Role: string(item.Role), ID: string(item.Artifact.ID), Name: item.Artifact.Name, + MIME: item.Artifact.MIME, Size: item.Artifact.Size, SHA256: item.SHA256, + }) + } + return receipt +} diff --git a/internal/runmgr/document_delivery_test.go b/internal/runmgr/document_delivery_test.go new file mode 100644 index 0000000..873b2c6 --- /dev/null +++ b/internal/runmgr/document_delivery_test.go @@ -0,0 +1,170 @@ +package runmgr + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "agent-system-backend/internal/bus" + "agent-system-backend/internal/docgen" + "agent-system-backend/internal/settings" + "agent-system-backend/internal/store" + "agent-system-backend/internal/tools" + core "agent-system-core" +) + +func TestDocumentDeliveryReportsPartialAndUnknownReceiptsWithStableRetry(t *testing.T) { + tests := []struct { + name string + stage string + wantOutcome string + wantCount int + }{ + {"derived staging", store.DocumentDeliveryFaultAfterManifestBlob, "partial", 1}, + {"post commit", store.DocumentDeliveryFaultAfterDerivedCommit, "unknown", 4}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + run, st := newDocumentDeliveryRun(t) + st.SetDocumentDeliveryFault(test.stage, errors.New("injected "+test.stage)) + arguments := json.RawMessage(`{"operationId":"receipt-operation","name":"Receipt","documentId":"receipt","documentType":"general","language":"en","markdown":"# Receipt\n\nbody\n","mode":"preview","controlled":false,"projectDocument":false}`) + failed := run.ExecuteTool(core.ToolCallRequest{CallID: "delivery-failed", Name: "render_document", Arguments: arguments}) + if failed.Status != "error" { + t.Fatalf("fault result = %#v", failed) + } + var receipt docgen.OperationReceipt + if err := json.Unmarshal([]byte(strings.TrimPrefix(failed.Output, "render_document: ")), &receipt); err != nil { + t.Fatalf("decode failure receipt: %v\n%s", err, failed.Output) + } + if receipt.Outcome != test.wantOutcome || len(receipt.Artifacts) != test.wantCount || receipt.FrozenAt.IsZero() { + t.Fatalf("failure receipt = %#v", receipt) + } + firstIDs := receiptArtifactIdentity(receipt.Artifacts) + + retry := run.ExecuteTool(core.ToolCallRequest{CallID: "delivery-retry", Name: "render_document", Arguments: arguments}) + if retry.Status != "ok" { + t.Fatalf("retry failed: %s", retry.Output) + } + var completed docgen.OperationReceipt + if err := json.Unmarshal([]byte(retry.Output), &completed); err != nil { + t.Fatal(err) + } + if completed.Outcome != "complete" || len(completed.Artifacts) != 4 || !completed.FrozenAt.Equal(receipt.FrozenAt) { + t.Fatalf("completed receipt = %#v", completed) + } + for role, identity := range firstIDs { + if got := receiptArtifactIdentity(completed.Artifacts)[role]; got != identity { + t.Errorf("artifact %s changed on retry: %s -> %s", role, identity, got) + } + } + }) + } +} + +func TestDocumentMigrationComparisonRequiresTrustedManagerCapability(t *testing.T) { + run, st := newDocumentDeliveryRun(t) + arguments := json.RawMessage(`{"operationId":"comparison-operation","name":"Comparison","documentId":"comparison","documentType":"general","language":"en","markdown":"# Comparison\n\nBody with **bold** and [safe](https://example.com/private).\n","mode":"preview","controlled":false,"projectDocument":false,"migrationComparison":true}`) + rejected := run.ExecuteTool(core.ToolCallRequest{CallID: "comparison-model", Name: "render_document", Arguments: arguments}) + if rejected.Status != "error" || !strings.Contains(rejected.Output, "unknown field") { + t.Fatalf("model enabled comparison: %#v", rejected) + } + + run.mgr.documentMigrationComparison = true + arguments = json.RawMessage(`{"operationId":"comparison-operation","name":"Comparison","documentId":"comparison","documentType":"general","language":"en","markdown":"# Comparison\n\nBody with **bold** and [safe](https://example.com/private).\n","mode":"preview","controlled":false,"projectDocument":false}`) + result := run.ExecuteTool(core.ToolCallRequest{CallID: "comparison-host", Name: "render_document", Arguments: arguments}) + if result.Status != "ok" { + t.Fatalf("trusted comparison: %s", result.Output) + } + var receipt docgen.OperationReceipt + if err := json.Unmarshal([]byte(result.Output), &receipt); err != nil { + t.Fatal(err) + } + var manifestID string + for _, artifact := range receipt.Artifacts { + if artifact.Role == string(store.DocumentArtifactManifest) { + manifestID = artifact.ID + } + } + raw, err := st.ArtifactContent("alice", manifestID) + if err != nil { + t.Fatal(err) + } + var manifest docgen.Manifest + if err := json.Unmarshal(raw, &manifest); err != nil { + t.Fatal(err) + } + if !manifest.Comparison.Enabled || manifest.Comparison.Assessment == "" || len(manifest.Comparison.Dimensions) == 0 { + t.Fatalf("trusted comparison was not assessed: %#v", manifest.Comparison) + } +} + +func TestDocumentRendererFailureKeepsAuthoritativeMarkdownForRetry(t *testing.T) { + run, st := newDocumentDeliveryRun(t) + arguments := json.RawMessage(`{"operationId":"renderer-failure","name":"Failure","documentId":"failure","documentType":"general","language":"en","markdown":"# Failure\n\n![missing](asset:missing@1.0.0)\n","mode":"preview","controlled":false,"projectDocument":false}`) + first := run.ExecuteTool(core.ToolCallRequest{CallID: "renderer-failure-1", Name: "render_document", Arguments: arguments}) + if first.Status != "error" { + t.Fatalf("renderer failure result = %#v", first) + } + var firstReceipt docgen.OperationReceipt + if err := json.Unmarshal([]byte(strings.TrimPrefix(first.Output, "render_document: ")), &firstReceipt); err != nil { + t.Fatal(err) + } + if firstReceipt.Outcome != "partial" || len(firstReceipt.Artifacts) != 1 || firstReceipt.Artifacts[0].Role != string(store.DocumentArtifactMarkdown) { + t.Fatalf("renderer failure lost Markdown receipt: %#v", firstReceipt) + } + markdown, err := st.ArtifactContent("alice", firstReceipt.Artifacts[0].ID) + if err != nil || !strings.Contains(string(markdown), "` -可选主题。build 退出码非 0 时按输出的诊断改 .md 源对应段落后重建;修改交付物 = edit 源 + 重建。 -Excel/PPT 用对应 kit 的 run_python 脚本。 +Markdown 支持 `#`-`######` 标题、段落、引用、围栏/行内代码、粗体、斜体、删除线、安全链接标签、`-`/`1.` 列表、GFM 表格、已声明 `asset:` 图片、 +`:::callout warning`/`:::` 提示块和 `:::page-break`。脚本、HTML、外链图片、未知指令和未声明资产会 fail-closed。 + +迁移对照只能由 Backend 内部受信能力运行,不能由模型、Python 或环境变量开启。Excel/PPT 仍用对应 kit 的 run_python 脚本。 ## 自检 ```bash -python3 -m housestyle.lint 交付物.docx -python3 -m housestyle.outline_cli 交付物.docx +python3 -m housestyle.lint 交付物.xlsx +python3 -m housestyle.outline_cli 交付物.pptx ``` -lint 打印 JSON。`severity` 为 `error` 时退出码是 1,必须修好再 `register_artifact`。模板生成的文件会把纸张、字体、主题色、页码降为 warning,结构问题(空段、空节、手打符号、跳级)仍然是 error。 +lint 打印 JSON。`severity` 为 `error` 时退出码是 1,必须修好再 `register_artifact`。Word 的安全、OPC、锚点和结构诊断由共享 renderer 写入 manifest。 回读结果要和动笔前的结构一致:没有空节,表格列和内容对应,页数或幻灯片数合理。 diff --git a/internal/skills/recipes/house-style/housestyle/build.py b/internal/skills/recipes/house-style/housestyle/build.py index 8230ae5..c720dff 100644 --- a/internal/skills/recipes/house-style/housestyle/build.py +++ b/internal/skills/recipes/house-style/housestyle/build.py @@ -1,169 +1,20 @@ -"""交付物源码化渲染:python3 -m housestyle.build <源.md> [-o 输出.docx]。 +"""Disabled legacy Word builder. -把可编辑的 Markdown 源文件确定性渲染为 docx(docx_kit 构件 + 主题), -写入即诊断:渲染后自动跑 lint 并回读结构,severity=error 时退出码 1, -诊断信息带触发段落原文,模型据此改 .md 源再重建(改源码→编译→看诊断), -不再整段重写生成脚本。xlsx/pptx 等其他类型仍走 run_python + 对应 kit。 - -Markdown 约定: - # 标题 文档标题(Title,只取第一个) - > 副标题或日期 标题下副题(只取标题后的第一段 >) - ## 一级 / ### 二级 / #### 三级 Heading 1/2/3(标题层级连续) - - 或 * 条目 项目符号 - 1. 条目 编号列表 - | a | b | 表格(首个分隔行 |---|---| 定表头) - > 引文 引用块(副题之后) - !!! 标签 文本 强调块(label=标签,缺省「摘要」) - 主题与强调色(可省) +Word delivery is a trusted host operation implemented by render_document. A +model-controlled Python process cannot authorize migration comparison or emit +a trusted DOCX receipt, so this module never writes Word output. """ from __future__ import annotations -import json import sys -from pathlib import Path - -from housestyle import docx_kit as dx -from housestyle import lint def build(source: str, output: str | None = None) -> int: - src = Path(source) - if not src.is_file(): - print(f"build: 源文件不存在: {source}", file=sys.stderr) - return 2 - text = src.read_text(encoding="utf-8") - out = Path(output or str(src.with_suffix(".docx"))) - if out.suffix.lower() not in (".docx",): - print(f"build: 输出只支持 .docx(其他类型用 run_python + 对应 kit)", file=sys.stderr) - return 2 - - theme, accent = "modern", None - lines = text.splitlines() - if lines and lines[0].strip().startswith("", "").split(): - if "=" in kv: - k, v = kv.split("=", 1) - if k == "theme": - theme = v - elif k == "accent": - accent = v - lines = lines[1:] - - doc = dx.new_document(theme=theme, accent=accent, title=_first_title(lines) or out.stem) - _render(doc, lines) - dx.save(doc, str(out)) - - issues = lint.lint_path(str(out)) - errors = [i for i in issues if i.get("severity") == "error"] - from housestyle.outline import summarize - - print(f"built {out.name}") - print(summarize(str(out))) - if issues: - print(json.dumps(issues, ensure_ascii=False, indent=2)) - if errors: - print(f"lint: {len(errors)} 个 error——修 .md 源里对应段落后重新 build", file=sys.stderr) - return 1 - print("lint: 通过(0 error)") - return 0 - - -def _first_title(lines: list[str]) -> str | None: - for line in lines: - if line.startswith("# "): - return line[2:].strip() - return None - - -def _split_row(line: str) -> list[str]: - body = line.strip().strip("|") - return [c.strip() for c in body.split("|")] - - -def _is_table_sep(line: str) -> bool: - s = line.strip() - if not s.startswith("|"): - return False - cells = [c.strip() for c in s.strip("|").split("|")] - return bool(cells) and all(set(c) <= set("-: ") and c for c in cells) - - -def _render(doc, lines: list[str]) -> None: - title_seen = False - subtitle_used = False - i = 0 - pending_numbers: list[str] = [] - pending_bullets: list[str] = [] - - def flush_lists() -> None: - nonlocal pending_numbers, pending_bullets - if pending_bullets: - dx.add_bullets(doc, pending_bullets) - pending_bullets = [] - if pending_numbers: - dx.add_numbers(doc, pending_numbers) - pending_numbers = [] - - while i < len(lines): - raw = lines[i] - line = raw.rstrip() - stripped = line.strip() - if not stripped or stripped == "---": - flush_lists() - i += 1 - continue - if stripped.startswith("|") and i + 1 < len(lines) and _is_table_sep(lines[i + 1]): - flush_lists() - headers = _split_row(stripped) - rows = [] - j = i + 2 - while j < len(lines) and lines[j].strip().startswith("|"): - rows.append(_split_row(lines[j])) - j += 1 - dx.add_table(doc, headers, rows) - i = j - continue - flush_lists() - if stripped.startswith("# ") and not title_seen: - dx.add_title(doc, stripped[2:].strip()) - title_seen = True - elif stripped.startswith("#### "): - dx.add_heading(doc, stripped[5:].strip(), 3) - elif stripped.startswith("### "): - dx.add_heading(doc, stripped[4:].strip(), 2) - elif stripped.startswith("## "): - dx.add_heading(doc, stripped[3:].strip(), 1) - elif stripped.startswith("!!!"): - rest = stripped[3:].strip() - label, _, body = rest.partition(" ") - dx.add_callout(doc, body.strip() or rest, label.strip() or "摘要") - elif stripped.startswith("> ") or stripped.startswith(">"): - quote = stripped.lstrip(">").strip() - dx.add_quote(doc, quote, "") - elif stripped.startswith(("- ", "* ")): - pending_bullets.append(stripped[2:].strip()) - elif len(stripped) > 2 and stripped[0].isdigit() and stripped[1:3] in (". ", ") ") or ( - len(stripped) > 3 and stripped[:2].isdigit() and stripped[2:4] == ". " - ): - pending_numbers.append(stripped.split(".", 1)[1].strip() if "." in stripped else stripped) - else: - dx.add_paragraph(doc, stripped) - i += 1 - flush_lists() + del source, output + print("build: disabled; use the trusted render_document host operation", file=sys.stderr) + return 2 if __name__ == "__main__": - if len(sys.argv) < 2: - print("usage: python3 -m housestyle.build <源.md> [-o 输出.docx]", file=sys.stderr) - raise SystemExit(2) - args = sys.argv[1:] - out = None - if "-o" in args: - k = args.index("-o") - if k + 1 >= len(args): - print("build: -o 缺少输出路径", file=sys.stderr) - raise SystemExit(2) - out = args[k + 1] - args = args[:k] + args[k + 2 :] - raise SystemExit(build(args[0], out)) + raise SystemExit(build("", None)) diff --git a/internal/skills/recipes/house-style/housestyle/lint.py b/internal/skills/recipes/house-style/housestyle/lint.py index 0163705..916e53c 100644 --- a/internal/skills/recipes/house-style/housestyle/lint.py +++ b/internal/skills/recipes/house-style/housestyle/lint.py @@ -102,7 +102,11 @@ def lint_docx(path: str) -> list[dict]: def lint_pptx(path: str) -> list[dict]: - prs = Presentation(path) + try: + prs = Presentation(path) + except Exception as exc: + return [_issue(path, "error", "structure-broken", + f"无法按 PPTX 解析({type(exc).__name__}: {exc})——疑似手拼 OOXML 或文件损坏,必须用 housestyle kit 重建")] issues = [] templated = "housestyle-template" in (prs.core_properties.subject or "") @@ -111,23 +115,50 @@ def lint_pptx(path: str) -> list[dict]: severity = "warning" issues.append(_issue(path, severity, rule, message, where)) - if prs.slide_width and prs.slide_height: - ratio = prs.slide_width / prs.slide_height - if abs(ratio - 16 / 9) > 0.05: - add("slide-size", "error", f"不是 16:9(宽高比 {ratio:.3f})") - for index, slide in enumerate(prs.slides, start=1): - texts = [] - for shape in slide.shapes: - if not shape.has_text_frame: - continue - body = shape.text_frame.text.strip() - if body: - texts.append(body) - count = sum(1 for p in shape.text_frame.paragraphs if p.text.strip()) - if count > 6: - add("bullet-overflow", "error", f"一页超过 6 条要点({count})", f"slide {index}") - if not texts: - add("empty-title", "error", "空白页", f"slide {index}") + # 结构底线(OPC 合法性,不随模板降级):母版、主题部件必须存在,每张 + # slide 的版式→母版链可达。python-pptx 对缺失母版完全容忍,但 Office + # 会按残缺包拒绝打开;docx/xlsx 没有这类缺口(打开成功即要求核心部件存在)。 + try: + masters = list(prs.slide_masters) + except Exception as exc: + add("structure-broken", "error", f"母版列表不可读({type(exc).__name__}: {exc})") + masters = [] + if not masters: + add("structure-broken", "error", "没有幻灯片母版——缺母版/主题的包 Office 会判为损坏") + for master in masters: + try: + reltypes = [rel.reltype for rel in master.part.rels.values()] + except Exception as exc: + add("structure-broken", "error", f"母版关系不可读({type(exc).__name__}: {exc})") + continue + if not any(t.endswith("/theme") for t in reltypes): + add("structure-broken", "error", "母版没有关联主题(theme)") + + try: + slides = list(prs.slides) + except Exception as exc: + add("structure-broken", "error", f"幻灯片列表不可读({type(exc).__name__}: {exc})") + return issues + for index, slide in enumerate(slides, start=1): + try: + slide.slide_layout.slide_master + except Exception as exc: + add("structure-broken", "error", f"版式/母版链断裂({type(exc).__name__}: {exc})", f"slide {index}") + try: + texts = [] + for shape in slide.shapes: + if not shape.has_text_frame: + continue + body = shape.text_frame.text.strip() + if body: + texts.append(body) + count = sum(1 for p in shape.text_frame.paragraphs if p.text.strip()) + if count > 6: + add("bullet-overflow", "error", f"一页超过 6 条要点({count})", f"slide {index}") + if not texts: + add("empty-title", "error", "空白页", f"slide {index}") + except Exception as exc: + add("structure-broken", "error", f"幻灯片正文不可读({type(exc).__name__}: {exc})", f"slide {index}") return issues diff --git a/internal/skills/recipes/house-style/tests/test_housestyle.py b/internal/skills/recipes/house-style/tests/test_housestyle.py index 49fd0b3..ce7ffa2 100644 --- a/internal/skills/recipes/house-style/tests/test_housestyle.py +++ b/internal/skills/recipes/house-style/tests/test_housestyle.py @@ -1,5 +1,6 @@ import tempfile import unittest +from unittest import mock from pathlib import Path from docx import Document @@ -171,44 +172,86 @@ if __name__ == "__main__": class BuildTests(unittest.TestCase): - def test_build_renders_markdown_and_passes_lint(self): - import tempfile, os, subprocess, sys, json + def test_legacy_word_build_is_disabled_by_default(self): + import os from housestyle import build as B with tempfile.TemporaryDirectory() as d: src = os.path.join(d, "plan.md") out = os.path.join(d, "plan.docx") - open(src, "w", encoding="utf-8").write( - "# 测试方案:先写需求再开发\n\n" - "## 一、结论\n结论:先写需求文档。\n\n" - "- 第一步:停代码\n- 第二步:写需求\n\n" - "## 二、行动项\n\n| 事项 | 负责人 |\n|---|---|\n| 需求文档 | 陈威 |\n\n" - "1. 确认设计输入\n" - ) - self.assertEqual(B.build(src, out), 0) - self.assertTrue(os.path.exists(out)) - from housestyle.outline import summarize - o = summarize(out) - self.assertIn("测试方案", o) - self.assertIn("一、结论", o) - self.assertIn("tables 1", o) + Path(src).write_text("# 方案\n\n正文。\n", encoding="utf-8") + with mock.patch.dict(os.environ, {}, clear=False): + os.environ.pop("AGENT_SYSTEM_LEGACY_WORD_COMPARISON", None) + self.assertEqual(B.build(src, out), 2) + self.assertFalse(os.path.exists(out)) - def test_build_reports_lint_errors_with_nonzero_exit(self): - import tempfile, os + def test_model_set_environment_cannot_enable_legacy_word_build(self): + import os from housestyle import build as B with tempfile.TemporaryDirectory() as d: - # 标题跳级(## 后直接 ####)触发 heading-skip error —— 诊断导向改源。 - src = os.path.join(d, "bad.md") - out = os.path.join(d, "bad.docx") - open(src, "w", encoding="utf-8").write("# 标题\n\n## 一、章节\n\n#### 跳级\n内容\n") - self.assertEqual(B.build(src, out), 1) + src = os.path.join(d, "plan.md") + out = os.path.join(d, "plan.docx") + Path(src).write_text("# 方案\n\n正文。\n", encoding="utf-8") + with mock.patch.dict(os.environ, {"AGENT_SYSTEM_LEGACY_WORD_COMPARISON": "1"}): + self.assertEqual(B.build(src, out), 2) + self.assertFalse(os.path.exists(out)) def test_build_cli_entry(self): import subprocess, sys, tempfile, os with tempfile.TemporaryDirectory() as d: src = os.path.join(d, "s.md") - open(src, "w", encoding="utf-8").write("# T\n\n## 一、A\n内容\n") + Path(src).write_text("# T\n\n## 一、A\n内容\n", encoding="utf-8") r = subprocess.run([sys.executable, "-m", "housestyle.build", src, "-o", os.path.join(d, "s.docx")], - capture_output=True, text=True, cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - # PYTHONPATH 由调用方保证;这里直接在 house-style 根目录下跑模块。 - if r.returncode not in (0, 1): - self.fail(r.stderr[:200]) + capture_output=True, text=True, + cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + self.assertEqual(r.returncode, 2, r.stderr[:200]) + self.assertFalse(os.path.exists(os.path.join(d, "s.docx"))) + + +class BrokenPptxTests(unittest.TestCase): + """手拼 OOXML 的结构底线:缺母版/主题/版式链必须报 structure-broken。""" + + @staticmethod + def _broken_pptx(path): + import zipfile + ct = ('' + '' + '' + '' + '') + p = "http://schemas.openxmlformats.org/presentationml/2006/main" + parts = { + "[Content_Types].xml": ct, + "_rels/.rels": ('' + ''), + "ppt/presentation.xml": f'', + "ppt/_rels/presentation.xml.rels": ('' + ''), + "ppt/slides/slide1.xml": f'', + "ppt/slideLayouts/slideLayout1.xml": f'', + } + with zipfile.ZipFile(path, "w") as z: + for name, body in parts.items(): + z.writestr(name, body) + + def test_masterless_package_reports_structure_broken(self): + with tempfile.TemporaryDirectory() as tmp: + path = str(Path(tmp) / "broken.pptx") + self._broken_pptx(path) + rules = {item["rule"] for item in _errors(lint_pptx(path))} + self.assertIn("structure-broken", rules) + + def test_kit_output_has_no_structure_errors(self): + with tempfile.TemporaryDirectory() as tmp: + path = str(Path(tmp) / "ok.pptx") + prs = pptx_kit.new_presentation(theme="modern", title="自检") + pptx_kit.add_title_slide(prs, "标题", "副标题") + pptx_kit.save(prs, path) + rules = {item["rule"] for item in _errors(lint_pptx(path))} + self.assertNotIn("structure-broken", rules) + + def test_unopenable_file_reports_structure_broken(self): + with tempfile.TemporaryDirectory() as tmp: + path = str(Path(tmp) / "fake.pptx") + Path(path).write_text("这不是 zip", encoding="utf-8") + issues = lint_pptx(path) + self.assertEqual([item["rule"] for item in issues], ["structure-broken"]) diff --git a/internal/skills/skills_test.go b/internal/skills/skills_test.go index 0e389ff..ccbaf6a 100644 --- a/internal/skills/skills_test.go +++ b/internal/skills/skills_test.go @@ -6,6 +6,7 @@ import ( "os/exec" "path/filepath" "runtime" + "strings" "testing" ) @@ -135,8 +136,38 @@ func TestSeedUpgradesLegacyBuiltin(t *testing.T) { func TestNames(t *testing.T) { names := Names() - if len(names) != 4 || names[0] != "docx-author" || names[1] != "house-style" || names[3] != "xlsx-author" { - t.Fatalf("want four sorted builtin skills, got %v", names) + want := []string{"canvas-design", "copy-editing", "copywriting", "docx-author", "frontend-design", "house-style", "pptx-author", "xlsx-author"} + if len(names) != len(want) { + t.Fatalf("want %d sorted builtin skills, got %d: %v", len(want), len(names), names) + } + for i := range want { + if names[i] != want[i] { + t.Fatalf("builtin skill order drifted at %d: want %v, got %v", i, want, names) + } + } +} + +// 第三方 vendor 的技能必须带 UPSTREAM.md 溯源(来源、上游固定 SHA、许可证、 +// 适配说明),保证可追溯升级与许可合规;缺失即测试失败。 +func TestVendoredSkillsCarryProvenance(t *testing.T) { + vendored := []string{"canvas-design", "copy-editing", "copywriting", "frontend-design"} + for _, name := range vendored { + data, err := recipes.ReadFile("recipes/" + name + "/UPSTREAM.md") + if err != nil { + t.Fatalf("vendored skill %s missing UPSTREAM.md: %v", name, err) + } + s := string(data) + for _, marker := range []string{"https://github.com/", "上游固定版本", "许可证"} { + if !strings.Contains(s, marker) { + t.Fatalf("vendored skill %s UPSTREAM.md missing %q", name, marker) + } + } + } + if _, err := recipes.ReadFile("recipes/frontend-design/LICENSE.txt"); err != nil { + t.Fatalf("frontend-design upstream license missing: %v", err) + } + if _, err := recipes.ReadFile("recipes/copywriting/UPSTREAM-LICENSE"); err != nil { + t.Fatalf("copywriting upstream license missing: %v", err) } } @@ -146,6 +177,47 @@ func TestEmbedIncludesPackageInit(t *testing.T) { } } +func TestHouseStyleRoutesProductionWordThroughUnifiedRenderer(t *testing.T) { + raw, err := recipes.ReadFile("recipes/house-style/SKILL.md") + if err != nil { + t.Fatal(err) + } + body := string(raw) + for _, required := range []string{"render_document", "已发布", "2026-10-16", "approved", "RDM", "禁止 Backend 默认回退"} { + if !strings.Contains(body, required) { + t.Errorf("house-style skill missing %q", required) + } + } + for _, stale := range []string{"pending-human-visual-review", "默认候选"} { + if strings.Contains(body, stale) { + t.Errorf("house-style still describes the organization default as unpublished: %q", stale) + } + } + for _, forbidden := range []string{"AGENT_SYSTEM_LEGACY_WORD_COMPARISON", "docx_kit", "new_document(template=", "dx.save("} { + if strings.Contains(body, forbidden) { + t.Fatalf("house-style still advertises Word bypass %q", forbidden) + } + } +} + +func TestDocxAuthorDescribesPublishedDefaultOnlyForWorkspaceWord(t *testing.T) { + raw, err := recipes.ReadFile("recipes/docx-author/SKILL.md") + if err != nil { + t.Fatal(err) + } + body := string(raw) + for _, required := range []string{"render_document", "已发布", "2026-10-16", "approved", "RDM", "禁止默认回退"} { + if !strings.Contains(body, required) { + t.Errorf("docx-author skill missing %q", required) + } + } + for _, stale := range []string{"pending-human-visual-review", "默认候选"} { + if strings.Contains(body, stale) { + t.Errorf("docx-author still describes the organization default as unpublished: %q", stale) + } + } +} + func TestHouseStylePython(t *testing.T) { if _, err := exec.LookPath("python3"); err != nil { t.Skip("python3 not available") diff --git a/internal/squadadapter/entry.go b/internal/squadadapter/entry.go index 902c3e7..60c674d 100644 --- a/internal/squadadapter/entry.go +++ b/internal/squadadapter/entry.go @@ -75,9 +75,10 @@ func RunEntry(rc Ctx, opt EntryOptions) (EntryResult, error) { hostNames = append(hostNames, tool.Name()) } registry := squad.NewRoleRegistry() + projectRun := rc.Run().ProjectID != "" for _, role := range kernelRoles(roleCaps{ host: hostNames, exec: cfg.Exec != nil, skills: len(cfg.SkillsRoots) > 0, - search: cfg.Search != nil, fetch: cfg.Fetch != nil, + search: cfg.Search != nil, fetch: cfg.Fetch != nil, workspace: !projectRun, }) { if err := registry.Register(role); err != nil { return EntryResult{}, fmt.Errorf("squad entry: register %s: %w", role.Role, err) @@ -167,15 +168,19 @@ func readDeliverable(res *squad.RunResult) string { } type roleCaps struct { - host []string - exec bool - skills bool - search bool - fetch bool + host []string + exec bool + skills bool + search bool + fetch bool + workspace bool } func kernelRoles(caps roleCaps) []squad.RoleSpec { - base := []string{"read_file", "write_file", "list_dir", "grep"} + base := []string{} + if caps.workspace { + base = []string{"read_file", "write_file", "list_dir", "grep"} + } writerTools := append([]string{}, base...) writerTools = append(writerTools, "edit_file", "multi_edit", "glob") if caps.exec { diff --git a/internal/store/account_budget_read_test.go b/internal/store/account_budget_read_test.go new file mode 100644 index 0000000..3b3bb25 --- /dev/null +++ b/internal/store/account_budget_read_test.go @@ -0,0 +1,15 @@ +package store + +import "testing" + +func TestZeroTokenBudgetLookupDoesNotWrite(t *testing.T) { + st := openTestStore(t) + status, err := st.AccountTokens("viewer", 0, 100) + if err != nil || status.Total != 0 || status.Over { + t.Fatalf("empty budget lookup: %+v %v", status, err) + } + var rows int + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM llm_usage_daily WHERE owner='viewer'`).Scan(&rows); err != nil || rows != 0 { + t.Fatalf("read-only budget lookup wrote %d rows: %v", rows, err) + } +} diff --git a/internal/store/account_cursor_key_test.go b/internal/store/account_cursor_key_test.go new file mode 100644 index 0000000..c953e3d --- /dev/null +++ b/internal/store/account_cursor_key_test.go @@ -0,0 +1,93 @@ +package store + +import ( + "os" + "path/filepath" + "sync" + "testing" +) + +func TestAccountCursorKeySharedAcrossStoreOpens(t *testing.T) { + path := filepath.Join(t.TempDir(), "accounts.db") + first, err := OpenSQLite(path) + if err != nil { + t.Fatal(err) + } + defer first.Close() + second, err := OpenExistingSQLite(path) + if err != nil { + t.Fatal(err) + } + defer second.Close() + key1, err := first.GetAccountCursorKey() + if err != nil { + t.Fatal(err) + } + key2, err := second.GetAccountCursorKey() + if err != nil { + t.Fatal(err) + } + if key1.Version != 1 || key1 != key2 || key1.Secret == ([32]byte{}) { + t.Fatal("distinct instances did not share a nonzero versioned key") + } +} + +func TestAccountCursorKeyMigrationAddsToExistingU3Schema(t *testing.T) { + path := filepath.Join(t.TempDir(), "accounts.db") + st, err := OpenSQLite(path) + if err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("existing", "Existing", "", false); err != nil { + t.Fatal(err) + } + if err := st.MigrateDown(33); err != nil { + t.Fatal(err) + } + st.Close() + st, err = OpenSQLite(path) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if _, err := st.GetAccountByOwner("existing"); err != nil { + t.Fatalf("v34 migration lost existing account: %v", err) + } + if key, err := st.GetAccountCursorKey(); err != nil || key.Version != 1 { + t.Fatalf("v34 migration failed to initialize key: %+v %v", key.Version, err) + } +} + +func TestPGAccountCursorKeyConcurrentInstances(t *testing.T) { + baseDSN := os.Getenv("AGENT_PG_TEST_DSN") + if baseDSN == "" { + t.Skip("isolated PostgreSQL test DSN required") + } + first := openTestStore(t) + if err := first.EnsureSystemIdentity("cursor-key-test", "pg"); err != nil { + t.Fatal(err) + } + var schema string + if err := first.DB().QueryRow(`SELECT current_schema()`).Scan(&schema); err != nil { + t.Fatal(err) + } + second, err := OpenExistingPostgres(withTestSearchPath(baseDSN, schema), filepath.Join(t.TempDir(), "artifacts")) + if err != nil { + t.Fatal(err) + } + defer second.Close() + var keys [2]AccountCursorKey + var failures [2]error + var wg sync.WaitGroup + for i, st := range []*Store{first, second} { + wg.Add(1) + go func() { + defer wg.Done() + keys[i], failures[i] = st.GetAccountCursorKey() + }() + } + wg.Wait() + if failures[0] != nil || failures[1] != nil || keys[0] != keys[1] || keys[0].Version != 1 || keys[0].Secret == ([32]byte{}) { + t.Fatalf("concurrent instances did not converge: %v %v", failures[0], failures[1]) + } +} diff --git a/internal/store/account_directory.go b/internal/store/account_directory.go new file mode 100644 index 0000000..157a0e3 --- /dev/null +++ b/internal/store/account_directory.go @@ -0,0 +1,91 @@ +package store + +import ( + "context" + "database/sql" + "errors" +) + +// DirectoryAccountRow is an internal candidate projection. It intentionally +// excludes contact details, notes, credentials and administrator privileges. +type DirectoryAccountRow struct { + UserID string + Owner string + Name string + StaffNo string + Enabled bool +} + +// ListDirectoryAccountRows only supplies data to the authorized directory +// service; this method itself is not an authorization boundary. +func (s *Store) ListDirectoryAccountRows() ([]DirectoryAccountRow, error) { + rows, err := s.db.Query(`SELECT id,owner,COALESCE(name,''),staff_no,enabled FROM users ORDER BY owner,id`) + if err != nil { + return nil, err + } + defer rows.Close() + results := []DirectoryAccountRow{} + for rows.Next() { + var row DirectoryAccountRow + if err := rows.Scan(&row.UserID, &row.Owner, &row.Name, &row.StaffNo, &row.Enabled); err != nil { + return nil, err + } + results = append(results, row) + } + return results, rows.Err() +} + +// ListAdminAccountsForActor takes a consistent snapshot for the protected +// system.users.view adapter. The actor's current admin/effective state is +// checked under lock before any profile fields are read. +func (s *Store) ListAdminAccountsForActor(actorOwner string) ([]Account, AccountDirectoryRevision, error) { + var tx *sql.Tx + var err error + if s.Dialect() == "postgres" { + tx, err = s.db.BeginTx(context.Background(), &sql.TxOptions{Isolation: sql.LevelRepeatableRead}) + } else { + tx, err = s.db.Begin() + } + if err != nil { + return nil, AccountDirectoryRevision{}, err + } + defer tx.Rollback() + locked, err := s.lockAccountUsers(tx, actorOwner) + if errors.Is(err, ErrNotFound) { + return nil, AccountDirectoryRevision{}, ErrAccountForbidden + } + if err != nil { + return nil, AccountDirectoryRevision{}, err + } + actor := locked[actorOwner] + if err := s.requireSystemActionTx(tx, actor, "system.users.view"); err != nil { + return nil, AccountDirectoryRevision{}, err + } + rows, err := tx.Query(s.q(`SELECT ` + accountColumns + ` FROM users ORDER BY owner,id`)) + if err != nil { + return nil, AccountDirectoryRevision{}, err + } + accounts := []Account{} + for rows.Next() { + account, err := scanAccount(rows) + if err != nil { + rows.Close() + return nil, AccountDirectoryRevision{}, err + } + accounts = append(accounts, account) + } + if err := rows.Err(); err != nil { + rows.Close() + return nil, AccountDirectoryRevision{}, err + } + rows.Close() + var revision AccountDirectoryRevision + if err := tx.QueryRow(`SELECT public_revision,staff_no_revision,private_revision FROM account_directory_revision WHERE id=1`). + Scan(&revision.Public, &revision.StaffNo, &revision.Private); err != nil { + return nil, AccountDirectoryRevision{}, err + } + if err := tx.Commit(); err != nil { + return nil, AccountDirectoryRevision{}, err + } + return accounts, revision, nil +} diff --git a/internal/store/account_directory_test.go b/internal/store/account_directory_test.go new file mode 100644 index 0000000..593cdfb --- /dev/null +++ b/internal/store/account_directory_test.go @@ -0,0 +1,37 @@ +package store + +import ( + "errors" + "testing" +) + +func TestAdminAccountSearchSnapshotRequiresCurrentSystemAuthority(t *testing.T) { + st := openTestStore(t) + t.Logf("admin directory dialect=%s", st.Dialect()) + if err := st.CreateAccount("admin", "", "hash", true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("ordinary", "", "hash", false); err != nil { + t.Fatal(err) + } + pending, err := st.CreatePendingAccount("admin", "alice", AccountProfile{Email: "alice@example.test", Notes: "private"}) + if err != nil { + t.Fatal(err) + } + accounts, revision, err := st.ListAdminAccountsForActor("admin") + if err != nil || len(accounts) != 3 || revision.Private == "" { + t.Fatalf("authorized snapshot: accounts=%d rev=%+v err=%v", len(accounts), revision, err) + } + if accounts[1].ID != pending.ID || accounts[1].Profile.Email != "alice@example.test" { + t.Fatalf("profile snapshot or stable sort: %+v", accounts) + } + if _, _, err := st.ListAdminAccountsForActor("ordinary"); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("ordinary account read protected profiles: %v", err) + } + if _, err := st.DB().Exec(st.q(`UPDATE users SET enabled=? WHERE owner=?`), false, "admin"); err != nil { + t.Fatal(err) + } + if _, _, err := st.ListAdminAccountsForActor("admin"); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled administrator read protected profiles: %v", err) + } +} diff --git a/internal/store/account_event_fence_test.go b/internal/store/account_event_fence_test.go new file mode 100644 index 0000000..731b983 --- /dev/null +++ b/internal/store/account_event_fence_test.go @@ -0,0 +1,47 @@ +package store + +import ( + "errors" + "testing" + + core "agent-system-core" +) + +func TestAccountCheckedEventAppendStopsAfterDisable(t *testing.T) { + st := openTestStore(t) + if err := st.CreateAccount("admin", "Admin", "hash", true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("alice", "Alice", "hash", false); err != nil { + t.Fatal(err) + } + project, err := st.CreateProject("alice", "fixture") + if err != nil { + t.Fatal(err) + } + conversation, err := st.CreateConversation("alice", string(project.ID), "fixture", "") + if err != nil { + t.Fatal(err) + } + one := []core.StreamEvent{{ConversationID: string(conversation.ID), Type: core.EvCardCreated, V: core.ProtocolVersion}} + if _, err := st.AppendEventsAccountChecked("admin", string(conversation.ID), one, false); !errors.Is(err, ErrNotFound) { + t.Fatalf("administrator bypassed conversation owner: %v", err) + } + if _, err := st.AppendEventsAccountChecked("alice", string(conversation.ID), one, false); err != nil { + t.Fatal(err) + } + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + if _, err := st.AppendEventsAccountChecked("alice", string(conversation.ID), one, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled account appended event: %v", err) + } + var count int + if err := st.DB().QueryRow(st.q(`SELECT COUNT(*) FROM events WHERE conversation_id=?`), conversation.ID).Scan(&count); err != nil || count != 1 { + t.Fatalf("event count=%d err=%v", count, err) + } +} diff --git a/internal/store/account_lifecycle_test.go b/internal/store/account_lifecycle_test.go index 10a1722..156cd41 100644 --- a/internal/store/account_lifecycle_test.go +++ b/internal/store/account_lifecycle_test.go @@ -64,6 +64,9 @@ func TestBootstrapFirstAdminSingleWinner(t *testing.T) { t.Fatal(err) } t.Cleanup(func() { st.Close() }) + if err := st.EnsureSystemIdentity("bootstrap-test", "sqlite"); err != nil { + t.Fatal(err) + } var wins, conflicts atomic.Int32 var wg sync.WaitGroup for i := 0; i < 8; i++ { @@ -78,6 +81,8 @@ func TestBootstrapFirstAdminSingleWinner(t *testing.T) { wins.Add(1) } else if err == ErrConflict { conflicts.Add(1) + } else { + t.Errorf("bootstrap contender: %v", err) } }(owner) } diff --git a/internal/store/account_lock_order_pg_test.go b/internal/store/account_lock_order_pg_test.go new file mode 100644 index 0000000..439e2ae --- /dev/null +++ b/internal/store/account_lock_order_pg_test.go @@ -0,0 +1,60 @@ +package store + +import ( + "errors" + "os" + "path/filepath" + "testing" + "time" +) + +func TestPGSameOwnerBootstrapAndCreateSerialize(t *testing.T) { + baseDSN := os.Getenv("AGENT_PG_TEST_DSN") + if baseDSN == "" { + t.Skip("isolated PostgreSQL test DSN required") + } + first := openTestStore(t) + if err := first.EnsureSystemIdentity("same-owner-admission", "pg"); err != nil { + t.Fatal(err) + } + var schema string + if err := first.DB().QueryRow(`SELECT current_schema()`).Scan(&schema); err != nil { + t.Fatal(err) + } + second, err := OpenExistingPostgres(withTestSearchPath(baseDSN, schema), filepath.Join(t.TempDir(), "artifacts")) + if err != nil { + t.Fatal(err) + } + defer second.Close() + start := make(chan struct{}) + results := make(chan error, 2) + go func() { + <-start + _, err := first.CreateActivatedAccount(AccountWrite{Owner: "same", Name: "Same", Purpose: LoginKeyPurposeActivate, Actor: "fixture", CreatedBy: "fixture"}) + results <- err + }() + go func() { + <-start + _, err := second.BootstrapFirstAdminWrite(AccountWrite{Owner: "same", Name: "Same", Actor: "fixture", CreatedBy: "fixture"}) + results <- err + }() + close(start) + wins, conflicts := 0, 0 + for range 2 { + select { + case err := <-results: + if err == nil { + wins++ + } else if errors.Is(err, ErrConflict) { + conflicts++ + } else { + t.Fatalf("same-owner admission returned %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("account/bootstrap lock ordering did not make progress") + } + } + if wins != 1 || conflicts != 1 { + t.Fatalf("wins=%d conflicts=%d, want 1/1", wins, conflicts) + } +} diff --git a/internal/store/account_model.go b/internal/store/account_model.go new file mode 100644 index 0000000..c72338b --- /dev/null +++ b/internal/store/account_model.go @@ -0,0 +1,502 @@ +package store + +import ( + "crypto/rand" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "net/mail" + "sort" + "strings" + "unicode" + "unicode/utf8" +) + +var ErrAccountForbidden = errors.New("store: account action forbidden") +var ErrInvalidAccountProfile = errors.New("store: invalid account profile") + +// CredentialOrigin describes persisted credential history, not the authority +// of the caller. Unknown values never supply owner proof. +type CredentialOrigin string + +const ( + CredentialOriginLegacyUnknown CredentialOrigin = "legacy_unknown" + CredentialOriginAdminPassword CredentialOrigin = "admin_password" + CredentialOriginOwnerInitialization CredentialOrigin = "owner_initialization" + CredentialOriginOwnerRecovery CredentialOrigin = "owner_recovery" + InitializationLegacy = "legacy" + InitializationPending = "pending" + InitializationInitialized = "initialized" + AuthMethodLegacyUnknown = "legacy_unknown" + AuthMethodPassword = "password" + AuthMethodGrant = "grant" +) + +// TrustedForOwnerProof checks only lineage. The recovery boundary must also +// check the exact live password session, account/version and proposal binding. +func (origin CredentialOrigin) TrustedForOwnerProof() bool { + return origin == CredentialOriginOwnerInitialization || origin == CredentialOriginOwnerRecovery +} + +type AccountProfile struct { + Name string `json:"name"` + Phone string `json:"phone"` + Email string `json:"email"` + StaffNo string `json:"staffNo"` + Department string `json:"department"` + Notes string `json:"notes"` +} + +// Account is an internal account view without credential material. Handlers and +// directory providers must project only the fields authorized for their caller. +type Account struct { + ID string + Owner string + Profile AccountProfile + ProfileVersion int64 + Enabled bool + StateVersion int64 + IsAdmin bool + IsSuperAdmin bool + HasPassword bool + MustChangePassword bool + AuthVersion int + CredentialOrigin CredentialOrigin + InitializationState string + InitializationGeneration int64 +} + +type AccountDirectoryRevision struct { + Public string + StaffNo string + Private string +} + +type AccountCursorKey struct { + Version int + Secret [32]byte +} + +const accountColumns = `id,owner,COALESCE(name,''),phone,email,staff_no,department,notes, + profile_version,enabled,state_version,is_admin,is_super_admin,password_hash<>'',must_change_password, + auth_version,credential_origin,initialization_state,initialization_generation` + +type accountQuerier interface { + QueryRow(string, ...any) *sql.Row +} + +type accountScanner interface{ Scan(...any) error } + +func scanAccount(row accountScanner) (Account, error) { + var a Account + err := row.Scan(&a.ID, &a.Owner, &a.Profile.Name, &a.Profile.Phone, &a.Profile.Email, + &a.Profile.StaffNo, &a.Profile.Department, &a.Profile.Notes, &a.ProfileVersion, + &a.Enabled, &a.StateVersion, &a.IsAdmin, &a.IsSuperAdmin, &a.HasPassword, &a.MustChangePassword, + &a.AuthVersion, &a.CredentialOrigin, &a.InitializationState, &a.InitializationGeneration) + if errors.Is(err, sql.ErrNoRows) { + return Account{}, ErrNotFound + } + return a, err +} + +// GetAccount uses the stable identity; it performs no caller authorization. +func (s *Store) GetAccount(userID string) (Account, error) { + return scanAccount(s.db.QueryRow(s.q(`SELECT `+accountColumns+` FROM users WHERE id=?`), userID)) +} + +func (s *Store) GetAccountByOwner(owner string) (Account, error) { + return s.accountByOwner(s.db, owner) +} + +func (s *Store) accountByOwner(db accountQuerier, owner string) (Account, error) { + return scanAccount(db.QueryRow(s.q(`SELECT `+accountColumns+` FROM users WHERE owner=?`), owner)) +} + +// lockAccountUsers is the common first lock for account transactions. Always +// acquire all participating users in owner order before proposal, grant and +// session locks. PG locks persist to commit; SQLite already holds its immediate +// transaction write lock. Never lock a second user later in an arbitrary order. +func (s *Store) lockAccountUsers(tx *sql.Tx, owners ...string) (map[string]Account, error) { + ordered := append([]string(nil), owners...) + sort.Strings(ordered) + accounts := make(map[string]Account, len(ordered)) + query := s.q(`SELECT ` + accountColumns + ` FROM users WHERE owner=?`) + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + for _, owner := range ordered { + if _, exists := accounts[owner]; exists { + continue + } + if err := s.dialect.lockAccount(tx, owner); err != nil { + return nil, err + } + a, err := scanAccount(tx.QueryRow(query, owner)) + if err != nil { + return nil, err + } + accounts[owner] = a + } + return accounts, nil +} + +func validateAccountProfilePatch(patch map[string]string) (map[string]string, error) { + limits := map[string]int{"name": 128, "phone": 64, "email": 254, "staffNo": 64, "department": 128, "notes": 2000} + normalized := make(map[string]string, len(patch)) + for field, value := range patch { + limit, ok := limits[field] + if !ok { + return nil, fmt.Errorf("%w: unknown field", ErrInvalidAccountProfile) + } + if !utf8.ValidString(value) { + return nil, fmt.Errorf("%w: %s is not UTF-8", ErrInvalidAccountProfile, field) + } + for _, r := range value { + if unicode.IsControl(r) { + return nil, fmt.Errorf("%w: %s contains control characters", ErrInvalidAccountProfile, field) + } + } + value = strings.TrimSpace(value) + if utf8.RuneCountInString(value) > limit { + return nil, fmt.Errorf("%w: %s exceeds %d characters", ErrInvalidAccountProfile, field, limit) + } + if field == "email" && value != "" { + address, err := mail.ParseAddress(value) + if err != nil || address.Address != value || address.Name != "" { + return nil, fmt.Errorf("%w: invalid email", ErrInvalidAccountProfile) + } + } + normalized[field] = value + } + return normalized, nil +} + +func applyAccountProfilePatch(profile AccountProfile, patch map[string]string) AccountProfile { + for field, value := range patch { + switch field { + case "name": + profile.Name = value + case "phone": + profile.Phone = value + case "email": + profile.Email = value + case "staffNo": + profile.StaffNo = value + case "department": + profile.Department = value + case "notes": + profile.Notes = value + } + } + return profile +} + +// PatchAccountProfile takes an actor from a trusted authentication boundary; +// callers must enforce its full password-session requirement. Current account +// status/admin authorization is rechecked under the same locks as the write. +func (s *Store) PatchAccountProfile(actorOwner, userID string, expectedProfileVersion int64, patch map[string]string) (Account, error) { + if expectedProfileVersion < 1 { + return Account{}, fmt.Errorf("%w: expectedProfileVersion is required", ErrInvalidAccountProfile) + } + normalized, err := validateAccountProfilePatch(patch) + if err != nil { + return Account{}, err + } + tx, err := s.db.Begin() + if err != nil { + return Account{}, err + } + defer tx.Rollback() + actor, err := s.accountByOwner(tx, actorOwner) + if errors.Is(err, ErrNotFound) || (err == nil && !actor.Enabled) { + return Account{}, ErrAccountForbidden + } + if err != nil { + return Account{}, err + } + target, err := scanAccount(tx.QueryRow(s.q(`SELECT `+accountColumns+` FROM users WHERE id=?`), userID)) + if errors.Is(err, ErrNotFound) && actor.ID != userID { + return Account{}, ErrAccountForbidden + } + if err != nil { + return Account{}, err + } + locked, err := s.lockAccountUsers(tx, actorOwner, target.Owner) + if err != nil { + return Account{}, err + } + actor, target = locked[actorOwner], locked[target.Owner] + if !actor.Enabled { + return Account{}, ErrAccountForbidden + } + if err := requireSuperAdminTargetAccess(actor, target); err != nil { + return Account{}, err + } + if actor.ID != target.ID { + if err := s.requireSystemActionTx(tx, actor, "system.users.edit"); err != nil { + return Account{}, err + } + } + if target.ID != userID || target.ProfileVersion != expectedProfileVersion { + return Account{}, ErrConflict + } + profile := applyAccountProfilePatch(target.Profile, normalized) + result, err := tx.Exec(s.q(`UPDATE users SET name=?,phone=?,email=?,staff_no=?,department=?,notes=?,profile_version=profile_version+1 WHERE id=? AND profile_version=?`), + profile.Name, profile.Phone, profile.Email, profile.StaffNo, profile.Department, profile.Notes, userID, expectedProfileVersion) + if err != nil { + return Account{}, err + } + if n, err := result.RowsAffected(); err != nil { + return Account{}, err + } else if n != 1 { + return Account{}, ErrConflict + } + if profile != target.Profile { + if err := s.bumpAccountDirectoryRevision(tx, profile.Name != target.Profile.Name, profile.StaffNo != target.Profile.StaffNo); err != nil { + return Account{}, err + } + } + // Audit field names and versions only, never profile values. + fields := make([]string, 0, len(normalized)) + for field := range normalized { + fields = append(fields, field) + } + sort.Strings(fields) + if err := s.insertAccountEvent(tx, actorOwner, "account-profile", target.Owner, "profile_updated", fmt.Sprintf("version=%d fields=%s", expectedProfileVersion+1, strings.Join(fields, ","))); err != nil { + return Account{}, err + } + if err := s.auditAccountMutation(tx, target.Owner, actorOwner, "account.profile"); err != nil { + return Account{}, err + } + if err := tx.Commit(); err != nil { + return Account{}, err + } + target.Profile, target.ProfileVersion = profile, expectedProfileVersion+1 + return target, nil +} + +// CreatePendingAccount is the ordinary account-management primitive. It cannot +// set passwords, administrator privileges or trusted credential provenance. +func (s *Store) CreatePendingAccount(actorOwner, owner string, profile AccountProfile) (Account, error) { + tx, err := s.db.Begin() + if err != nil { + return Account{}, err + } + defer tx.Rollback() + locked, err := s.lockAccountUsers(tx, actorOwner) + if errors.Is(err, ErrNotFound) { + return Account{}, ErrAccountForbidden + } + if err != nil { + return Account{}, err + } + actor := locked[actorOwner] + if err := s.requireSystemActionTx(tx, actor, "system.users.create"); err != nil { + return Account{}, err + } + a, err := s.insertPendingAccountTx(tx, owner, profile) + if err != nil { + return Account{}, err + } + if err := s.insertAccountEvent(tx, actorOwner, "account-management", owner, "account_created_pending", a.ID); err != nil { + return Account{}, err + } + if err := s.auditAccountMutation(tx, owner, actorOwner, "account.create_pending"); err != nil { + return Account{}, err + } + if err := tx.Commit(); err != nil { + return Account{}, err + } + return a, nil +} + +// CreateAdminPasswordAccount creates an ordinary user with an administrator- +// supplied password hash. The account row, hash, and audit entries commit +// together so a failed write cannot leave a credential without its account. +func (s *Store) CreateAdminPasswordAccount(actorOwner, owner string, profile AccountProfile, passwordHash string) (Account, error) { + return s.CreatePasswordAccountWithAuthority(actorOwner, owner, profile, passwordHash, false) +} + +// CreatePasswordAccountWithAuthority is CreateAdminPasswordAccount with an +// optional administrator appointment. Appointment is reserved to the enabled +// super administrator and is rechecked under the actor lock (R3, R4); every +// other actor receives ErrAccountForbidden without any partial write. +func (s *Store) CreatePasswordAccountWithAuthority(actorOwner, owner string, profile AccountProfile, passwordHash string, isAdmin bool) (Account, error) { + if passwordHash == "" { + return Account{}, ErrConflict + } + tx, err := s.db.Begin() + if err != nil { + return Account{}, err + } + defer tx.Rollback() + locked, err := s.lockAccountUsers(tx, actorOwner) + if errors.Is(err, ErrNotFound) { + return Account{}, ErrAccountForbidden + } + if err != nil { + return Account{}, err + } + actor := locked[actorOwner] + if err := s.requireSystemActionTx(tx, actor, "system.users.create"); err != nil { + return Account{}, err + } + if isAdmin && !actor.IsSuperAdmin { + return Account{}, ErrAccountForbidden + } + a, err := s.insertPendingAccountTx(tx, owner, profile) + if err != nil { + return Account{}, err + } + if _, err := tx.Exec(s.q(`UPDATE users SET password_hash=?,password_set_at=?,must_change_password=?,credential_origin=?,initialization_state=?,is_admin=? WHERE id=?`), + passwordHash, nowISO(), true, CredentialOriginAdminPassword, InitializationInitialized, isAdmin, a.ID); err != nil { + return Account{}, err + } + if isAdmin { + if err := s.seedSystemAdminGrantsTx(tx, owner); err != nil { + return Account{}, err + } + if err := s.bumpAuthorityEpochTx(tx); err != nil { + return Account{}, err + } + } + a, err = s.accountByOwner(tx, owner) + if err != nil { + return Account{}, err + } + if err := s.insertAccountEvent(tx, actorOwner, "account-management", owner, "account_created_password", a.ID); err != nil { + return Account{}, err + } + if err := s.auditAccountMutation(tx, owner, actorOwner, "account.create_password"); err != nil { + return Account{}, err + } + if isAdmin { + if err := s.insertAccountEvent(tx, actorOwner, "account-management", owner, "admin_appointed", a.ID); err != nil { + return Account{}, err + } + if err := s.auditAccountMutation(tx, owner, actorOwner, "account.admin_appoint"); err != nil { + return Account{}, err + } + } + if err := tx.Commit(); err != nil { + return Account{}, err + } + return a, nil +} + +// insertPendingAccountTx lets the trusted local initializer create and grant in +// one transaction without impersonating an administrator. The caller owns its +// authorization, audit and commit; this helper always inserts an ordinary user. +func (s *Store) insertPendingAccountTx(tx *sql.Tx, owner string, profile AccountProfile) (Account, error) { + if owner == "" || owner != strings.TrimSpace(owner) || !utf8.ValidString(owner) { + return Account{}, fmt.Errorf("%w: invalid owner", ErrInvalidAccountProfile) + } + for _, r := range owner { + if unicode.IsControl(r) { + return Account{}, fmt.Errorf("%w: invalid owner", ErrInvalidAccountProfile) + } + } + patch, err := validateAccountProfilePatch(map[string]string{"name": profile.Name, "phone": profile.Phone, "email": profile.Email, "staffNo": profile.StaffNo, "department": profile.Department, "notes": profile.Notes}) + if err != nil { + return Account{}, err + } + profile = applyAccountProfilePatch(AccountProfile{}, patch) + _, err = tx.Exec(s.q(`INSERT INTO users(id,owner,name,phone,email,staff_no,department,notes,created_at,initialization_state) VALUES(?,?,?,?,?,?,?,?,?,?)`), + newID("usr"), owner, profile.Name, profile.Phone, profile.Email, profile.StaffNo, profile.Department, profile.Notes, nowISO(), InitializationPending) + if isUniqueViolation(err) { + return Account{}, ErrConflict + } + if err != nil { + return Account{}, err + } + if err := s.bumpAccountDirectoryRevision(tx, true, true); err != nil { + return Account{}, err + } + return s.accountByOwner(tx, owner) +} + +func (s *Store) GetAccountDirectoryRevision() (AccountDirectoryRevision, error) { + var revision AccountDirectoryRevision + err := s.db.QueryRow(`SELECT public_revision,staff_no_revision,private_revision FROM account_directory_revision WHERE id=1`).Scan(&revision.Public, &revision.StaffNo, &revision.Private) + return revision, err +} + +// GetAccountCursorKey initializes one random signing key in the shared account +// database. The compare-and-set update lets concurrent Backend instances +// converge on the same key without exposing it through directory responses. +func (s *Store) GetAccountCursorKey() (AccountCursorKey, error) { + var key AccountCursorKey + var generated [32]byte + if _, err := rand.Read(generated[:]); err != nil { + return key, err + } + tx, err := s.db.Begin() + if err != nil { + return key, err + } + defer tx.Rollback() + if _, err := tx.Exec(s.q(`UPDATE account_directory_revision SET cursor_secret=? WHERE id=1 AND cursor_secret=''`), hex.EncodeToString(generated[:])); err != nil { + return key, err + } + var encoded string + if err := tx.QueryRow(`SELECT cursor_key_version,cursor_secret FROM account_directory_revision WHERE id=1`).Scan(&key.Version, &encoded); err != nil { + return key, err + } + decoded, err := hex.DecodeString(encoded) + if err != nil || len(decoded) != len(key.Secret) || key.Version < 1 { + return AccountCursorKey{}, ErrConflict + } + copy(key.Secret[:], decoded) + if err := tx.Commit(); err != nil { + return AccountCursorKey{}, err + } + return key, nil +} + +func newAccountDirectoryRevision() (string, error) { + var bytes [16]byte + if _, err := rand.Read(bytes[:]); err != nil { + return "", err + } + return hex.EncodeToString(bytes[:]), nil +} + +// Call only after all user locks. Public covers name/identity/valid; StaffNo is +// composed into a directory version only when that field is authorized. Private +// changes on every profile edit, without exposing hidden-field edits to callers. +func (s *Store) bumpAccountDirectoryRevision(tx *sql.Tx, publicChanged, staffNoChanged bool) error { + private, err := newAccountDirectoryRevision() + if err != nil { + return err + } + assignments := []string{`private_revision=?`} + args := []any{private} + if publicChanged { + public, err := newAccountDirectoryRevision() + if err != nil { + return err + } + assignments = append(assignments, `public_revision=?`) + args = append(args, public) + } + if staffNoChanged { + staffNo, err := newAccountDirectoryRevision() + if err != nil { + return err + } + assignments = append(assignments, `staff_no_revision=?`) + args = append(args, staffNo) + } + query := `UPDATE account_directory_revision SET ` + strings.Join(assignments, ",") + ` WHERE id=1` + result, err := tx.Exec(s.q(query), args...) + if err != nil { + return err + } + if n, err := result.RowsAffected(); err != nil { + return err + } else if n != 1 { + return errors.New("store: account directory revision missing") + } + return nil +} diff --git a/internal/store/account_model_test.go b/internal/store/account_model_test.go new file mode 100644 index 0000000..7158365 --- /dev/null +++ b/internal/store/account_model_test.go @@ -0,0 +1,431 @@ +package store + +import ( + "errors" + "os" + "strings" + "sync" + "testing" + "time" +) + +// A legacy account must retain its identity and credentials and never acquire +// pending enrollment or trusted provenance merely by applying the migration. +func TestAccountModelLegacyMigrationDefaults(t *testing.T) { + st := openTestStore(t) + t.Logf("account model dialect=%s", st.Dialect()) + if err := st.MigrateDown(31); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("legacy", "原姓名", "existing-hash", true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("legacy-empty", "", "", false); err != nil { + t.Fatal(err) + } + // This fixture intentionally writes a v31 row before v32 migration. The + // current session API enforces the v32 account state and cannot be used here. + token := "sess_legacy_migration_fixture" + _, err := st.db.Exec(st.q(`INSERT INTO sessions(token_hash,owner,created_at,expires_at,last_seen_at,scope,auth_version) + VALUES(?,?,?,?,?,?,?)`), hashKey(token), "legacy", nowISO(), time.Now().Add(time.Hour).UTC().Format(time.RFC3339Nano), nowISO(), SessionScopeFull, 0) + if err != nil { + t.Fatal(err) + } + before, err := st.GetUser("legacy") + if err != nil { + t.Fatal(err) + } + if err := st.migrate(); err != nil { + t.Fatal(err) + } + var id, owner, name, hash, origin, initialization string + var enabled, admin, mustChange bool + var profileVersion, stateVersion, generation int64 + err = st.db.QueryRow(`SELECT id,owner,name,password_hash,is_admin,must_change_password, + enabled,profile_version,state_version,credential_origin,initialization_state,initialization_generation + FROM users WHERE owner='legacy'`).Scan(&id, &owner, &name, &hash, &admin, &mustChange, + &enabled, &profileVersion, &stateVersion, &origin, &initialization, &generation) + if err != nil { + t.Fatalf("account foundation migration is missing: %v", err) + } + if id != before.ID || owner != "legacy" || name != "原姓名" || hash != "existing-hash" || !admin || !mustChange { + t.Fatal("migration changed existing identity or credentials") + } + if !enabled || profileVersion != 1 || stateVersion != 1 || origin != "legacy_unknown" || initialization != "legacy" || generation != 0 { + t.Fatalf("unsafe migrated defaults: enabled=%v profile=%d state=%d origin=%q initialization=%q generation=%d", enabled, profileVersion, stateVersion, origin, initialization, generation) + } + if SchemaCompatFloor() < 32 { + t.Fatal("old authentication writers must be excluded by the compatibility floor") + } + empty, err := st.GetAccountByOwner("legacy-empty") + if err != nil || empty.HasPassword || empty.InitializationState != InitializationLegacy || empty.CredentialOrigin.TrustedForOwnerProof() { + t.Fatalf("a credential-free legacy account was inferred as pending/trusted: %+v %v", empty, err) + } + var sessionMethod, sessionOrigin, grantID string + if err := st.db.QueryRow(st.q(`SELECT auth_method,credential_origin,grant_id FROM sessions WHERE token_hash=?`), hashKey(token)).Scan(&sessionMethod, &sessionOrigin, &grantID); err != nil { + t.Fatal(err) + } + if sessionMethod != "legacy_unknown" || sessionOrigin != "legacy_unknown" || grantID != "" { + t.Fatal("migration invented proof metadata on a pre-existing session") + } +} + +func TestAccountModelPendingProfileIdentity(t *testing.T) { + st := openTestStore(t) + t.Logf("account model dialect=%s", st.Dialect()) + if err := st.CreateAccount("admin", "", "admin-hash", true); err != nil { + t.Fatal(err) + } + a, err := st.CreatePendingAccount("admin", "alice", AccountProfile{}) + if err != nil { + t.Fatal(err) + } + if a.ID == "" || a.Owner != "alice" || a.Profile != (AccountProfile{}) || !a.Enabled || a.IsAdmin || a.HasPassword || a.MustChangePassword || a.ProfileVersion != 1 || a.StateVersion != 1 || a.AuthVersion != 0 || a.InitializationState != InitializationPending || a.InitializationGeneration != 0 || a.CredentialOrigin != CredentialOriginLegacyUnknown { + t.Fatalf("pending account must be ordinary and have no credentials: %+v", a) + } + if _, err := st.CreatePendingAccount("admin", "alice", AccountProfile{Name: "other"}); !errors.Is(err, ErrConflict) { + t.Fatalf("duplicate owner: %v", err) + } + if err := st.MergeSettingsScope("alice", "user", map[string]any{"identity-sentinel": "preserved"}); err != nil { + t.Fatal(err) + } + patch := map[string]string{"name": " 同名 ", "phone": " +86 123 ", "email": "alice@example.org", "staffNo": " A-1 ", "department": "研发", "notes": "可选备注"} + after, err := st.PatchAccountProfile("alice", a.ID, 1, patch) + if err != nil { + t.Fatal(err) + } + if after.Profile.Name != "同名" || after.Profile.Phone != "+86 123" || after.Profile.StaffNo != "A-1" || after.ProfileVersion != 2 { + t.Fatalf("profile normalization: %+v", after) + } + for k := range patch { + patch[k] = "" + } + after, err = st.PatchAccountProfile("admin", a.ID, 2, patch) + if err != nil { + t.Fatal(err) + } + if after.ID != a.ID || after.Owner != a.Owner || after.Profile != (AccountProfile{}) || after.ProfileVersion != 3 || after.StateVersion != a.StateVersion || after.AuthVersion != a.AuthVersion || after.CredentialOrigin != a.CredentialOrigin || after.InitializationState != a.InitializationState || after.IsAdmin || after.HasPassword { + t.Fatalf("profile changed identity/auth: %+v", after) + } + settings, err := st.GetSettingsScope("alice", "user") + if err != nil || settings["identity-sentinel"] != "preserved" { + t.Fatalf("business data changed: %v %v", settings, err) + } + other, err := st.CreatePendingAccount("admin", "bob", AccountProfile{Name: "同名"}) + if err != nil || other.ID == a.ID { + t.Fatalf("same names must have distinct identities: %+v %v", other, err) + } +} + +func TestAccountModelProfileAuthorization(t *testing.T) { + st := openTestStore(t) + for _, owner := range []string{"alice", "bob", "admin"} { + if err := st.CreateAccount(owner, "", "existing-hash", owner == "admin"); err != nil { + t.Fatal(err) + } + } + a, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + for _, actor := range []string{"", "missing", "bob"} { + if _, err := st.PatchAccountProfile(actor, a.ID, 1, map[string]string{"name": "forbidden"}); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("actor %q: %v", actor, err) + } + } + if _, err := st.PatchAccountProfile("bob", "missing-user-id", 1, map[string]string{"name": "forbidden"}); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("non-admin must not learn target existence: %v", err) + } + if _, err := st.PatchAccountProfile("alice", a.ID, 1, map[string]string{"name": "self"}); err != nil { + t.Fatal(err) + } + if _, err := st.db.Exec(st.q(`UPDATE users SET enabled=? WHERE owner=?`), false, "alice"); err != nil { + t.Fatal(err) + } + if _, err := st.PatchAccountProfile("alice", a.ID, 2, map[string]string{"name": "disabled"}); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled self: %v", err) + } + if _, err := st.PatchAccountProfile("admin", a.ID, 2, map[string]string{"name": "admin"}); err != nil { + t.Fatal(err) + } + if _, err := st.db.Exec(st.q(`UPDATE users SET is_admin=? WHERE owner=?`), false, "admin"); err != nil { + t.Fatal(err) + } + if _, err := st.db.Exec(st.q(`DELETE FROM system_grants WHERE user_id=(SELECT id FROM users WHERE owner=?)`), "admin"); err != nil { + t.Fatal(err) + } + if _, err := st.PatchAccountProfile("admin", a.ID, 3, map[string]string{"name": "stale admin"}); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("demoted admin: %v", err) + } + if _, err := st.CreatePendingAccount("admin", "forbidden", AccountProfile{}); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("demoted creator: %v", err) + } + if _, err := st.db.Exec(st.q(`UPDATE users SET is_admin=?,enabled=? WHERE owner=?`), true, false, "admin"); err != nil { + t.Fatal(err) + } + if _, err := st.PatchAccountProfile("admin", a.ID, 3, map[string]string{"name": "disabled admin"}); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled admin: %v", err) + } + if _, err := st.CreatePendingAccount("admin", "forbidden", AccountProfile{}); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled creator: %v", err) + } + got, err := st.GetAccount(a.ID) + if err != nil { + t.Fatal(err) + } + if got.Profile.Name != "admin" || got.ProfileVersion != 3 || !got.HasPassword || !got.MustChangePassword || got.AuthVersion != 0 || got.CredentialOrigin != CredentialOriginLegacyUnknown { + t.Fatalf("unexpected rejection side effects: %+v", got) + } + var hash string + if err := st.db.QueryRow(`SELECT password_hash FROM users WHERE owner='alice'`).Scan(&hash); err != nil || hash != "existing-hash" { + t.Fatalf("profile modified hash: %v", err) + } +} + +func TestAccountModelProfileValidation(t *testing.T) { + st := openTestStore(t) + if err := st.CreateAccount("alice", "", "", true); err != nil { + t.Fatal(err) + } + a, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + limits := map[string]int{"name": 128, "phone": 64, "email": 254, "staffNo": 64, "department": 128, "notes": 2000} + for field, limit := range limits { + if _, err := st.PatchAccountProfile("alice", a.ID, 1, map[string]string{field: strings.Repeat("界", limit+1)}); !errors.Is(err, ErrInvalidAccountProfile) { + t.Errorf("overlong %s: %v", field, err) + } + } + for _, value := range []string{"x\x00y", "x\ny", "x\u0085y", string([]byte{0xff})} { + if _, err := st.PatchAccountProfile("alice", a.ID, 1, map[string]string{"notes": value}); !errors.Is(err, ErrInvalidAccountProfile) { + t.Errorf("invalid text %q: %v", value, err) + } + } + for _, value := range []string{"missing-at", "a@", "@example.org", "Name ", "a b@example.org", "a@example.org,b@example.org"} { + if _, err := st.PatchAccountProfile("alice", a.ID, 1, map[string]string{"email": value}); !errors.Is(err, ErrInvalidAccountProfile) { + t.Errorf("invalid email %q: %v", value, err) + } + } + for _, field := range []string{"owner", "id", "isAdmin", "enabled", "roles", "password", "proof", "profileVersion", "staff_no", "unknown"} { + if _, err := st.PatchAccountProfile("alice", a.ID, 1, map[string]string{"name": "must not write", field: "x"}); !errors.Is(err, ErrInvalidAccountProfile) { + t.Errorf("unknown field %q: %v", field, err) + } + } + if _, err := st.PatchAccountProfile("alice", a.ID, 0, map[string]string{"name": "x"}); !errors.Is(err, ErrInvalidAccountProfile) { + t.Fatalf("missing version: %v", err) + } + if _, err := st.CreatePendingAccount("alice", "bad", AccountProfile{Email: "bad"}); !errors.Is(err, ErrInvalidAccountProfile) { + t.Fatalf("invalid create: %v", err) + } + if _, err := st.GetAccountByOwner("bad"); !errors.Is(err, ErrNotFound) { + t.Fatalf("invalid create left account: %v", err) + } + valid := map[string]string{"name": strings.Repeat("界", 128), "phone": strings.Repeat("1", 64), "staffNo": strings.Repeat("号", 64), "department": strings.Repeat("部", 128), "notes": strings.Repeat("注", 2000), "email": strings.Repeat("a", 64) + "@" + strings.Repeat("b", 63) + "." + strings.Repeat("c", 63) + "." + strings.Repeat("d", 61)} + got, err := st.PatchAccountProfile("alice", a.ID, 1, valid) + if err != nil || got.ProfileVersion != 2 { + t.Fatalf("Unicode and exact length boundaries: %+v %v", got, err) + } +} + +func TestAccountModelDirectoryRevisionsAndRollback(t *testing.T) { + st := openTestStore(t) + if err := st.CreateAccount("admin", "", "", true); err != nil { + t.Fatal(err) + } + initial, err := st.GetAccountDirectoryRevision() + if err != nil { + t.Fatal(err) + } + a, err := st.CreatePendingAccount("admin", "alice", AccountProfile{}) + if err != nil { + t.Fatal(err) + } + created, err := st.GetAccountDirectoryRevision() + if err != nil { + t.Fatal(err) + } + if created.Public == initial.Public || created.StaffNo == initial.StaffNo || created.Private == initial.Private || created.Public == "" || created.StaffNo == "" || created.Private == "" { + t.Fatal("creation did not invalidate directory") + } + if _, err := st.PatchAccountProfile("alice", a.ID, 1, map[string]string{"phone": "123", "email": "a@example.org", "department": "private", "notes": "secret"}); err != nil { + t.Fatal(err) + } + private, err := st.GetAccountDirectoryRevision() + if err != nil { + t.Fatal(err) + } + if private.Public != created.Public || private.StaffNo != created.StaffNo || private.Private == created.Private { + t.Fatal("private edits must change only the private revision") + } + if _, err := st.PatchAccountProfile("alice", a.ID, 2, map[string]string{"name": "visible"}); err != nil { + t.Fatal(err) + } + public, err := st.GetAccountDirectoryRevision() + if err != nil { + t.Fatal(err) + } + if public.Public == private.Public || public.StaffNo != private.StaffNo || public.Private == private.Private { + t.Fatal("name edit must change public/private revisions and preserve the staff number revision") + } + if _, err := st.PatchAccountProfile("alice", a.ID, 3, map[string]string{"staffNo": "visible staff"}); err != nil { + t.Fatal(err) + } + staff, err := st.GetAccountDirectoryRevision() + if err != nil { + t.Fatal(err) + } + if staff.Public != public.Public || staff.StaffNo == public.StaffNo || staff.Private == public.Private { + t.Fatal("staff number edit must preserve the default candidate revision and change staff number/private revisions") + } + if _, err := st.PatchAccountProfile("alice", a.ID, 4, map[string]string{"name": "visible"}); err != nil { + t.Fatal(err) + } + noChange, err := st.GetAccountDirectoryRevision() + if err != nil || noChange != staff { + t.Fatalf("no-op changed visible directory: %+v %v", noChange, err) + } + before, err := st.GetAccount(a.ID) + if err != nil { + t.Fatal(err) + } + if _, err := st.db.Exec(`DROP TABLE account_events`); err != nil { + t.Fatal(err) + } + if _, err := st.PatchAccountProfile("alice", a.ID, before.ProfileVersion, map[string]string{"name": "must roll back", "staffNo": "must roll back"}); err == nil { + t.Fatal("audit failure allowed mutation") + } + after, err := st.GetAccount(a.ID) + if err != nil || after != before { + t.Fatalf("audit failure partially changed account: %+v %v", after, err) + } + revision, err := st.GetAccountDirectoryRevision() + if err != nil || revision != staff { + t.Fatalf("audit failure changed revision: %+v %v", revision, err) + } + if _, err := st.CreatePendingAccount("admin", "rollback", AccountProfile{}); err == nil { + t.Fatal("create succeeded without audit") + } + if _, err := st.GetAccountByOwner("rollback"); !errors.Is(err, ErrNotFound) { + t.Fatalf("failed creation leaked account: %v", err) + } +} + +// Open a genuinely separate connection pool to the exact isolated test database. +func reopenAccountModelStore(t *testing.T, st *Store) *Store { + t.Helper() + var next *Store + var err error + if st.Dialect() == "postgres" { + var schema string + if err := st.db.QueryRow(`SELECT current_schema()`).Scan(&schema); err != nil { + t.Fatal(err) + } + next, err = OpenPostgres(withTestSearchPath(os.Getenv("AGENT_PG_TEST_DSN"), schema), st.dataDir) + } else { + var seq int + var name, path string + if err := st.db.QueryRow(`PRAGMA database_list`).Scan(&seq, &name, &path); err != nil { + t.Fatal(err) + } + next, err = OpenSQLite(path) + } + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { next.Close() }) + return next +} + +func TestAccountModelConcurrentCASAndReopen(t *testing.T) { + st := openTestStore(t) + other := reopenAccountModelStore(t, st) + t.Logf("account model dialect=%s two independent pools", st.Dialect()) + if err := st.CreateAccount("alice", "", "", false); err != nil { + t.Fatal(err) + } + a, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + start := make(chan struct{}) + errs := make(chan error, 2) + var wg sync.WaitGroup + for i, conn := range []*Store{st, other} { + wg.Add(1) + go func(i int, conn *Store) { + defer wg.Done() + <-start + _, err := conn.PatchAccountProfile("alice", a.ID, 1, map[string]string{"name": []string{"first", "second"}[i]}) + errs <- err + }(i, conn) + } + close(start) + wg.Wait() + close(errs) + wins, conflicts := 0, 0 + for err := range errs { + if err == nil { + wins++ + } else if errors.Is(err, ErrConflict) { + conflicts++ + } else { + t.Fatal(err) + } + } + if wins != 1 || conflicts != 1 { + t.Fatalf("CAS wins=%d conflicts=%d", wins, conflicts) + } + if _, err := st.db.Exec(st.q(`UPDATE users SET enabled=? WHERE owner=?`), false, "alice"); err != nil { + t.Fatal(err) + } + revision, err := st.GetAccountDirectoryRevision() + if err != nil { + t.Fatal(err) + } + reopened := reopenAccountModelStore(t, st) + got, err := reopened.GetAccount(a.ID) + if err != nil || got.ProfileVersion != 2 || got.ID != a.ID || got.Enabled || got.CredentialOrigin != CredentialOriginLegacyUnknown || got.InitializationState != InitializationLegacy { + t.Fatalf("persistent model changed after reopen: %+v %v", got, err) + } + next, err := reopened.GetAccountDirectoryRevision() + if err != nil || next != revision { + t.Fatalf("revision changed on reopen: %+v %v", next, err) + } + events, err := reopened.ListAccountEvents("alice", 10) + if err != nil || len(events) != 1 { + t.Fatalf("CAS must audit only winner: %v %v", events, err) + } +} + +func TestAccountModelUnknownProvenance(t *testing.T) { + for _, origin := range []CredentialOrigin{"", "unknown", "admin_password", "legacy_unknown"} { + if origin.TrustedForOwnerProof() { + t.Errorf("untrusted origin %q accepted", origin) + } + } + for _, origin := range []CredentialOrigin{CredentialOriginOwnerInitialization, CredentialOriginOwnerRecovery} { + if !origin.TrustedForOwnerProof() { + t.Errorf("trusted origin %q rejected", origin) + } + } + st := openTestStore(t) + if err := st.CreateAccount("legacy", "", "hash", false); err != nil { + t.Fatal(err) + } + token, err := st.CreateSession("legacy") + if err != nil { + t.Fatal(err) + } + var method, origin, grant string + if err := st.db.QueryRow(st.q(`SELECT auth_method,credential_origin,grant_id FROM sessions WHERE token_hash=?`), hashKey(token)).Scan(&method, &origin, &grant); err != nil { + t.Fatal(err) + } + if method != "legacy_unknown" || origin != "legacy_unknown" || grant != "" { + t.Fatal("legacy session received proof provenance") + } + state, err := st.GetUserAuthState("legacy") + if err != nil || state.UserID == "" || !state.Enabled || state.StateVersion != 1 || state.CredentialOrigin.TrustedForOwnerProof() || state.InitializationState != InitializationLegacy { + t.Fatalf("unsafe auth metadata: %+v %v", state, err) + } +} diff --git a/internal/store/account_process_crash_test.go b/internal/store/account_process_crash_test.go new file mode 100644 index 0000000..61fb4fe --- /dev/null +++ b/internal/store/account_process_crash_test.go @@ -0,0 +1,104 @@ +package store + +import ( + "bufio" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "testing" + "time" +) + +func TestAccountPasswordSurvivesProcessTermination(t *testing.T) { + if mode := os.Getenv("U7_CRASH_CHILD_MODE"); mode != "" { + path := os.Getenv("U7_CRASH_CHILD_DB") + st, err := OpenExistingSQLite(path) + if err != nil { + t.Fatal(err) + } + h, err := HashPassword("private-fixture-password") + if err != nil { + t.Fatal(err) + } + _, err = st.CreateActivatedAccount(AccountWrite{Owner: "survivor", Name: "Survivor", PasswordHash: h, Action: "account.create", Actor: "fixture"}) + if err != nil { + t.Fatal(err) + } + // Keep the database connection and process alive at the signal point; + // a graceful close would only test reopen, not abrupt termination. + fmt.Println("U7_COMMITTED") + select {} + } + for _, tc := range []struct { + name string + signal syscall.Signal + }{{"SIGTERM", syscall.SIGTERM}, {"SIGKILL", syscall.SIGKILL}} { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "accounts.db") + created, err := OpenSQLite(path) + if err != nil { + t.Fatal(err) + } + if err := created.Close(); err != nil { + t.Fatal(err) + } + child := exec.Command(os.Args[0], "-test.run=^TestAccountPasswordSurvivesProcessTermination$") + child.Env = append(os.Environ(), "U7_CRASH_CHILD_MODE="+tc.name, "U7_CRASH_CHILD_DB="+path) + stdout, err := child.StdoutPipe() + if err != nil { + t.Fatal(err) + } + child.Stderr = os.Stderr + if err := child.Start(); err != nil { + t.Fatal(err) + } + ready := make(chan error, 1) + go func() { + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + if strings.Contains(scanner.Text(), "U7_COMMITTED") { + ready <- nil + return + } + } + ready <- fmt.Errorf("child exited without committed receipt: %v", scanner.Err()) + }() + select { + case err := <-ready: + if err != nil { + _ = child.Process.Kill() + _ = child.Wait() + t.Fatal(err) + } + case <-time.After(10 * time.Second): + _ = child.Process.Kill() + _ = child.Wait() + t.Fatal("child did not commit in time") + } + if err := child.Process.Signal(tc.signal); err != nil { + t.Fatal(err) + } + waitErr := child.Wait() + var exitErr *exec.ExitError + if !errors.As(waitErr, &exitErr) { + t.Fatalf("child was not terminated by %s: %v", tc.name, waitErr) + } + status, ok := exitErr.Sys().(syscall.WaitStatus) + if !ok || !status.Signaled() || status.Signal() != tc.signal { + t.Fatalf("child ended without requested %s: %v", tc.name, waitErr) + } + reopened, err := OpenExistingSQLite(path) + if err != nil { + t.Fatal(err) + } + defer reopened.Close() + if _, ok := reopened.VerifyLogin("survivor", "private-fixture-password"); !ok { + t.Fatal("committed password missing after real process termination") + } + }) + } +} diff --git a/internal/store/account_security_integration_test.go b/internal/store/account_security_integration_test.go new file mode 100644 index 0000000..7e22bc1 --- /dev/null +++ b/internal/store/account_security_integration_test.go @@ -0,0 +1,187 @@ +package store + +import ( + "errors" + "testing" + "time" +) + +func activeAccount(t *testing.T) *Store { + t.Helper() + s := openTestStore(t) + h, e := HashPassword("old-password") + if e != nil { + t.Fatal(e) + } + if e := s.CreateAccount("ada", "Ada", h, false); e != nil { + t.Fatal(e) + } + if e := s.ClearMustChangePassword("ada"); e != nil { + t.Fatal(e) + } + return s +} +func TestAccountMaintenanceInvalidatesVerifiedLogin(t *testing.T) { + for _, kind := range []string{"password", "revoke-all"} { + t.Run(kind, func(t *testing.T) { + s := activeAccount(t) + _, ver, ok := s.VerifyLoginWithVersion("ada", "old-password") + if !ok { + t.Fatal("healthy login failed") + } + var err error + if kind == "password" { + h, e := HashPassword("new-password") + if e != nil { + t.Fatal(e) + } + _, err = s.ApplyPasswordChange(AccountWrite{Owner: "ada", PasswordHash: h}) + } else { + _, err = s.RevokeAllAndIssue(AccountWrite{Owner: "ada", Purpose: LoginKeyPurposeRecover, CreatedBy: "admin"}) + } + if err != nil { + t.Fatal(err) + } + if _, err := s.CreateSessionVerified("ada", SessionScopeFull, time.Hour, ver); err == nil { + t.Fatal("stale in-flight login could create a session after credential replacement") + } + }) + } +} +func TestActivatedAccountRetainsLoginKeySuccessEvent(t *testing.T) { + s := openTestStore(t) + if _, err := s.CreateActivatedAccount(AccountWrite{Owner: "ada", Purpose: LoginKeyPurposeActivate, CreatedBy: "admin", Actor: "admin"}); err != nil { + t.Fatal(err) + } + var n int + if err := s.db.QueryRow(s.q(`SELECT COUNT(*) FROM account_events WHERE owner=? AND action='login_key_issued'`), "ada").Scan(&n); err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatal("atomic issuance dropped main account event") + } +} +func TestCheckedPasswordMutationRollsBackAuditFailure(t *testing.T) { + s := openTestStore(t) + if err := s.EnsureSystemIdentity("test-grant-audit", "sqlite"); err != nil { + t.Fatal(err) + } + out, err := s.BootstrapFirstAdminWrite(AccountWrite{Owner: "ada", Name: "Ada", Actor: "test"}) + if err != nil { + t.Fatal(err) + } + token, _, err := s.RedeemLoginKey(out.Plain) + if err != nil { + t.Fatal(err) + } + h, err := HashPassword("new-password") + if err != nil { + t.Fatal(err) + } + s.SetAccountFault(ErrConflict) + if _, err := s.CompleteSetPasswordChecked(token, h); err == nil { + t.Fatal("checked password path bypassed the shared audited commit") + } + if a, err := s.GetUserAuthState("ada"); err != nil || a.HasPassword { + t.Fatal("failed checked change left a password behind") + } + if _, _, ok := s.SessionInfo(token); !ok { + t.Fatal("failed checked change consumed its session") + } +} + +func TestRevokeAllRefusesMissingOwnerWithoutCredential(t *testing.T) { + s := openTestStore(t) + _, err := s.RevokeAllAndIssue(AccountWrite{Owner: "missing", Purpose: LoginKeyPurposeRecover, CreatedBy: "admin", Actor: "admin"}) + if !errors.Is(err, ErrNotFound) { + t.Fatalf("missing owner must be rejected before issuing any key: %v", err) + } + var n int + if err := s.db.QueryRow(s.q(`SELECT COUNT(*) FROM login_keys WHERE owner=?`), "missing").Scan(&n); err != nil || n != 0 { + t.Fatalf("orphan credential rows=%d err=%v", n, err) + } +} + +func TestActivePasswordChangeRevokesUnusedRecoveryKey(t *testing.T) { + s := activeAccount(t) + key, _, err := s.IssueLoginKey("ada", LoginKeyPurposeRecover, "admin", 0) + if err != nil { + t.Fatal(err) + } + session, err := s.CreateSession("ada") + if err != nil { + t.Fatal(err) + } + hash, err := HashPassword("replacement-password") + if err != nil { + t.Fatal(err) + } + if err := s.ChangeUserPasswordActive("ada", "old-password", hash, session); err != nil { + t.Fatal(err) + } + if _, _, err := s.RedeemLoginKey(key); err == nil { + t.Fatal("unused recovery key issued before active password change still redeems") + } +} + +func TestRejectedActiveChangeIsAudited(t *testing.T) { + s := activeAccount(t) + h, err := HashPassword("next-password") + if err != nil { + t.Fatal(err) + } + err = s.ChangeUserPasswordActive("ada", "incorrect-current-password", h, "") + if !errors.Is(err, ErrBadCredentials) { + t.Fatalf("expected bad credentials, got %v", err) + } + count, err := s.CountAudit("ada", "rejected") + if err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatalf("rejected checked account mutation has %d rejected audit rows, want1", count) + } +} + +func TestDelayedOldVersionSessionIsRejectedAtUse(t *testing.T) { + s := activeAccount(t) + _, ver, ok := s.VerifyLoginWithVersion("ada", "old-password") + if !ok { + t.Fatal("healthy verification missing") + } + h, err := HashPassword("new-password") + if err != nil { + t.Fatal(err) + } + if _, err := s.ApplyPasswordChange(AccountWrite{Owner: "ada", PasswordHash: h}); err != nil { + t.Fatal(err) + } + // Represents an insert racing after the replacement's earlier DELETE. + token := "sess_private_stale_fixture" + _, err = s.db.Exec(s.q(`INSERT INTO sessions(token_hash,owner,created_at,expires_at,last_seen_at,scope,auth_version) VALUES(?,?,?,?,?,?,?)`), + hashKey(token), "ada", nowISO(), time.Now().Add(time.Hour).UTC().Format(time.RFC3339Nano), nowISO(), SessionScopeFull, ver) + if err != nil { + t.Fatal(err) + } + if _, _, ok := s.SessionInfo(token); ok { + t.Fatal("session carrying the retired auth_version became authorized") + } +} + +func TestActivePasswordChangePreservesCurrentVersionedSession(t *testing.T) { + s := activeAccount(t) + token, err := s.CreateSession("ada") + if err != nil { + t.Fatal(err) + } + h, err := HashPassword("new-password") + if err != nil { + t.Fatal(err) + } + if err := s.ChangeUserPasswordActive("ada", "old-password", h, token); err != nil { + t.Fatal(err) + } + if owner, scope, ok := s.SessionInfo(token); !ok || owner != "ada" || scope != SessionScopeFull { + t.Fatal("active change did not retain its current full session") + } +} diff --git a/internal/store/account_serialization_pg_test.go b/internal/store/account_serialization_pg_test.go new file mode 100644 index 0000000..c5ed749 --- /dev/null +++ b/internal/store/account_serialization_pg_test.go @@ -0,0 +1,171 @@ +package store + +import ( + "context" + "testing" + "time" +) + +// The trigger holds a transaction after its authorization read. A second +// connection must wait for that owner's lock before replacing credentials. +func holdPGAccountWrite(t *testing.T, s *Store, table, event, suffix string) func() { + t.Helper() + lock := "u7_account_" + suffix + _, err := s.db.Exec(`CREATE FUNCTION u7_pause_account() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN PERFORM pg_advisory_xact_lock(hashtext(current_schema() || ':` + lock + `')); RETURN NEW; END $$`) + if err != nil { + t.Fatal(err) + } + _, err = s.db.Exec(`CREATE TRIGGER u7_pause_account BEFORE ` + event + ` ON ` + table + ` FOR EACH ROW EXECUTE FUNCTION u7_pause_account()`) + if err != nil { + t.Fatal(err) + } + conn, err := s.db.Conn(context.Background()) + if err != nil { + t.Fatal(err) + } + if _, err := conn.ExecContext(context.Background(), `SELECT pg_advisory_lock(hashtext(current_schema() || ':`+lock+`'))`); err != nil { + conn.Close() + t.Fatal(err) + } + released := false + release := func() { + if !released { + released = true + _, _ = conn.ExecContext(context.Background(), `SELECT pg_advisory_unlock(hashtext(current_schema() || ':`+lock+`'))`) + _ = conn.Close() + } + } + t.Cleanup(release) + return release +} + +func waitForPGAccountWrite(t *testing.T, s *Store, suffix string) { + t.Helper() + lock := "u7_account_" + suffix + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + var n int + err := s.db.QueryRow(`SELECT count(*) FROM pg_locks WHERE locktype='advisory' AND NOT granted + AND objid=(hashtext(current_schema() || ':` + lock + `')::bigint & 4294967295)::oid`).Scan(&n) + if err != nil { + t.Fatal(err) + } + if n > 0 { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("account write did not reach the deterministic PostgreSQL pause") +} + +func awaitPGAccountResult[T any](t *testing.T, done <-chan T) T { + t.Helper() + select { + case result := <-done: + return result + case <-time.After(5 * time.Second): + t.Fatal("serialized account transaction did not finish") + var zero T + return zero + } +} + +func TestPGVerifiedSessionAndPasswordChangeSerialize(t *testing.T) { + s := activeAccount(t) + if s.Dialect() != "postgres" { + t.Skip("real PostgreSQL required") + } + _, version, ok := s.VerifyLoginWithVersion("ada", "old-password") + if !ok { + t.Fatal("initial login failed") + } + release := holdPGAccountWrite(t, s, "sessions", "INSERT", "session") + type sessionResult struct { + token string + err error + } + sessionDone := make(chan sessionResult, 1) + go func() { + token, err := s.CreateSessionVerified("ada", SessionScopeFull, time.Hour, version) + sessionDone <- sessionResult{token, err} + }() + waitForPGAccountWrite(t, s, "session") + hash, err := HashPassword("replacement-password") + if err != nil { + t.Fatal(err) + } + changeDone := make(chan error, 1) + go func() { + _, err := s.ApplyPasswordChange(AccountWrite{Owner: "ada", PasswordHash: hash, Actor: "admin"}) + changeDone <- err + }() + select { + case err := <-changeDone: + t.Fatalf("replacement committed ahead of the in-flight verified session: %v", err) + case <-time.After(100 * time.Millisecond): + } + release() + session := awaitPGAccountResult(t, sessionDone) + if session.err != nil { + t.Fatal(session.err) + } + if err := awaitPGAccountResult(t, changeDone); err != nil { + t.Fatal(err) + } + if _, _, ok := s.SessionInfo(session.token); ok { + t.Fatal("old verified session survived later password replacement") + } +} + +func TestPGRestrictedPasswordSetAndKeyReissueSerialize(t *testing.T) { + s := openTestStore(t) + if s.Dialect() != "postgres" { + t.Skip("real PostgreSQL required") + } + if err := s.EnsureSystemIdentity("test-pg-grant-serialize", "pg"); err != nil { + t.Fatal(err) + } + out, err := s.BootstrapFirstAdminWrite(AccountWrite{Owner: "ada", Name: "Ada", Actor: "test"}) + if err != nil { + t.Fatal(err) + } + account, err := s.GetAccountByOwner("ada") + if err != nil { + t.Fatal(err) + } + token, _, err := s.RedeemLoginKey(out.Plain) + if err != nil { + t.Fatal(err) + } + hash, err := HashPassword("replacement-password") + if err != nil { + t.Fatal(err) + } + release := holdPGAccountWrite(t, s, "users", "UPDATE", "password") + setDone := make(chan error, 1) + go func() { + _, err := s.CompleteSetPasswordChecked(token, hash) + setDone <- err + }() + waitForPGAccountWrite(t, s, "password") + issueDone := make(chan error, 1) + go func() { + _, err := s.InitializePendingAccount(account.ID, account.StateVersion, account.InitializationGeneration) + issueDone <- err + }() + select { + case err := <-issueDone: + t.Fatalf("key replacement committed ahead of the in-flight password set: %v", err) + case <-time.After(100 * time.Millisecond): + } + release() + if err := awaitPGAccountResult(t, setDone); err != nil { + t.Fatal(err) + } + if err := awaitPGAccountResult(t, issueDone); err != ErrConflict && err != ErrAccountForbidden { + t.Fatalf("completed initialization was reissued: %v", err) + } + if _, _, ok := s.SessionInfo(token); ok { + t.Fatal("old restricted session survived password set and key reissue") + } +} diff --git a/internal/store/account_settle_test.go b/internal/store/account_settle_test.go new file mode 100644 index 0000000..dc89175 --- /dev/null +++ b/internal/store/account_settle_test.go @@ -0,0 +1,62 @@ +package store + +import ( + "errors" + "testing" + "time" + + core "agent-system-core" +) + +func TestAccountCheckedFinalSettlementCannotWriteAfterDisable(t *testing.T) { + st := openTestStore(t) + t.Logf("account final settlement dialect=%s", st.Dialect()) + if err := st.CreateAccount("admin", "", "hash", true); err != nil { + t.Fatal(err) + } + if err := st.EnsureUser(OwnerDefault, ""); err != nil { + t.Fatal(err) + } + conv := seedConversation(t, st) + run := seedRun(t, st, conv, core.RunRunning) + local, err := st.GetAccountByOwner(OwnerDefault) + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", local.ID, local.StateVersion, false); err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + run.Status, run.Phase, run.EndedAt = core.RunCompleted, core.PhaseDone, &now + message := core.Message{ConversationID: conv.ID, RunID: run.ID, Role: core.RoleAssistant, + Parts: []core.Part{{Type: core.PartText, Text: "must not persist after disable"}}} + settle := func() error { + if st.Dialect() == "postgres" { + _, _, err := st.SettleRunFencedAccountChecked(&run, &message, "test-instance", 0, true) + return err + } + _, err := st.SettleRunAccountChecked(&run, &message, true) + return err + } + if err := settle(); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled account completed business output: %v", err) + } + got, err := st.GetRunByID(run.ID) + if err != nil || got.Status != core.RunRunning { + t.Fatalf("failed transaction changed run state: %+v %v", got, err) + } + messages, err := st.ListMessages(conv.ID) + if err != nil || len(messages) != 0 { + t.Fatalf("failed transaction wrote assistant message: %+v %v", messages, err) + } + run.Status = core.RunFailed + run.Error = &core.ErrorPart{Code: "run.account_inactive", Message: "account disabled"} + message.Parts = []core.Part{{Type: core.PartError, Err: run.Error}} + if err := settle(); err != nil { + t.Fatalf("minimal account-denial failure could not settle: %v", err) + } + got, err = st.GetRunByID(run.ID) + if err != nil || got.Status != core.RunFailed { + t.Fatalf("denial state not persisted: %+v %v", got, err) + } +} diff --git a/internal/store/account_state.go b/internal/store/account_state.go new file mode 100644 index 0000000..b25226d --- /dev/null +++ b/internal/store/account_state.go @@ -0,0 +1,126 @@ +package store + +import ( + "database/sql" + "errors" + "sort" +) + +// SetAccountEnabled changes only the effective account state. The actor and +// target are rechecked inside one transaction; disabling also consumes every +// existing credential handle. A global manager lock serializes the last-admin +// decision across PostgreSQL connections. +func (s *Store) SetAccountEnabled(actorOwner, userID string, expectedStateVersion int64, enabled bool) (Account, error) { + if expectedStateVersion < 1 { + return Account{}, ErrConflict + } + pre, err := s.GetAccount(userID) + if err != nil { + return Account{}, err + } + tx, err := s.db.Begin() + if err != nil { + return Account{}, err + } + defer tx.Rollback() + owners := []string{actorOwner, pre.Owner} + sort.Strings(owners) + for i, owner := range owners { + if i > 0 && owner == owners[i-1] { + continue + } + if err := s.dialect.lockAccount(tx, owner); err != nil { + return Account{}, err + } + } + if err := s.dialect.lockBootstrap(tx); err != nil { + return Account{}, err + } + target, err := scanAccount(tx.QueryRow(s.q(`SELECT `+accountColumns+` FROM users WHERE id=?`), userID)) + if err != nil { + return Account{}, err + } + locked, err := s.lockAccountUsers(tx, actorOwner, target.Owner) + if errors.Is(err, ErrNotFound) { + return Account{}, ErrAccountForbidden + } + if err != nil { + return Account{}, err + } + actor, target := locked[actorOwner], locked[target.Owner] + if err := s.requireSystemActionTx(tx, actor, "system.users.disable"); err != nil { + return Account{}, err + } + // The unique super administrator is never disabled through account + // management, not even by itself (R5). + if target.IsSuperAdmin { + return Account{}, ErrAccountForbidden + } + if target.ID != userID || target.StateVersion != expectedStateVersion || target.Enabled == enabled { + return Account{}, ErrConflict + } + if !enabled && target.IsAdmin && target.HasPassword && !target.MustChangePassword { + var managers int + if err := tx.QueryRow(s.q(`SELECT COUNT(1) FROM users WHERE enabled=? AND is_admin=? AND password_hash<>'' AND must_change_password=?`), true, true, false).Scan(&managers); err != nil { + return Account{}, err + } + if managers <= 1 { + return Account{}, ErrConflict + } + } + if !enabled { + var targetManagesPermissions bool + err := tx.QueryRow(s.q(`SELECT allowed FROM system_grants WHERE user_id=? AND action_id=?`), target.ID, "system.permissions.manage").Scan(&targetManagesPermissions) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return Account{}, err + } + if targetManagesPermissions { + var managers int + if err := tx.QueryRow(s.q(`SELECT COUNT(DISTINCT g.user_id) FROM system_grants g JOIN users u ON u.id=g.user_id + WHERE g.action_id=? AND g.allowed=? AND u.enabled=?`), "system.permissions.manage", true, true).Scan(&managers); err != nil { + return Account{}, err + } + if managers <= 1 { + return Account{}, ErrLastPermissionManager + } + } + } + result, err := tx.Exec(s.q(`UPDATE users SET enabled=?,state_version=state_version+1,auth_version=auth_version+1 WHERE id=? AND state_version=?`), enabled, userID, expectedStateVersion) + if err != nil { + return Account{}, err + } + if n, err := result.RowsAffected(); err != nil { + return Account{}, err + } else if n != 1 { + return Account{}, ErrConflict + } + if !enabled { + if _, err := tx.Exec(s.q(`UPDATE api_keys SET revoked=? WHERE owner=? AND revoked=?`), 1, target.Owner, 0); err != nil { + return Account{}, err + } + if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), target.Owner); err != nil { + return Account{}, err + } + if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`), LoginKeyStatusRevoked, target.Owner, LoginKeyStatusUnused); err != nil { + return Account{}, err + } + } + if err := s.bumpAccountDirectoryRevision(tx, true, false); err != nil { + return Account{}, err + } + action := "account_enabled" + if !enabled { + action = "account_disabled" + } + if err := s.insertAccountEvent(tx, actorOwner, "account-state", target.Owner, action, ""); err != nil { + return Account{}, err + } + if err := s.auditAccountMutation(tx, target.Owner, actorOwner, "account.state"); err != nil { + return Account{}, err + } + if err := tx.Commit(); err != nil { + return Account{}, err + } + target.Enabled, target.StateVersion, target.AuthVersion = enabled, expectedStateVersion+1, target.AuthVersion+1 + return target, nil +} diff --git a/internal/store/account_state_test.go b/internal/store/account_state_test.go new file mode 100644 index 0000000..6ea8bcf --- /dev/null +++ b/internal/store/account_state_test.go @@ -0,0 +1,153 @@ +package store + +import ( + "errors" + "sync" + "testing" +) + +func TestAccountStateDisablesCredentialsWithoutRestoringThem(t *testing.T) { + st := openTestStore(t) + adminHash, err := HashPassword("admin-password-1") + if err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("admin", "", adminHash, true); err != nil { + t.Fatal(err) + } + if err := st.ClearMustChangePassword("admin"); err != nil { + t.Fatal(err) + } + userHash, err := HashPassword("user-password-1") + if err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("user", "", userHash, false); err != nil { + t.Fatal(err) + } + if err := st.ClearMustChangePassword("user"); err != nil { + t.Fatal(err) + } + key, err := st.CreateAPIKey("user", "existing") + if err != nil { + t.Fatal(err) + } + session, err := st.CreateSession("user") + if err != nil { + t.Fatal(err) + } + user, err := st.GetAccountByOwner("user") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", user.ID, user.StateVersion, false); err != nil { + t.Fatal(err) + } + if _, ok := st.AuthOwner(key); ok { + t.Fatal("disabled user's API key authenticated") + } + if _, _, ok := st.SessionInfo(session); ok { + t.Fatal("disabled user's session authenticated") + } + if _, _, ok := st.VerifyLoginWithVersion("user", "user-password-1"); ok { + t.Fatal("disabled user logged in") + } + if _, err := st.CreateSession("user"); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled user gained a new session: %v", err) + } + if _, err := st.CreateAPIKey("user", "new"); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled user gained a new key: %v", err) + } + if _, err := st.SetAccountEnabled("admin", user.ID, user.StateVersion+1, true); err != nil { + t.Fatal(err) + } + if _, ok := st.AuthOwner(key); ok { + t.Fatal("revoked key was restored by enable") + } + if _, _, ok := st.SessionInfo(session); ok { + t.Fatal("revoked session was restored by enable") + } + if _, _, ok := st.VerifyLoginWithVersion("user", "user-password-1"); !ok { + t.Fatal("retained password could not log in after enable") + } +} + +func TestAccountStateConcurrentLastTwoManagersAcrossPools(t *testing.T) { + st := openTestStore(t) + other := reopenAccountModelStore(t, st) + t.Logf("last managers dialect=%s two independent pools", st.Dialect()) + for _, owner := range []string{"admin-a", "admin-b"} { + if err := st.CreateAccount(owner, "", "existing-hash", true); err != nil { + t.Fatal(err) + } + if err := st.ClearMustChangePassword(owner); err != nil { + t.Fatal(err) + } + } + a, _ := st.GetAccountByOwner("admin-a") + b, _ := st.GetAccountByOwner("admin-b") + start := make(chan struct{}) + results := make(chan error, 2) + var wg sync.WaitGroup + for i, item := range []struct { + owner string + account Account + }{{"admin-a", a}, {"admin-b", b}} { + conn := []*Store{st, other}[i] + wg.Add(1) + go func() { + defer wg.Done() + <-start + _, err := conn.SetAccountEnabled(item.owner, item.account.ID, item.account.StateVersion, false) + results <- err + }() + } + close(start) + wg.Wait() + close(results) + wins, conflicts := 0, 0 + for err := range results { + if err == nil { + wins++ + } else if errors.Is(err, ErrConflict) { + conflicts++ + } else { + t.Fatal(err) + } + } + if wins != 1 || conflicts != 1 { + t.Fatalf("last-admin race wins=%d conflicts=%d", wins, conflicts) + } + a, _ = st.GetAccount(a.ID) + b, _ = st.GetAccount(b.ID) + if a.Enabled == b.Enabled { + t.Fatalf("must retain exactly one operable manager: a=%v b=%v", a.Enabled, b.Enabled) + } +} + +func TestAccountStateProtectsLastOperableSystemManager(t *testing.T) { + st := openTestStore(t) + hash, err := HashPassword("admin-password-1") + if err != nil { + t.Fatal(err) + } + for _, owner := range []string{"admin-a", "admin-b"} { + if err := st.CreateAccount(owner, "", hash, true); err != nil { + t.Fatal(err) + } + if err := st.ClearMustChangePassword(owner); err != nil { + t.Fatal(err) + } + } + a, _ := st.GetAccountByOwner("admin-a") + b, _ := st.GetAccountByOwner("admin-b") + if _, err := st.SetAccountEnabled("admin-a", a.ID, a.StateVersion, false); err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin-b", b.ID, b.StateVersion, false); !errors.Is(err, ErrConflict) { + t.Fatalf("last operable manager disabled: %v", err) + } + if _, err := st.SetAccountEnabled("admin-a", b.ID, b.StateVersion, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled manager changed another account: %v", err) + } +} diff --git a/internal/store/account_two_process_pg_test.go b/internal/store/account_two_process_pg_test.go new file mode 100644 index 0000000..fea8054 --- /dev/null +++ b/internal/store/account_two_process_pg_test.go @@ -0,0 +1,202 @@ +package store + +import ( + "bytes" + "context" + "database/sql" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +// Two independent process opens share one isolated PostgreSQL schema. The +// barrier makes their account transactions genuinely concurrent. +func TestAccountTwoProcessPostgresAdmission(t *testing.T) { + if mode := os.Getenv("U7_ACCOUNT_CHILD_MODE"); mode != "" { + childDSN := os.Getenv("U7_ACCOUNT_CHILD_DSN") + barrier := os.Getenv("U7_ACCOUNT_CHILD_BARRIER") + st, err := OpenExistingPostgres(childDSN, os.Getenv("U7_ACCOUNT_CHILD_ARTIFACTS")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if err := os.WriteFile(os.Getenv("U7_ACCOUNT_CHILD_READY"), []byte("ready"), 0o600); err != nil { + t.Fatal(err) + } + for i := 0; i < 500; i++ { + if _, err := os.Stat(barrier); err == nil { + break + } + time.Sleep(5 * time.Millisecond) + } + if _, err := os.Stat(barrier); err != nil { + t.Fatal("two-process barrier missing") + } + var outcome AccountOutcome + switch mode { + case "create": + outcome, err = st.CreateActivatedAccount(AccountWrite{Owner: "same", Name: "Same", Purpose: LoginKeyPurposeActivate, CreatedBy: "fixture", Actor: "fixture", Action: "account.create"}) + case "bootstrap": + outcome, err = st.BootstrapFirstAdminWrite(AccountWrite{Owner: os.Getenv("U7_ACCOUNT_CHILD_OWNER"), Name: "Bootstrap", CreatedBy: "fixture", Actor: "fixture"}) + case "bootstrap-versus-create": + if os.Getenv("U7_ACCOUNT_CHILD_OWNER") == "bootstrap" { + outcome, err = st.BootstrapFirstAdminWrite(AccountWrite{Owner: "admin", Name: "Admin", CreatedBy: "fixture", Actor: "fixture"}) + } else { + outcome, err = st.CreateActivatedAccount(AccountWrite{Owner: "ordinary", Name: "Ordinary", Purpose: LoginKeyPurposeActivate, CreatedBy: "fixture", Actor: "fixture", Action: "account.create"}) + } + default: + t.Fatal("unknown child mode") + } + if err == nil { + if outcome.OperationID == "" || outcome.Plain == "" { + t.Fatal("committed account did not produce a complete receipt") + } + fmt.Println("U7_RESULT=success") + return + } + if errors.Is(err, ErrConflict) { + fmt.Println("U7_RESULT=conflict") + return + } + t.Fatalf("unexpected admission error: %v", err) + return + } + baseDSN := os.Getenv("AGENT_PG_TEST_DSN") + if baseDSN == "" { + t.Skip("AGENT_PG_TEST_DSN absent; Dev PG gate runs this against a real isolated schema") + } + for _, tc := range []struct { + mode string + owners [2]string + }{ + {"create", [2]string{"one", "two"}}, + {"bootstrap", [2]string{"admin-one", "admin-two"}}, + {"bootstrap-versus-create", [2]string{"bootstrap", "ordinary"}}, + } { + t.Run(tc.mode, func(t *testing.T) { + schema := fmt.Sprintf("u7_audit_%d", time.Now().UnixNano()) + admin, err := sql.Open("pgx", baseDSN) + if err != nil { + t.Fatal(err) + } + defer admin.Close() + ensurePGTrgm(t, admin) + if _, err := admin.Exec(`CREATE SCHEMA ` + schema); err != nil { + t.Fatal(err) + } + defer admin.Exec(`DROP SCHEMA IF EXISTS ` + schema + ` CASCADE`) + dsn := withTestSearchPath(baseDSN, schema) + artifacts := filepath.Join(t.TempDir(), "artifacts") + st, err := OpenPostgres(dsn, artifacts) + if err != nil { + t.Fatal(err) + } + if err := st.EnsureSystemIdentity("pg-two-process", "pg"); err != nil { + t.Fatal(err) + } + st.Close() + // Hold the shared admission point so both independent processes + // are demonstrably contending before either may commit. + lockKey := "bootstrap:first-admin" + if tc.mode == "create" { + lockKey = "account:same" + } + lockConn, err := admin.Conn(context.Background()) + if err != nil { + t.Fatal(err) + } + defer lockConn.Close() + if _, err := lockConn.ExecContext(context.Background(), `SELECT pg_advisory_lock(hashtext($1))`, lockKey); err != nil { + t.Fatal(err) + } + released := false + release := func() { + if !released { + released = true + _, _ = lockConn.ExecContext(context.Background(), `SELECT pg_advisory_unlock(hashtext($1))`, lockKey) + } + } + defer release() + barrier := filepath.Join(t.TempDir(), "go") + readyDir := t.TempDir() + var children [2]*exec.Cmd + var logs [2]bytes.Buffer + for i := range children { + readyPath := filepath.Join(readyDir, fmt.Sprintf("ready-%d", i)) + cmd := exec.Command(os.Args[0], "-test.run=^TestAccountTwoProcessPostgresAdmission$", "-test.v") + cmd.Env = append(os.Environ(), "U7_ACCOUNT_CHILD_MODE="+tc.mode, "U7_ACCOUNT_CHILD_DSN="+dsn, + "U7_ACCOUNT_CHILD_OWNER="+tc.owners[i], "U7_ACCOUNT_CHILD_ARTIFACTS="+artifacts, + "U7_ACCOUNT_CHILD_BARRIER="+barrier, "U7_ACCOUNT_CHILD_READY="+readyPath) + cmd.Stdout = &logs[i] + cmd.Stderr = &logs[i] + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + children[i] = cmd + } + deadline := time.Now().Add(10 * time.Second) + for i := range children { + readyPath := filepath.Join(readyDir, fmt.Sprintf("ready-%d", i)) + for { + if _, err := os.Stat(readyPath); err == nil { + break + } + if time.Now().After(deadline) { + t.Fatalf("child %d never opened the shared database: %s", i, logs[i].String()) + } + time.Sleep(5 * time.Millisecond) + } + } + if err := os.WriteFile(barrier, []byte("go"), 0o600); err != nil { + t.Fatal(err) + } + for { + var waiters int + err := admin.QueryRow(`SELECT count(*) FROM pg_locks WHERE locktype='advisory' AND NOT granted + AND objid=(hashtext($1)::bigint & 4294967295)::oid`, lockKey).Scan(&waiters) + if err != nil { + t.Fatal(err) + } + if waiters >= 2 { + break + } + if time.Now().After(deadline) { + t.Fatalf("only %d of 2 child processes reached the shared admission lock: %s | %s", waiters, logs[0].String(), logs[1].String()) + } + time.Sleep(5 * time.Millisecond) + } + release() + wins, losses := 0, 0 + for i, cmd := range children { + if err := cmd.Wait(); err != nil { + t.Fatalf("child %d failed: %v %s", i, err, logs[i].String()) + } + out := logs[i].String() + if strings.Contains(out, "U7_RESULT=success") { + wins++ + } else if strings.Contains(out, "U7_RESULT=conflict") { + losses++ + } else { + t.Fatalf("child %d produced no outcome: %s", i, out) + } + } + if wins != 1 || losses != 1 { + t.Fatalf("admission wins=%d conflicts=%d, want 1/1", wins, losses) + } + reopened, err := OpenExistingPostgres(dsn, artifacts) + if err != nil { + t.Fatal(err) + } + defer reopened.Close() + users, err := reopened.ListUsersAdmin() + if err != nil || len(users) != 1 { + t.Fatalf("actual committed users=%d, err=%v", len(users), err) + } + }) + } +} diff --git a/internal/store/account_tx.go b/internal/store/account_tx.go new file mode 100644 index 0000000..5d19939 --- /dev/null +++ b/internal/store/account_tx.go @@ -0,0 +1,426 @@ +package store + +import ( + "crypto/rand" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "sort" + "time" +) + +// AccountWrite 是一次账户变更的调用参数。成功审计与变更同事务。 +type AccountWrite struct { + Owner string + Name string + PasswordHash string + IsAdmin bool + MustChange bool + ExceptToken string + Purpose string + CreatedBy string + TTL time.Duration + RevokeAllKeys bool + Actor string + InstanceID string + Action string + // ActorAccount marks Actor as an account owner that accountTx locks + // together with Owner in owner order, so the callback can recheck it. + ActorAccount bool +} + +// AccountOutcome 是成功提交后可以对外展示的标识。不含密码、哈希或明文。 +type AccountOutcome struct { + Plain string + CredentialID string + OperationID string +} + +// CreateActivatedAccount 严格插入账户。passwordHash 为空且 Purpose 非空时,同一事务签发登录 Key。 +func (s *Store) CreateActivatedAccount(w AccountWrite) (AccountOutcome, error) { + if w.Action == "" { + w.Action = "account.create" + } + var out AccountOutcome + err := s.accountTx(w, func(tx *sql.Tx, opID string) error { + if s.dialect.name() == "postgres" { + // A first ordinary account and first-admin bootstrap use the same + // admission lock. If bootstrap commits while this call waits, the + // earlier empty-database observation cannot authorize a second + // competing first account. + var before, after int + if err := tx.QueryRow(s.q(`SELECT COUNT(*) FROM users`)).Scan(&before); err != nil { + return err + } + if before == 0 { + if err := s.dialect.lockBootstrap(tx); err != nil { + return err + } + if err := tx.QueryRow(s.q(`SELECT COUNT(*) FROM users`)).Scan(&after); err != nil { + return err + } + if after != 0 { + return ErrConflict + } + } + } + if err := insertAccount(s, tx, w); err != nil { + return err + } + if w.IsAdmin { + if err := s.seedSystemAdminGrantsTx(tx, w.Owner); err != nil { + return err + } + } + if w.PasswordHash == "" && w.Purpose != "" { + plain, id, err := insertLoginKey(s, tx, w) + if err != nil { + return err + } + out.Plain, out.CredentialID = plain, id + } + out.OperationID = opID + return nil + }) + return out, err +} + +// ApplyPasswordChange 在同一事务里写哈希、更新强制改密标志、作废未使用登录 Key,并处理会话。 +func (s *Store) ApplyPasswordChange(w AccountWrite) (AccountOutcome, error) { + if w.Action == "" { + w.Action = "account.password" + } + var out AccountOutcome + err := s.accountTx(w, func(tx *sql.Tx, opID string) error { + res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=?, auth_version=auth_version+1 WHERE owner=?`), + w.PasswordHash, nowISO(), w.MustChange, w.Owner) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`), + LoginKeyStatusRevoked, w.Owner, LoginKeyStatusUnused); err != nil { + return err + } + if w.ExceptToken != "" { + _, err = tx.Exec(s.q(`DELETE FROM sessions WHERE owner=? AND token_hash<>?`), w.Owner, hashKey(w.ExceptToken)) + } else { + _, err = tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), w.Owner) + } + if err != nil { + return err + } + if w.ExceptToken != "" { + if _, err := tx.Exec(s.q(`UPDATE sessions SET auth_version=(SELECT auth_version FROM users WHERE owner=?) WHERE owner=? AND token_hash=?`), + w.Owner, w.Owner, hashKey(w.ExceptToken)); err != nil { + return err + } + } + out.OperationID = opID + return nil + }) + return out, err +} + +// BootstrapFirstAdmin 在立即事务(SQLite)或身份行锁(PostgreSQL)内复核空库后建首位管理员。 +func (s *Store) BootstrapFirstAdminWrite(w AccountWrite) (AccountOutcome, error) { + w.IsAdmin = true + w.PasswordHash = "" + w.Purpose = LoginKeyPurposeActivate + if w.CreatedBy == "" { + w.CreatedBy = localOwnerActor() + } + if w.Action == "" { + w.Action = "account.bootstrap" + } + var out AccountOutcome + err := s.accountTx(w, func(tx *sql.Tx, opID string) error { + if err := s.dialect.lockBootstrap(tx); err != nil { + return err + } + if err := lockIdentity(s, tx); err != nil { + return err + } + if err := requireEmptyBusiness(s, tx); err != nil { + return err + } + account, err := s.insertPendingAccountTx(tx, w.Owner, AccountProfile{Name: w.Name}) + if err != nil { + return err + } + if _, err := tx.Exec(s.q(`UPDATE users SET is_admin=?,initialization_generation=? WHERE id=?`), true, 1, account.ID); err != nil { + return err + } + if err := s.seedSystemAdminGrantsTx(tx, w.Owner); err != nil { + return err + } + var users int + if err := tx.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&users); err != nil { + return err + } + if users != 1 { + return ErrConflict + } + if err := s.insertAccountEvent(tx, w.CreatedBy, entranceOf(w.CreatedBy), w.Owner, "bootstrap_admin", w.Owner); err != nil { + return err + } + plain, err := newActivationPlain() + if err != nil { + return err + } + id := newID("lk") + now := time.Now().UTC() + if _, err := s.insertLoginKeyTx(tx, w.Owner, LoginKeyPurposeActivate, w.CreatedBy, plain, id, now, now.Add(defaultLoginKeyTTL), + grantBinding{origin: CredentialOriginOwnerInitialization, generation: 1, version: 0}); err != nil { + return err + } + if err := s.stampLoginKeyInstance(tx, id, w.InstanceID); err != nil { + return err + } + out.Plain, out.CredentialID, out.OperationID = plain, id, opID + return nil + }) + return out, err +} + +// RevokeAllAndIssue 把吊销长期密钥、删除会话、作废未使用登录 Key 和签发新 Key 放进一次提交。 +func (s *Store) RevokeAllAndIssue(w AccountWrite) (AccountOutcome, error) { + if w.Action == "" { + w.Action = "account.revoke_credentials" + } + var out AccountOutcome + err := s.accountTx(w, func(tx *sql.Tx, opID string) error { + res, err := tx.Exec(s.q(`UPDATE users SET auth_version=auth_version+1 WHERE owner=?`), w.Owner) + if err != nil { + return err + } + if n, err := res.RowsAffected(); err != nil { + return err + } else if n != 1 { + return ErrNotFound + } + + if _, err := tx.Exec(s.q(`UPDATE api_keys SET revoked=1 WHERE owner=? AND revoked=0`), w.Owner); err != nil { + return err + } + if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), w.Owner); err != nil { + return err + } + if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`), + LoginKeyStatusRevoked, w.Owner, LoginKeyStatusUnused); err != nil { + return err + } + plain, id, err := insertLoginKey(s, tx, w) + if err != nil { + return err + } + out.Plain, out.CredentialID, out.OperationID = plain, id, opID + return nil + }) + return out, err +} + +func (s *Store) accountTx(w AccountWrite, fn func(*sql.Tx, string) error) error { + opID := newID("op") + tx, err := s.db.Begin() + if err != nil { + return err + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback() + } + }() + var lockOwners []string + if w.Owner != "" { + lockOwners = append(lockOwners, w.Owner) + } + if w.ActorAccount && w.Actor != "" && w.Actor != w.Owner { + lockOwners = append(lockOwners, w.Actor) + } + sort.Strings(lockOwners) + for _, owner := range lockOwners { + if err := s.dialect.lockAccount(tx, owner); err != nil { + return err + } + } + if w.InstanceID == "" { + if err := tx.QueryRow(`SELECT instance_id FROM agent_system_identity`).Scan(&w.InstanceID); err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + } + if err := fn(tx, opID); err != nil { + _ = tx.Rollback() + committed = true + s.recordRejected(w, opID) + return err + } + if s.accountFault != nil { + err = s.accountFault + _ = tx.Rollback() + committed = true + s.recordRejected(w, opID) + return err + } + if err := insertAudit(s, tx, w, opID, "success"); err != nil { + _ = tx.Rollback() + committed = true + s.recordRejected(w, opID) + return err + } + if err := tx.Commit(); err != nil { + // Commit may have reached the database even if the client lost its + // response. Recording a rejection here would assert an unknown outcome. + return err + } + committed = true + return nil +} + +func (s *Store) recordRejected(w AccountWrite, opID string) { + _, _ = s.db.Exec(s.q(`INSERT INTO audit_events(time,owner,action,actor,target,entry,instance_id,operation_id,outcome) VALUES(?,?,?,?,?,?,?,?,?)`), + nowISO(), w.Owner, w.Action, w.Actor, w.Owner, "account", w.InstanceID, opID, "rejected") +} + +func insertAudit(s *Store, tx *sql.Tx, w AccountWrite, opID, outcome string) error { + _, err := tx.Exec(s.q(`INSERT INTO audit_events(time,owner,action,actor,target,entry,instance_id,operation_id,outcome) VALUES(?,?,?,?,?,?,?,?,?)`), + nowISO(), w.Owner, w.Action, w.Actor, w.Owner, "account", w.InstanceID, opID, outcome) + return err +} + +// auditAccountMutation records U3 account operations that have their own +// multi-account transaction and therefore cannot enter accountTx's single +// target callback. It must be called before the caller commits. +func (s *Store) auditAccountMutation(tx *sql.Tx, owner, actor, action string) error { + var instanceID string + if err := tx.QueryRow(`SELECT instance_id FROM agent_system_identity`).Scan(&instanceID); err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + return insertAudit(s, tx, AccountWrite{Owner: owner, Actor: actor, Action: action, InstanceID: instanceID}, newID("op"), "success") +} + +func (s *Store) stampLoginKeyInstance(tx *sql.Tx, credentialID, instanceID string) error { + if instanceID == "" { + if err := tx.QueryRow(`SELECT instance_id FROM agent_system_identity`).Scan(&instanceID); err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + } + _, err := tx.Exec(s.q(`UPDATE login_keys SET instance_id=? WHERE id=?`), instanceID, credentialID) + return err +} + +func insertAccount(s *Store, tx *sql.Tx, w AccountWrite) error { + mustChange := w.PasswordHash != "" || w.MustChange + setAt := "" + if w.PasswordHash != "" { + setAt = nowISO() + } + _, err := tx.Exec(s.q(`INSERT INTO users(id,owner,name,created_at,password_hash,password_set_at,must_change_password,is_admin) VALUES(?,?,?,?,?,?,?,?)`), + newID("usr"), w.Owner, w.Name, nowISO(), w.PasswordHash, setAt, mustChange, w.IsAdmin) + if isUniqueViolation(err) { + return ErrConflict + } + return err +} + +func insertLoginKey(s *Store, tx *sql.Tx, w AccountWrite) (string, string, error) { + purpose := w.Purpose + if purpose != LoginKeyPurposeActivate && purpose != LoginKeyPurposeRecover { + return "", "", fmt.Errorf("login key: invalid purpose %q", purpose) + } + ttl := w.TTL + if ttl <= 0 { + ttl = defaultLoginKeyTTL + } + if ttl < minLoginKeyTTL { + ttl = minLoginKeyTTL + } + if ttl > maxLoginKeyTTL { + ttl = maxLoginKeyTTL + } + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", "", err + } + idRaw := make([]byte, 8) + if _, err := rand.Read(idRaw); err != nil { + return "", "", err + } + plain := "alk_" + hex.EncodeToString(raw) + id := "lk_" + hex.EncodeToString(idRaw) + now := time.Now().UTC() + if _, err := s.insertLoginKeyTx(tx, w.Owner, purpose, w.CreatedBy, plain, id, now, now.Add(ttl)); err != nil { + return "", "", err + } + _, err := tx.Exec(s.q(`UPDATE login_keys SET instance_id=? WHERE id=?`), w.InstanceID, id) + return plain, id, err +} + +func lockIdentity(s *Store, tx *sql.Tx) error { + q := `SELECT instance_id FROM agent_system_identity` + if s.dialect.name() == "postgres" { + q += ` FOR UPDATE` + } + rows, err := tx.Query(q) + if err != nil { + return err + } + defer rows.Close() + n := 0 + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return err + } + n++ + } + if err := rows.Err(); err != nil { + return err + } + if n == 0 { + return fmt.Errorf("%w: agent_system_identity 无行", ErrSystemIdentityMissing) + } + return nil +} + +func requireEmptyBusiness(s *Store, tx *sql.Tx) error { + for _, table := range []string{"users", "projects", "conversations", "messages", "runs", "api_keys", "sessions", "login_keys"} { + var n int + if err := tx.QueryRow(`SELECT COUNT(1) FROM ` + table).Scan(&n); err != nil { + return err + } + if n > 0 { + return ErrConflict + } + } + return nil +} + +func (s *Store) requireCurrentSchema() error { + applied, err := s.MigrationStatus() + if err != nil || len(applied) == 0 { + return fmt.Errorf("schema 版本不可读;请运行 agent-system migrate,或先启动一次服务") + } + latest := migrations[len(migrations)-1].version + got := applied[len(applied)-1] + if got != latest { + return fmt.Errorf("schema v%d 与本二进制 v%d 不一致;请运行 agent-system migrate,或先启动一次服务", got, latest) + } + return nil +} + +// SetAccountFault 让下一次账户事务在提交前失败,用于半态测试。 +func (s *Store) SetAccountFault(err error) { + s.accountFault = err +} + +// CountAudit 返回指定 outcome 的账户审计条数。 +func (s *Store) CountAudit(owner, outcome string) (int, error) { + var n int + err := s.db.QueryRow(s.q(`SELECT COUNT(1) FROM audit_events WHERE owner=? AND entry=? AND outcome=?`), owner, "account", outcome).Scan(&n) + return n, err +} diff --git a/internal/store/account_tx_lifecycle_test.go b/internal/store/account_tx_lifecycle_test.go new file mode 100644 index 0000000..d18725b --- /dev/null +++ b/internal/store/account_tx_lifecycle_test.go @@ -0,0 +1,156 @@ +package store + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "testing" +) + +func TestConcurrentCreateOneWinner(t *testing.T) { + st := openLife(t) + const n = 8 + var wg sync.WaitGroup + wins := make(chan error, n) + for i := 0; i < n; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _, err := st.CreateActivatedAccount(AccountWrite{ + Owner: "same", Name: "Same", Purpose: LoginKeyPurposeActivate, CreatedBy: "test", + Action: "account.create", + }) + wins <- err + }() + } + wg.Wait() + close(wins) + okN, conflict := 0, 0 + for err := range wins { + switch { + case err == nil: + okN++ + case errors.Is(err, ErrConflict): + conflict++ + default: + t.Fatal(err) + } + } + if okN != 1 || conflict != n-1 { + t.Fatalf("wins=%d conflicts=%d", okN, conflict) + } +} + +func TestPasswordChangeIsAtomic(t *testing.T) { + st := openLife(t) + hash, err := HashPassword("old-password") + if err != nil { + t.Fatal(err) + } + if _, err := st.CreateActivatedAccount(AccountWrite{Owner: "ada", Name: "Ada", PasswordHash: hash, Action: "account.create"}); err != nil { + t.Fatal(err) + } + plain, _, err := st.IssueLoginKey("ada", LoginKeyPurposeActivate, "test", 0) + if err != nil { + t.Fatal(err) + } + st.SetAccountFault(fmt.Errorf("injected")) + next, err := HashPassword("new-password") + if err != nil { + t.Fatal(err) + } + if _, err := st.ApplyPasswordChange(AccountWrite{Owner: "ada", PasswordHash: next, Action: "account.password"}); err == nil { + t.Fatal("faulted change returned success") + } + st.SetAccountFault(nil) + if _, ok := st.VerifyLogin("ada", "old-password"); !ok { + t.Fatal("old password did not survive the failed change") + } + if _, err := st.ApplyPasswordChange(AccountWrite{Owner: "ada", PasswordHash: next, Action: "account.password"}); err != nil { + t.Fatal(err) + } + if _, _, err := st.RedeemLoginKey(plain); err == nil { + t.Fatal("old key redeemed after password change") + } + if _, ok := st.VerifyLogin("ada", "new-password"); !ok { + t.Fatal("new password missing") + } + n, err := st.CountAudit("ada", "rejected") + if err != nil || n == 0 { + t.Fatalf("rejected audit n=%d err=%v", n, err) + } +} + +func TestOpenExistingDoesNotMigrateOrCreate(t *testing.T) { + missing := filepath.Join(t.TempDir(), "absent", "agent-system.db") + if _, err := OpenExistingSQLite(missing); err == nil { + t.Fatal("missing file opened") + } + if _, err := os.Stat(filepath.Dir(missing)); !os.IsNotExist(err) { + t.Fatalf("parent created: %v", err) + } + path := filepath.Join(t.TempDir(), "agent-system.db") + st, err := OpenSQLite(path) + if err != nil { + t.Fatal(err) + } + if err := st.Close(); err != nil { + t.Fatal(err) + } + again, err := OpenExistingSQLite(path) + if err != nil { + t.Fatal(err) + } + again.Close() +} + +func TestBootstrapRefusesExistingBusiness(t *testing.T) { + st := openLife(t) + if err := st.EnsureSystemIdentity("inst", "sqlite"); err != nil { + t.Fatal(err) + } + if _, err := st.CreateProject("owner", "p"); err != nil { + t.Fatal(err) + } + if _, err := st.BootstrapFirstAdminWrite(AccountWrite{Owner: "root", Name: "root"}); err == nil { + t.Fatal("bootstrap accepted a database that already has a project") + } +} + +func TestPasswordSurvivesReopen(t *testing.T) { + path := filepath.Join(t.TempDir(), "agent-system.db") + st, err := OpenSQLite(path) + if err != nil { + t.Fatal(err) + } + hash, err := HashPassword("keep-password") + if err != nil { + t.Fatal(err) + } + if _, err := st.CreateActivatedAccount(AccountWrite{Owner: "ada", Name: "Ada", PasswordHash: hash}); err != nil { + t.Fatal(err) + } + if err := st.Close(); err != nil { + t.Fatal(err) + } + st, err = OpenSQLite(path) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if _, ok := st.VerifyLogin("ada", "keep-password"); !ok { + t.Fatal("password did not survive reopen") + } +} + +func openLife(t *testing.T) *Store { + t.Helper() + st, err := OpenSQLite(filepath.Join(t.TempDir(), "agent-system.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + return st +} diff --git a/internal/store/account_u3_audit_test.go b/internal/store/account_u3_audit_test.go new file mode 100644 index 0000000..209f59e --- /dev/null +++ b/internal/store/account_u3_audit_test.go @@ -0,0 +1,35 @@ +package store + +import "testing" + +func TestAccountStateRollsBackWhenTransactionalAuditFails(t *testing.T) { + s := openTestStore(t) + if s.Dialect() != "sqlite" { + t.Skip("SQLite fault-injection trigger; PostgreSQL account tests run separately") + } + hash, err := HashPassword("owner-password") + if err != nil { + t.Fatal(err) + } + if err := s.CreateAccount("root", "Root", hash, true); err != nil { + t.Fatal(err) + } + if err := s.CreateAccount("ada", "Ada", "", false); err != nil { + t.Fatal(err) + } + target, err := s.GetAccountByOwner("ada") + if err != nil { + t.Fatal(err) + } + if _, err := s.DB().Exec(`CREATE TRIGGER fail_account_state_audit BEFORE INSERT ON audit_events + WHEN NEW.action='account.state' BEGIN SELECT RAISE(ABORT, 'audit unavailable'); END`); err != nil { + t.Fatal(err) + } + if _, err := s.SetAccountEnabled("root", target.ID, target.StateVersion, false); err == nil { + t.Fatal("state change committed without its audit event") + } + current, err := s.GetAccount(target.ID) + if err != nil || !current.Enabled || current.StateVersion != target.StateVersion { + t.Fatalf("audit failure left a partial state change: %+v %v", current, err) + } +} diff --git a/internal/store/account_write_guard.go b/internal/store/account_write_guard.go new file mode 100644 index 0000000..02f3f75 --- /dev/null +++ b/internal/store/account_write_guard.go @@ -0,0 +1,88 @@ +package store + +import ( + "database/sql" + "errors" + "sort" +) + +// withEffectiveAccountWrite runs a Backend-owned business mutation after the +// actor's effective state has been locked and checked in the final transaction. +// Disable uses the same account advisory/user-row lock, so it cannot commit +// before a write that has passed this check, nor can a later write pass after +// disable commits. Callers acquire resource and scope locks only inside fn. +func (s *Store) withEffectiveAccountWrite(actor string, allowImplicitDefault bool, fn func(*sql.Tx, Account) error) error { + if actor == "" || fn == nil { + return ErrAccountForbidden + } + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + accounts, err := s.lockAccountUsers(tx, actor) + var account Account + if errors.Is(err, ErrNotFound) && allowImplicitDefault && actor == OwnerDefault { + account = Account{Owner: OwnerDefault, Enabled: true} + } else if errors.Is(err, ErrNotFound) { + return ErrAccountForbidden + } else if err != nil { + return err + } else { + account = accounts[actor] + } + if !account.Enabled { + return ErrAccountForbidden + } + if err := fn(tx, account); err != nil { + return err + } + return tx.Commit() +} + +// withEffectiveProjectActorsWrite fences a delegated project projection against +// both the real operator and the persisted creator. Distinct accounts are +// acquired together in owner order before the global project-name lock. +func (s *Store) withEffectiveProjectActorsWrite(actor, creator string, allowImplicitDefault bool, fn func(*sql.Tx) error) error { + if actor == "" || creator == "" || fn == nil { + return ErrAccountForbidden + } + if actor == creator { + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + return fn(tx) + }) + } + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + ordered := []string{actor, creator} + sort.Strings(ordered) + query := s.q(`SELECT ` + accountColumns + ` FROM users WHERE owner=?`) + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + for _, owner := range ordered { + if err := s.dialect.lockAccount(tx, owner); err != nil { + return err + } + account, err := scanAccount(tx.QueryRow(query, owner)) + if errors.Is(err, ErrNotFound) && allowImplicitDefault && owner == OwnerDefault { + continue + } + if errors.Is(err, ErrNotFound) { + return ErrAccountForbidden + } + if err != nil { + return err + } + if !account.Enabled { + return ErrAccountForbidden + } + } + if err := fn(tx); err != nil { + return err + } + return tx.Commit() +} diff --git a/internal/store/admission.go b/internal/store/admission.go index c9393fb..d6f88db 100644 --- a/internal/store/admission.go +++ b/internal/store/admission.go @@ -38,6 +38,37 @@ func (s *Store) TryAcquireExecutionSlot(runID, owner string, budget ExecutionBud if err := s.lockAdmission(tx); err != nil { return err } + if err := s.acquireExecutionSlotTx(tx, runID, owner, budget); err != nil { + return err + } + return tx.Commit() +} + +// TryAcquireExecutionSlotAccountChecked takes the actor account lock before +// the global admission lock. The run row, not a caller-supplied owner string, +// supplies the authoritative owner for a new or reactivated slot. +func (s *Store) TryAcquireExecutionSlotAccountChecked(actor, runID string, budget ExecutionBudget, allowImplicitDefault bool) error { + if budget.Off() { + return nil // no lease row or other DB mutation + } + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + if err := s.lockAdmission(tx); err != nil { + return err + } + var owner string + if err := tx.QueryRow(s.q(`SELECT owner FROM runs WHERE id=?`), runID).Scan(&owner); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + if owner != actor { + return ErrNotFound + } + return s.acquireExecutionSlotTx(tx, runID, actor, budget) + }) +} + +func (s *Store) acquireExecutionSlotTx(tx *sql.Tx, runID, owner string, budget ExecutionBudget) error { var global, perUser int if budget.Global > 0 { if err := tx.QueryRow(s.q(`SELECT COUNT(*) FROM admission_leases WHERE released_at IS NULL AND run_id<>?`), runID).Scan(&global); err != nil { @@ -60,7 +91,7 @@ func (s *Store) TryAcquireExecutionSlot(runID, owner string, budget ExecutionBud runID, owner, nowISO()); err != nil { return err } - return tx.Commit() + return nil } // ReleaseExecutionSlot 释放执行许可(幂等):父 run 等待子代理/HITL 时让出, diff --git a/internal/store/artifact_account_write_test.go b/internal/store/artifact_account_write_test.go new file mode 100644 index 0000000..bde8b69 --- /dev/null +++ b/internal/store/artifact_account_write_test.go @@ -0,0 +1,212 @@ +package store + +import ( + "errors" + "os" + "path/filepath" + "testing" + "time" + + "agent-system-backend/internal/blobstore" + core "agent-system-core" +) + +type artifactAccountWriter interface { + CreateArtifactAccountChecked(actor string, art *core.Artifact, content []byte, allowImplicitDefault bool) error + UpdateArtifactContentAccountChecked(actor, id string, content []byte, allowImplicitDefault bool) error + DeleteArtifactAccountChecked(actor, id string, allowImplicitDefault bool) error +} + +func artifactWriter(t *testing.T, st *Store) artifactAccountWriter { + t.Helper() + w, ok := any(st).(artifactAccountWriter) + if !ok { + t.Fatal("Store is missing the account-checked artifact write API") + } + return w +} + +func openArtifactFenceStore(t *testing.T, path string) *Store { + t.Helper() + st, err := OpenSQLite(path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = st.Close() }) + return st +} + +func artifactRef(t *testing.T, st *Store, id string) string { + t.Helper() + var ref string + if err := st.DB().QueryRow(`SELECT content_path FROM artifacts WHERE id=?`, id).Scan(&ref); err != nil { + t.Fatal(err) + } + return ref +} + +func TestArtifactAccountFenceCreateRejectsDisabledAndForeignOwner(t *testing.T) { + st := openArtifactFenceStore(t, filepath.Join(t.TempDir(), "account-artifacts.db")) + w := artifactWriter(t, st) + for _, owner := range []string{"alice", "bob"} { + if err := st.EnsureUser(owner, ""); err != nil { + t.Fatal(err) + } + } + foreign := core.Artifact{Owner: "alice", ProjectID: "project", Name: "foreign.txt", Kind: "data"} + if err := w.CreateArtifactAccountChecked("bob", &foreign, []byte("wrong"), false); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign-owner create: got %v, want ErrNotFound", err) + } + if _, err := st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner='alice'`); err != nil { + t.Fatal(err) + } + blocked := core.Artifact{Owner: "alice", ProjectID: "project", Name: "blocked.txt", Kind: "data"} + if err := w.CreateArtifactAccountChecked("alice", &blocked, []byte("late"), false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled create: got %v, want ErrAccountForbidden", err) + } + var n int + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM artifacts`).Scan(&n); err != nil || n != 0 { + t.Fatalf("rejected creates left %d artifact rows: %v", n, err) + } + files, err := os.ReadDir(st.DataDir()) + if err != nil || len(files) != 0 { + t.Fatalf("rejected creates left staged blobs: %d entries, %v", len(files), err) + } +} + +type pauseAfterArtifactPut struct { + blobstore.Store + putDone chan struct{} + resume chan struct{} +} + +func (p *pauseAfterArtifactPut) Put(id string, content []byte) (string, error) { + ref, err := p.Store.Put(id, content) + if err == nil { + p.putDone <- struct{}{} + <-p.resume + } + return ref, err +} + +func TestArtifactAccountFenceUpdatePreservesVisibleBytesWhenDisableWins(t *testing.T) { + path := filepath.Join(t.TempDir(), "account-artifacts.db") + st := openArtifactFenceStore(t, path) + other := openArtifactFenceStore(t, path) + w := artifactWriter(t, st) + if err := st.EnsureUser("alice", ""); err != nil { + t.Fatal(err) + } + art := core.Artifact{Owner: "alice", ProjectID: "project", Name: "minutes.txt", Kind: "data"} + if err := st.CreateArtifact(&art, []byte("original")); err != nil { + t.Fatal(err) + } + oldRef := artifactRef(t, st, art.ID) + pause := &pauseAfterArtifactPut{Store: st.blobs, putDone: make(chan struct{}), resume: make(chan struct{})} + st.blobs = pause + result := make(chan error, 1) + go func() { result <- w.UpdateArtifactContentAccountChecked("alice", art.ID, []byte("late edit"), false) }() + select { + case <-pause.putDone: + case <-time.After(5 * time.Second): + close(pause.resume) + t.Fatal("new blob was not staged") + } + if _, err := other.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner='alice'`); err != nil { + close(pause.resume) + t.Fatal(err) + } + close(pause.resume) + if err := <-result; !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("late update: got %v, want ErrAccountForbidden", err) + } + if err := w.DeleteArtifactAccountChecked("alice", art.ID, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled delete: got %v, want ErrAccountForbidden", err) + } + if got, err := st.ArtifactContent("alice", art.ID); err != nil || string(got) != "original" { + t.Fatalf("visible content after rejected update: %q, %v", got, err) + } + if got := artifactRef(t, st, art.ID); got != oldRef { + t.Fatalf("rejected update switched content ref: %q -> %q", oldRef, got) + } + files, err := os.ReadDir(st.DataDir()) + if err != nil || len(files) != 1 { + t.Fatalf("rejected update left staged blob: %d entries, %v", len(files), err) + } +} + +func TestArtifactAccountFenceImplicitLocalRejectsRecordedDisable(t *testing.T) { + st := openArtifactFenceStore(t, filepath.Join(t.TempDir(), "account-artifacts.db")) + w := artifactWriter(t, st) + art := core.Artifact{Owner: OwnerDefault, ProjectID: "project", Name: "local.txt", Kind: "data"} + if err := w.CreateArtifactAccountChecked(OwnerDefault, &art, []byte("embedded"), true); err != nil { + t.Fatalf("missing implicit local account should be effective: %v", err) + } + if err := st.EnsureUser(OwnerDefault, ""); err != nil { + t.Fatal(err) + } + if _, err := st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner=?`, OwnerDefault); err != nil { + t.Fatal(err) + } + if err := w.UpdateArtifactContentAccountChecked(OwnerDefault, art.ID, []byte("late"), true); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("recorded disabled local account update: got %v, want ErrAccountForbidden", err) + } + if got, err := st.ArtifactContent(OwnerDefault, art.ID); err != nil || string(got) != "embedded" { + t.Fatalf("disabled local account changed visible content: %q, %v", got, err) + } +} + +func TestArtifactAccountFenceUpdateAndDeleteKeepOwnerAndIndexIsolation(t *testing.T) { + st := openArtifactFenceStore(t, filepath.Join(t.TempDir(), "account-artifacts.db")) + w := artifactWriter(t, st) + for _, owner := range []string{"alice", "bob"} { + if err := st.EnsureUser(owner, ""); err != nil { + t.Fatal(err) + } + } + art := core.Artifact{Owner: "alice", ProjectID: "project", Name: "minutes.txt", Kind: "data"} + if err := w.CreateArtifactAccountChecked("alice", &art, []byte("original"), false); err != nil { + t.Fatal(err) + } + firstRef := artifactRef(t, st, art.ID) + if err := w.UpdateArtifactContentAccountChecked("bob", art.ID, []byte("foreign"), false); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign update: got %v, want ErrNotFound", err) + } + if err := w.DeleteArtifactAccountChecked("bob", art.ID, false); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign delete: got %v, want ErrNotFound", err) + } + if got, err := st.ArtifactContent("alice", art.ID); err != nil || string(got) != "original" { + t.Fatalf("foreign actor changed content: %q, %v", got, err) + } + if err := w.UpdateArtifactContentAccountChecked("alice", art.ID, []byte("replacement"), false); err != nil { + t.Fatal(err) + } + if got, err := st.ArtifactContent("alice", art.ID); err != nil || string(got) != "replacement" { + t.Fatalf("guarded update content: %q, %v", got, err) + } + if ref := artifactRef(t, st, art.ID); ref == firstRef { + t.Fatal("guarded update overwrote the visible blob ref") + } + if _, err := os.Stat(firstRef); !os.IsNotExist(err) { + t.Fatalf("old blob remains after successful switch: %v", err) + } + doc := core.KnowledgeDoc{ID: "knw-artifact-fence", Owner: "alice", Ref: art.ID, Source: "artifact", Title: "minutes", Chunk: "indexed content"} + if err := st.AddKnowledge(doc); err != nil { + t.Fatal(err) + } + if err := st.UpsertKnowledgeVectorExt(t.Context(), "alice", string(doc.ID), art.ID, "test", "hash", []float32{1, 0}); err != nil { + t.Fatal(err) + } + if err := w.DeleteArtifactAccountChecked("alice", art.ID, false); err != nil { + t.Fatal(err) + } + if _, err := st.GetArtifact("alice", art.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("deleted artifact still visible: %v", err) + } + for _, table := range []string{"knowledge", "knowledge_vectors"} { + var n int + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM `+table+` WHERE owner=? AND ref=?`, "alice", art.ID).Scan(&n); err != nil || n != 0 { + t.Fatalf("%s rows after delete: %d, %v", table, n, err) + } + } +} diff --git a/internal/store/audit.go b/internal/store/audit.go index b5479a8..37b7202 100644 --- a/internal/store/audit.go +++ b/internal/store/audit.go @@ -3,6 +3,8 @@ package store import ( "database/sql" "encoding/json" + "errors" + "fmt" "time" agentcommand "agent-command" @@ -12,17 +14,118 @@ import ( // owner 是租户身份(认证注入),与信封里的 Actor(发起主体)语义不同:查询按 // owner 过滤,跨租户审计不可见。 func (s *Store) AppendAudit(owner string, env agentcommand.AuditEnvelope) error { + if err := s.requireProjectAuditCommittedPrefix(); err != nil { + return err + } raw, err := json.Marshal(env) if err != nil { return err } - _, err = s.db.Exec(s.q(`INSERT INTO command_audit (time, command_id, source, entrance, state, operation_id, conversation_id, owner, payload) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`), + _, err = s.db.Exec(s.q(`INSERT INTO command_audit (time, command_id, source, entrance, state, operation_id, conversation_id, owner, payload, actor_name, project_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT name FROM users WHERE owner=?), ''), + COALESCE((SELECT project_id FROM conversations WHERE id=? AND owner=?), ''))`), env.Time.UTC().Format(time.RFC3339Nano), env.CommandID, string(env.Source), env.Entrance, string(env.State), - env.OperationID, env.ConversationID, owner, string(raw)) + env.OperationID, env.ConversationID, owner, string(raw), env.Actor, env.ConversationID, owner) return err } +// AppendAuditAccountChecked fences command result envelopes against account +// disable. Security-denial audit uses its separate account-state path. +func (s *Store) AppendAuditAccountChecked(owner string, env agentcommand.AuditEnvelope, allowImplicitDefault bool) error { + if err := s.requireProjectAuditCommittedPrefix(); err != nil { + return err + } + raw, err := json.Marshal(env) + if err != nil { + return err + } + return s.withEffectiveAccountWrite(owner, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + _, err := tx.Exec(s.q(`INSERT INTO command_audit (time, command_id, source, entrance, state, operation_id, conversation_id, owner, payload, actor_name, project_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT name FROM users WHERE owner=?), ''), + COALESCE((SELECT project_id FROM conversations WHERE id=? AND owner=?), ''))`), + env.Time.UTC().Format(time.RFC3339Nano), env.CommandID, string(env.Source), env.Entrance, string(env.State), + env.OperationID, env.ConversationID, owner, string(raw), env.Actor, env.ConversationID, owner) + return err + }) +} + +// ProjectCommandAuditRow is an internal command receipt. Callers must project +// only approved fields; the envelope contains a digest and is not a public DTO. +type ProjectCommandAuditRow struct { + Offset int64 + ActorName string // empty on old rows or when no trusted account name was recorded + Envelope agentcommand.AuditEnvelope +} + +// ErrAuditCutoffUnsafe prevents a PostgreSQL page from claiming a fixed cutoff +// when the ID generator, sequence, or all-writer database guard cannot prove +// commit order. +var ErrAuditCutoffUnsafe = errors.New("store: project audit cutoff unsafe") + +func (s *Store) requireProjectAuditCommittedPrefix() error { + if s.dialect.name() != "postgres" { + return nil + } // SQLite serializes writers. + var safe bool + if err := s.db.QueryRow(`SELECT project_audit_cutoff_safe('command_audit'::regclass) AND project_audit_writers_guarded('command_audit'::regclass)`).Scan(&safe); err != nil { + return fmt.Errorf("%w: %v", ErrAuditCutoffUnsafe, err) + } + if !safe { + return ErrAuditCutoffUnsafe + } + return nil +} + +// ListProjectCommandAudits reads the project resolved from a trusted conversation +// at append time. Reassigning or deleting that conversation cannot rewrite the +// original project. Legacy rows without this snapshot are excluded: current +// project membership cannot establish their historical project attribution. +// Only the authenticated owner can read this first private slice. +func (s *Store) ListProjectCommandAudits(actorOwner, projectID string, before, cutoff int64, limit int) ([]ProjectCommandAuditRow, int64, int64, error) { + if actorOwner == "" || projectID == "" || before < 0 || cutoff < 0 || (before != 0 && cutoff == 0) { + return nil, 0, 0, errors.New("project audit: invalid scope or cursor") + } + if err := s.requireProjectAuditCommittedPrefix(); err != nil { + return nil, 0, 0, err + } + if limit <= 0 || limit > 200 { + limit = 50 + } + if cutoff == 0 { + if err := s.db.QueryRow(s.q(`SELECT COALESCE(MAX(id),0) FROM command_audit + WHERE project_id=? AND owner=?`), projectID, actorOwner).Scan(&cutoff); err != nil { + return nil, 0, 0, err + } + } + rows, err := s.db.Query(s.q(`SELECT id, actor_name, payload FROM command_audit + WHERE project_id=? AND owner=? AND id<=? AND (?=0 OR id` 仅此一次返回。 func (s *Store) CreateAPIKey(owner, name string) (string, error) { - if err := s.EnsureUser(owner, ""); err != nil { - return "", err - } raw := make([]byte, 16) if _, err := rand.Read(raw); err != nil { return "", err @@ -92,11 +89,50 @@ func (s *Store) CreateAPIKey(owner, name string) (string, error) { if name == "" { name = "key-" + nowISO()[:10] } - _, err := s.db.Exec(s.q(`INSERT INTO api_keys(key_hash,owner,name,created_at,revoked) VALUES(?,?,?,?,0)`), - hashKey(plain), owner, name, nowISO()) + tx, err := s.db.Begin() if err != nil { return "", err } + defer tx.Rollback() + if err := s.dialect.lockAccount(tx, owner); err != nil { + return "", err + } + // The account row lock and key insert share a transaction with disable. + // The version predicate also protects the admission decision if the row + // changes between the read and the insert on a different dialect. + var stateVersion int64 + var enabled bool + var initializationState string + query := s.q(`SELECT state_version,enabled,initialization_state FROM users WHERE owner=?`) + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + err = tx.QueryRow(query, owner).Scan(&stateVersion, &enabled, &initializationState) + if errors.Is(err, sql.ErrNoRows) { + return "", ErrAccountForbidden + } + if err != nil { + return "", err + } + if !enabled || (initializationState != InitializationInitialized && initializationState != InitializationLegacy) { + return "", ErrAccountForbidden + } + result, err := tx.Exec(s.q(`INSERT INTO api_keys(key_hash,owner,name,created_at,revoked) + SELECT ?,owner,?,?,? FROM users WHERE owner=? AND state_version=? AND enabled=? + AND initialization_state IN (?,?)`), + hashKey(plain), name, nowISO(), 0, owner, stateVersion, true, + InitializationInitialized, InitializationLegacy) + if err != nil { + return "", err + } + if n, err := result.RowsAffected(); err != nil { + return "", err + } else if n != 1 { + return "", ErrAccountForbidden + } + if err := tx.Commit(); err != nil { + return "", err + } return plain, nil } @@ -142,7 +178,8 @@ func (s *Store) AuthOwner(plainKey string) (string, bool) { } var owner string var revoked int - err := s.db.QueryRow(s.q(`SELECT owner,revoked FROM api_keys WHERE key_hash=?`), hashKey(plainKey)).Scan(&owner, &revoked) + err := s.db.QueryRow(s.q(`SELECT k.owner,k.revoked FROM api_keys k JOIN users u ON u.owner=k.owner + WHERE k.key_hash=? AND u.enabled=?`), hashKey(plainKey), true).Scan(&owner, &revoked) if err != nil || revoked == 1 { return "", false } @@ -158,13 +195,20 @@ const sessionTTL = 7 * 24 * time.Hour // AdminUser 管理员用户列表的展示视图(不含哈希)。 type AdminUser struct { - Owner string `json:"owner"` - Name string `json:"name"` - IsAdmin bool `json:"isAdmin"` - MustChangePassword bool `json:"mustChangePassword"` - HasPassword bool `json:"hasPassword"` - PasswordSetAt *time.Time `json:"passwordSetAt,omitempty"` - CreatedAt time.Time `json:"createdAt"` + ID string `json:"userId"` + Owner string `json:"owner"` + Name string `json:"name"` + Enabled bool `json:"enabled"` + StateVersion int64 `json:"stateVersion"` + ProfileVersion int64 `json:"profileVersion"` + InitializationState string `json:"initializationState"` + InitializationGeneration int64 `json:"initializationGeneration"` + IsAdmin bool `json:"isAdmin"` + IsSuperAdmin bool `json:"isSuperAdmin"` + MustChangePassword bool `json:"mustChangePassword"` + HasPassword bool `json:"hasPassword"` + PasswordSetAt *time.Time `json:"passwordSetAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` } // HashPassword 生成 argon2id PHC 串(m=64MiB,t=2,p=2;随机盐)。 @@ -223,33 +267,16 @@ func VerifyPassword(hash, password string) bool { // (原子化:旧实现先查后插再 UPDATE,两个并发 create 同名会在 UPDATE 步互相覆盖 // 角色与密码——R5/KTD12 并发建号不覆盖。) func (s *Store) CreateAccount(owner, name, passwordHash string, isAdmin bool) error { - mustChange := passwordHash != "" - setAt := "" - if passwordHash != "" { - setAt = nowISO() - } - // 布尔列一律绑 Go bool 参数、不写 1/0 字面量:SQLite 两侧都容忍, - // PostgreSQL 对 boolean 列收到 integer 会 42804(2026-10-05 user create 半失败根因)。 - _, err := s.db.Exec(s.q(`INSERT INTO users(id,owner,name,created_at,password_hash,password_set_at,must_change_password,is_admin) - VALUES(?,?,?,?,?,?,?,?)`), - newID("usr"), owner, name, nowISO(), passwordHash, setAt, mustChange, isAdmin) - if isUniqueViolation(err) { - return ErrConflict - } + _, err := s.CreateActivatedAccount(AccountWrite{ + Owner: owner, Name: name, PasswordHash: passwordHash, IsAdmin: isAdmin, + Action: "account.create", + }) return err } // SetUserPassword 设置/重置密码:must_change=1(首登强制改密)。 func (s *Store) SetUserPassword(owner, passwordHash string) error { - res, err := s.db.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=? WHERE owner=?`), - passwordHash, nowISO(), true, owner) - if err != nil { - return err - } - if n, _ := res.RowsAffected(); n == 0 { - return ErrNotFound - } - return nil + return s.AdminResetUserPassword(owner, passwordHash, "legacy-store", "legacy-store") } // ClearMustChangePassword 改密完成后清标志(改密者会话保留,其余吊销由调用方处理)。 @@ -260,28 +287,34 @@ func (s *Store) ClearMustChangePassword(owner string) error { // UserAuthState 登录与身份视图。 type UserAuthState struct { - Owner string - Name string - IsAdmin bool - MustChangePassword bool - HasPassword bool + UserID string + Owner string + Name string + IsAdmin bool + IsSuperAdmin bool + MustChangePassword bool + HasPassword bool + Enabled bool + StateVersion int64 + AuthVersion int + CredentialOrigin CredentialOrigin + InitializationState string + InitializationGeneration int64 } // GetUserAuthState 按 owner 取认证态(含会话/APIOwner 路径共用)。 func (s *Store) GetUserAuthState(owner string) (UserAuthState, error) { - var st UserAuthState - var hash, ca string - // 直接扫进 bool:PG 返回原生 boolean;SQLite 整数 0/1 经 driver.Bool 转换同样成立。 - err := s.db.QueryRow(s.q(`SELECT owner,COALESCE(name,''),password_hash,must_change_password,is_admin,created_at FROM users WHERE owner=?`), owner). - Scan(&st.Owner, &st.Name, &hash, &st.MustChangePassword, &st.IsAdmin, &ca) - if errors.Is(err, sql.ErrNoRows) { - return st, ErrNotFound - } + a, err := s.GetAccountByOwner(owner) if err != nil { - return st, err + return UserAuthState{}, err } - st.HasPassword = hash != "" - return st, nil + return UserAuthState{ + UserID: a.ID, Owner: a.Owner, Name: a.Profile.Name, IsAdmin: a.IsAdmin, IsSuperAdmin: a.IsSuperAdmin, + MustChangePassword: a.MustChangePassword, HasPassword: a.HasPassword, + Enabled: a.Enabled, StateVersion: a.StateVersion, AuthVersion: a.AuthVersion, + CredentialOrigin: a.CredentialOrigin, InitializationState: a.InitializationState, + InitializationGeneration: a.InitializationGeneration, + }, nil } // VerifyLogin 校验用户名密码。 @@ -294,10 +327,32 @@ func (s *Store) VerifyLogin(owner, password string) (UserAuthState, bool) { // 时必须以 CreateSessionVerified 传回该版本,堵「验证在途 → 恢复/改密完成 → // 旧请求又建会话」的竞态窗口。 func (s *Store) VerifyLoginWithVersion(owner, password string) (UserAuthState, int, bool) { + return s.VerifyLoginWithFallback(owner, password, nil) +} + +// FallbackCredential reports whether password equals the in-memory fallback +// credential configured for owner (KTD2). Implementations must compare in +// constant time and must never persist or log the secret. +type FallbackCredential func(owner, password string) bool + +// verifyAccountCredential checks the database hash first, then the fallback. +// The fallback is honoured only for an account carrying the persisted +// super-admin marker, so it can never authenticate any other account. +func verifyAccountCredential(hash string, isSuperAdmin bool, owner, password string, fallback FallbackCredential) bool { + if hash != "" && VerifyPassword(hash, password) { + return true + } + return isSuperAdmin && fallback != nil && fallback(owner, password) +} + +// VerifyLoginWithFallback 同 VerifyLoginWithVersion,另对已启用且带超级管理员标记的 +// 账户接受 fallback 凭据(R6);数据库密码优先。返回的认证版本语义不变。 +func (s *Store) VerifyLoginWithFallback(owner, password string, fallback FallbackCredential) (UserAuthState, int, bool) { var hash string var ver int - err := s.db.QueryRow(s.q(`SELECT password_hash,auth_version FROM users WHERE owner=?`), owner).Scan(&hash, &ver) - if err != nil || hash == "" || !VerifyPassword(hash, password) { + var isSuperAdmin bool + err := s.db.QueryRow(s.q(`SELECT password_hash,auth_version,is_super_admin FROM users WHERE owner=? AND enabled=?`), owner, true).Scan(&hash, &ver, &isSuperAdmin) + if err != nil || !verifyAccountCredential(hash, isSuperAdmin, owner, password, fallback) { return UserAuthState{}, 0, false } st, err := s.GetUserAuthState(owner) @@ -322,17 +377,40 @@ func (s *Store) CreateSessionScoped(owner, scope string, ttl time.Duration) (str if ttl <= 0 { ttl = sessionTTL } - ver := 0 - _ = s.db.QueryRow(s.q(`SELECT auth_version FROM users WHERE owner=?`), owner).Scan(&ver) raw := make([]byte, 32) if _, err := rand.Read(raw); err != nil { return "", err } token := "sess_" + hex.EncodeToString(raw) exp := time.Now().Add(ttl) - _, err := s.db.Exec(s.q(`INSERT INTO sessions(token_hash,owner,created_at,expires_at,last_seen_at,scope,auth_version) VALUES(?,?,?,?,?,?,?)`), + tx, err := s.db.Begin() + if err != nil { + return "", err + } + defer tx.Rollback() + if err := s.dialect.lockAccount(tx, owner); err != nil { + return "", err + } + var ver int + var enabled bool + query := s.q(`SELECT auth_version,enabled FROM users WHERE owner=?`) + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + if err := tx.QueryRow(query, owner).Scan(&ver, &enabled); errors.Is(err, sql.ErrNoRows) { + return "", ErrNotFound + } else if err != nil { + return "", err + } + if !enabled { + return "", ErrAccountForbidden + } + _, err = tx.Exec(s.q(`INSERT INTO sessions(token_hash,owner,created_at,expires_at,last_seen_at,scope,auth_version) VALUES(?,?,?,?,?,?,?)`), hashKey(token), owner, nowISO(), exp.UTC().Format(time.RFC3339Nano), nowISO(), scope, ver) - return token, err + if err != nil { + return "", err + } + return token, tx.Commit() } // CreateSessionVerified 建会话并绑定期望认证版本:事务内重读账户版本,与密码验证 @@ -355,19 +433,85 @@ func (s *Store) CreateSessionVerified(owner, scope string, ttl time.Duration, ex return "", err } defer tx.Rollback() + if err := s.dialect.lockAccount(tx, owner); err != nil { + return "", err + } var ver int - err = tx.QueryRow(s.q(`SELECT auth_version FROM users WHERE owner=?`), owner).Scan(&ver) + var enabled bool + var origin CredentialOrigin + versionQuery := s.q(`SELECT auth_version,enabled,credential_origin FROM users WHERE owner=?`) + if s.Dialect() == "postgres" { + versionQuery += ` FOR UPDATE` + } + err = tx.QueryRow(versionQuery, owner).Scan(&ver, &enabled, &origin) if errors.Is(err, sql.ErrNoRows) { return "", ErrNotFound } if err != nil { return "", err } - if ver != expectVersion { + if !enabled || ver != expectVersion { return "", ErrConflict } - if _, err := tx.Exec(s.q(`INSERT INTO sessions(token_hash,owner,created_at,expires_at,last_seen_at,scope,auth_version) VALUES(?,?,?,?,?,?,?)`), - hashKey(token), owner, nowISO(), exp.UTC().Format(time.RFC3339Nano), nowISO(), scope, ver); err != nil { + if _, err := tx.Exec(s.q(`INSERT INTO sessions(token_hash,owner,created_at,expires_at,last_seen_at,scope,auth_version,auth_method,credential_origin) + VALUES(?,?,?,?,?,?,?,?,?)`), + hashKey(token), owner, nowISO(), exp.UTC().Format(time.RFC3339Nano), nowISO(), scope, ver, AuthMethodPassword, origin); err != nil { + return "", err + } + return token, tx.Commit() +} + +// CreateSessionVerifiedWithPasswordLoginCleanup creates a password session +// bound to expectVersion and atomically applies post-login OTP cleanup. +func (s *Store) CreateSessionVerifiedWithPasswordLoginCleanup(owner, scope string, ttl time.Duration, expectVersion int) (string, error) { + if scope == "" { + scope = SessionScopeFull + } + if ttl <= 0 { + ttl = sessionTTL + } + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", err + } + token := "sess_" + hex.EncodeToString(raw) + exp := time.Now().Add(ttl) + tx, err := s.db.Begin() + if err != nil { + return "", err + } + defer tx.Rollback() + if err := s.dialect.lockAccount(tx, owner); err != nil { + return "", err + } + var ver int + var enabled bool + var origin CredentialOrigin + versionQuery := s.q(`SELECT auth_version,enabled,credential_origin FROM users WHERE owner=?`) + if s.Dialect() == "postgres" { + versionQuery += ` FOR UPDATE` + } + err = tx.QueryRow(versionQuery, owner).Scan(&ver, &enabled, &origin) + if errors.Is(err, sql.ErrNoRows) { + return "", ErrNotFound + } + if err != nil { + return "", err + } + if !enabled || ver != expectVersion { + return "", ErrConflict + } + if _, err := tx.Exec(s.q(`INSERT INTO sessions(token_hash,owner,created_at,expires_at,last_seen_at,scope,auth_version,auth_method,credential_origin) + VALUES(?,?,?,?,?,?,?,?,?)`), + hashKey(token), owner, nowISO(), exp.UTC().Format(time.RFC3339Nano), nowISO(), scope, ver, AuthMethodPassword, origin); err != nil { + return "", err + } + if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND purpose=? AND status=?`), + LoginKeyStatusRevoked, owner, LoginKeyPurposeOneTimePassword, LoginKeyStatusUnused); err != nil { + return "", err + } + if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=? AND auth_method=? AND scope=?`), + owner, AuthMethodOneTimePassword, SessionScopeSetPasswordOnly); err != nil { return "", err } return token, tx.Commit() @@ -379,17 +523,39 @@ func (s *Store) SessionOwner(token string) (string, bool) { return owner, ok } +// SessionAuthMethod reports persisted authentication lineage for an already +// checked cookie. Callers must still validate current scope/version/effective +// state with SessionInfo before granting any capability. +func (s *Store) SessionAuthMethod(token string) (string, error) { + if token == "" { + return "", ErrNotFound + } + var method string + err := s.db.QueryRow(s.q(`SELECT auth_method FROM sessions WHERE token_hash=?`), hashKey(token)).Scan(&method) + if errors.Is(err, sql.ErrNoRows) { + return "", ErrNotFound + } + return method, err +} + // SessionInfo 返回 owner 与 scope。 func (s *Store) SessionInfo(token string) (owner, scope string, ok bool) { if token == "" { return "", "", false } var exp string - err := s.db.QueryRow(s.q(`SELECT owner,expires_at,COALESCE(scope,'full') FROM sessions WHERE token_hash=?`), hashKey(token)). - Scan(&owner, &exp, &scope) + var sessionVersion, accountVersion int + var enabled bool + err := s.db.QueryRow(s.q(`SELECT s.owner,s.expires_at,COALESCE(s.scope,'full'),s.auth_version,u.auth_version,u.enabled + FROM sessions s JOIN users u ON u.owner=s.owner WHERE s.token_hash=?`), hashKey(token)). + Scan(&owner, &exp, &scope, &sessionVersion, &accountVersion, &enabled) if err != nil { return "", "", false } + if !enabled || sessionVersion != accountVersion { + _, _ = s.db.Exec(s.q(`DELETE FROM sessions WHERE token_hash=? AND auth_version=?`), hashKey(token), sessionVersion) + return "", "", false + } if expT, err := time.Parse(time.RFC3339Nano, exp); err != nil || time.Now().After(expT) { s.db.Exec(s.q(`DELETE FROM sessions WHERE token_hash=?`), hashKey(token)) return "", "", false @@ -417,7 +583,8 @@ func (s *Store) DeleteUserSessions(owner, exceptToken string) { // ListUsersAdmin 管理员用户列表(按创建序)。 func (s *Store) ListUsersAdmin() ([]AdminUser, error) { - rows, err := s.db.Query(s.q(`SELECT owner,COALESCE(name,''),password_hash,must_change_password,is_admin,password_set_at,created_at FROM users ORDER BY created_at`)) + rows, err := s.db.Query(s.q(`SELECT id,owner,COALESCE(name,''),enabled,state_version,profile_version,initialization_state,initialization_generation, + password_hash,must_change_password,is_admin,is_super_admin,password_set_at,created_at FROM users ORDER BY created_at`)) if err != nil { return nil, err } @@ -426,7 +593,8 @@ func (s *Store) ListUsersAdmin() ([]AdminUser, error) { for rows.Next() { var u AdminUser var hash, psa, ca string - if err := rows.Scan(&u.Owner, &u.Name, &hash, &u.MustChangePassword, &u.IsAdmin, &psa, &ca); err != nil { + if err := rows.Scan(&u.ID, &u.Owner, &u.Name, &u.Enabled, &u.StateVersion, &u.ProfileVersion, + &u.InitializationState, &u.InitializationGeneration, &hash, &u.MustChangePassword, &u.IsAdmin, &u.IsSuperAdmin, &psa, &ca); err != nil { return nil, err } u.HasPassword = hash != "" diff --git a/internal/store/auth_account_key_test.go b/internal/store/auth_account_key_test.go new file mode 100644 index 0000000..90ffcc2 --- /dev/null +++ b/internal/store/auth_account_key_test.go @@ -0,0 +1,132 @@ +package store + +import ( + "errors" + "testing" + "time" +) + +func TestCreateAPIKeyRequiresExistingEnabledReadyAccount(t *testing.T) { + st := openTestStore(t) + t.Logf("API key account admission dialect=%s", st.Dialect()) + if err := st.CreateAccount("admin", "", "admin-hash", true); err != nil { + t.Fatal(err) + } + if _, err := st.CreatePendingAccount("admin", "pending", AccountProfile{}); err != nil { + t.Fatal(err) + } + if err := st.EnsureUser("legacy", ""); err != nil { + t.Fatal(err) + } + if err := st.EnsureUser("disabled", ""); err != nil { + t.Fatal(err) + } + disabled, err := st.GetAccountByOwner("disabled") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", disabled.ID, disabled.StateVersion, false); err != nil { + t.Fatal(err) + } + ready, err := st.CreatePendingAccount("admin", "ready", AccountProfile{}) + if err != nil { + t.Fatal(err) + } + grant, err := st.InitializePendingAccount(ready.ID, ready.StateVersion, ready.InitializationGeneration) + if err != nil { + t.Fatal(err) + } + session, _, err := st.RedeemLoginKey(grant) + if err != nil { + t.Fatal(err) + } + hash, err := HashPassword("ready-password") + if err != nil { + t.Fatal(err) + } + if _, err := st.CompleteSetPasswordChecked(session, hash); err != nil { + t.Fatal(err) + } + + var usersBefore, keysBefore int + if err := st.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&usersBefore); err != nil { + t.Fatal(err) + } + if err := st.db.QueryRow(`SELECT COUNT(*) FROM api_keys`).Scan(&keysBefore); err != nil { + t.Fatal(err) + } + for _, owner := range []string{"missing", "pending", "disabled"} { + if key, err := st.CreateAPIKey(owner, "rejected"); key != "" || !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("%s received an API key: key=%q err=%v", owner, key, err) + } + var usersAfter, keysAfter int + if err := st.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&usersAfter); err != nil { + t.Fatal(err) + } + if err := st.db.QueryRow(`SELECT COUNT(*) FROM api_keys`).Scan(&keysAfter); err != nil { + t.Fatal(err) + } + if usersAfter != usersBefore || keysAfter != keysBefore { + t.Fatalf("%s rejection changed rows: users %d->%d keys %d->%d", owner, usersBefore, usersAfter, keysBefore, keysAfter) + } + } + for _, owner := range []string{"legacy", "ready"} { + key, err := st.CreateAPIKey(owner, owner+"-integration") + if err != nil || key == "" { + t.Fatalf("%s could not receive an API key: %v", owner, err) + } + if got, ok := st.AuthOwner(key); !ok || got != owner { + t.Fatalf("%s key authenticated as %q, ok=%v", owner, got, ok) + } + } +} + +func TestPGCreateAPIKeyAndDisableSerialize(t *testing.T) { + st := openTestStore(t) + if st.Dialect() != "postgres" { + t.Skip("real PostgreSQL required") + } + other := reopenAccountModelStore(t, st) + if err := st.CreateAccount("admin", "", "admin-hash", true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("owner", "", "", false); err != nil { + t.Fatal(err) + } + owner, err := st.GetAccountByOwner("owner") + if err != nil { + t.Fatal(err) + } + release := holdPGAccountWrite(t, st, "api_keys", "INSERT", "api_key") + type keyResult struct { + key string + err error + } + keyDone := make(chan keyResult, 1) + go func() { + key, err := st.CreateAPIKey("owner", "racing") + keyDone <- keyResult{key, err} + }() + waitForPGAccountWrite(t, st, "api_key") + disableDone := make(chan error, 1) + go func() { + _, err := other.SetAccountEnabled("admin", owner.ID, owner.StateVersion, false) + disableDone <- err + }() + select { + case err := <-disableDone: + t.Fatalf("disable committed ahead of the in-flight key issuance: %v", err) + case <-time.After(100 * time.Millisecond): + } + release() + issued := awaitPGAccountResult(t, keyDone) + if issued.err != nil || issued.key == "" { + t.Fatalf("in-flight issuance failed: %v", issued.err) + } + if err := awaitPGAccountResult(t, disableDone); err != nil { + t.Fatal(err) + } + if _, ok := st.AuthOwner(issued.key); ok { + t.Fatal("key issued before disable survived the disabling transaction") + } +} diff --git a/internal/store/borrow.go b/internal/store/borrow.go index 2eea5cc..26ac83e 100644 --- a/internal/store/borrow.go +++ b/internal/store/borrow.go @@ -163,6 +163,90 @@ func (s *Store) DeleteSecret(owner, id string) error { return nil } +// AddSecretAccountChecked fences the final secret write against account disable. +func (s *Store) AddSecretAccountChecked(actor, service, label, value string, allowImplicitDefault bool) (Secret, error) { + service, label, value = strings.TrimSpace(service), strings.TrimSpace(label), strings.TrimSpace(value) + if service == "" || value == "" { + return Secret{}, fmt.Errorf("store: secret service and value are required") + } + if label == "" { + label = "未命名" + } + sec := Secret{ID: newID("key"), Service: service, Label: label, Active: true, CreatedAt: nowISO(), Mask: maskSecret(value)} + err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + if _, err := tx.Exec(s.q(`UPDATE secret_keys SET active=0 WHERE owner=? AND service=?`), actor, service); err != nil { + return err + } + _, err := tx.Exec(s.q(`INSERT INTO secret_keys(id,owner,service,label,value,active,created_at) VALUES(?,?,?,?,?,?,?)`), + sec.ID, actor, service, label, value, 1, sec.CreatedAt) + return err + }) + if err != nil { + return Secret{}, err + } + return sec, nil +} + +func (s *Store) ActivateSecretAccountChecked(actor, id string, allowImplicitDefault bool) error { + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + var service string + err := tx.QueryRow(s.q(`SELECT service FROM secret_keys WHERE id=? AND owner=?`), id, actor).Scan(&service) + if err == sql.ErrNoRows { + return ErrNotFound + } + if err != nil { + return err + } + if _, err := tx.Exec(s.q(`UPDATE secret_keys SET active=0 WHERE owner=? AND service=?`), actor, service); err != nil { + return err + } + res, err := tx.Exec(s.q(`UPDATE secret_keys SET active=1 WHERE id=? AND owner=?`), id, actor) + if err != nil { + return err + } + if n, err := res.RowsAffected(); err != nil { + return err + } else if n != 1 { + return ErrNotFound + } + return nil + }) +} + +func (s *Store) RenameSecretAccountChecked(actor, id, label string, allowImplicitDefault bool) error { + label = strings.TrimSpace(label) + if label == "" { + return fmt.Errorf("store: label required") + } + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + res, err := tx.Exec(s.q(`UPDATE secret_keys SET label=? WHERE id=? AND owner=?`), label, id, actor) + if err != nil { + return err + } + if n, err := res.RowsAffected(); err != nil { + return err + } else if n != 1 { + return ErrNotFound + } + return nil + }) +} + +func (s *Store) DeleteSecretAccountChecked(actor, id string, allowImplicitDefault bool) error { + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + res, err := tx.Exec(s.q(`DELETE FROM secret_keys WHERE id=? AND owner=?`), id, actor) + if err != nil { + return err + } + if n, err := res.RowsAffected(); err != nil { + return err + } else if n != 1 { + return ErrNotFound + } + return nil + }) +} + func (s *Store) HasSecret(owner, service string) (bool, error) { var n int err := s.db.QueryRow(s.q(`SELECT COUNT(1) FROM secret_keys WHERE owner=? AND service=?`), owner, service).Scan(&n) @@ -184,6 +268,36 @@ func (s *Store) SaveSnapshotIfChanged(owner, scope, body, reason string) (bool, return err == nil, err } +// SaveSnapshotIfChangedAccountChecked fences the optional post-merge snapshot +// in its own final transaction. A disable between the settings commit and this +// transaction may suppress the snapshot, but cannot admit a late write. +func (s *Store) SaveSnapshotIfChangedAccountChecked(actor, owner, scope, body, reason string, allowImplicitDefault bool) (bool, error) { + var saved bool + err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, account Account) error { + if err := s.dialect.lockScope(tx, owner, scope); err != nil { + return err + } + if err := s.checkSettingsScopeWriteActor(tx, actor, owner, scope, account, allowImplicitDefault); err != nil { + return err + } + var prev string + err := tx.QueryRow(s.q(`SELECT body FROM settings_snapshots WHERE owner=? AND scope=? ORDER BY created_at DESC LIMIT 1`), owner, scope).Scan(&prev) + if err == nil && prev == body { + return nil + } + if err != nil && err != sql.ErrNoRows { + return err + } + _, err = tx.Exec(s.q(`INSERT INTO settings_snapshots(id,owner,scope,body,reason,created_at) VALUES(?,?,?,?,?,?)`), + newID("snap"), owner, scope, body, reason, nowISO()) + if err == nil { + saved = true + } + return err + }) + return saved && err == nil, err +} + // LatestSnapshotAge 返回最近一份快照的时间。没有快照时 ok 为 false。 func (s *Store) LatestSnapshotAt(owner, scope string) (time.Time, bool) { var raw string @@ -280,6 +394,13 @@ func (s *Store) TakeRegenPending(conversationID string) (group string, index int return group, index, true } +// PeekRegenPending leaves the marker in place until settlement commits its +// assistant message, avoiding a disable/fence between consumption and settle. +func (s *Store) PeekRegenPending(conversationID string) (group string, index int, ok bool) { + err := s.db.QueryRow(s.q(`SELECT variant_group, variant_index FROM regen_pending WHERE conversation_id=?`), conversationID).Scan(&group, &index) + return group, index, err == nil +} + func (s *Store) ListContextEntries(owner, projectID, conversationID string) ([]ContextEntry, error) { rows, err := s.db.Query(s.q(`SELECT id,project_id,conversation_id,keys_json,secondary,logic,content,constant,scan_depth,sort_order,budget_chars,enabled FROM context_entries WHERE owner=? ORDER BY sort_order, created_at`), owner) @@ -378,6 +499,72 @@ func (s *Store) DeleteContextEntry(owner, id string) error { return nil } +func (s *Store) SaveContextEntryAccountChecked(actor string, e ContextEntry, allowImplicitDefault bool) (ContextEntry, error) { + if strings.TrimSpace(e.Content) == "" { + return ContextEntry{}, fmt.Errorf("store: entry content required") + } + if e.Logic != "and" && e.Logic != "or" && e.Logic != "not" { + e.Logic = "and" + } + if e.ScanDepth <= 0 { + e.ScanDepth = 4 + } + if e.BudgetChars <= 0 { + e.BudgetChars = 2000 + } + raw, _ := json.Marshal(e.Keys) + constant, enabled := 0, 0 + if e.Constant { + constant = 1 + } + if e.Enabled || e.ID == "" { + enabled = 1 + e.Enabled = true + } + creating := e.ID == "" + if creating { + e.ID = newID("ctx") + } + err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + if creating { + _, err := tx.Exec(s.q(`INSERT INTO context_entries(id,owner,project_id,conversation_id,keys_json,secondary,logic,content,constant,scan_depth,sort_order,budget_chars,enabled,created_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`), + e.ID, actor, e.ProjectID, e.ConversationID, string(raw), e.Secondary, e.Logic, e.Content, constant, e.ScanDepth, e.SortOrder, e.BudgetChars, enabled, nowISO()) + return err + } + res, err := tx.Exec(s.q(`UPDATE context_entries SET project_id=?, conversation_id=?, keys_json=?, secondary=?, logic=?, content=?, constant=?, scan_depth=?, sort_order=?, budget_chars=?, enabled=? WHERE id=? AND owner=?`), + e.ProjectID, e.ConversationID, string(raw), e.Secondary, e.Logic, e.Content, constant, e.ScanDepth, e.SortOrder, e.BudgetChars, enabled, e.ID, actor) + if err != nil { + return err + } + if n, err := res.RowsAffected(); err != nil { + return err + } else if n != 1 { + return ErrNotFound + } + return nil + }) + if err != nil { + return ContextEntry{}, err + } + return e, nil +} + +func (s *Store) DeleteContextEntryAccountChecked(actor, id string, allowImplicitDefault bool) error { + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + res, err := tx.Exec(s.q(`DELETE FROM context_entries WHERE id=? AND owner=?`), id, actor) + if err != nil { + return err + } + if n, err := res.RowsAffected(); err != nil { + return err + } else if n != 1 { + return ErrNotFound + } + return nil + }) +} + func (s *Store) ListUserCommands(owner string) ([]UserCommand, error) { rows, err := s.db.Query(s.q(`SELECT id,name,title,template,created_at FROM user_commands WHERE owner=? ORDER BY name`), owner) if err != nil { @@ -436,6 +623,85 @@ func (s *Store) DeleteUserCommand(owner, id string) error { return nil } +func (s *Store) SaveUserCommandAccountChecked(actor string, c UserCommand, allowImplicitDefault bool) (UserCommand, error) { + c.Name = strings.TrimSpace(c.Name) + if !strings.HasPrefix(c.Name, "/") { + c.Name = "/" + c.Name + } + if c.Title == "" { + c.Title = c.Name + } + if strings.TrimSpace(c.Template) == "" { + return UserCommand{}, fmt.Errorf("store: command template required") + } + creating := c.ID == "" + if creating { + c.ID = newID("cmd") + c.CreatedAt = nowISO() + } + err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + if creating { + _, err := tx.Exec(s.q(`INSERT INTO user_commands(id,owner,name,title,template,created_at) VALUES(?,?,?,?,?,?)`), + c.ID, actor, c.Name, c.Title, c.Template, c.CreatedAt) + return err + } + res, err := tx.Exec(s.q(`UPDATE user_commands SET name=?, title=?, template=? WHERE id=? AND owner=?`), c.Name, c.Title, c.Template, c.ID, actor) + if err != nil { + return err + } + if n, err := res.RowsAffected(); err != nil { + return err + } else if n != 1 { + return ErrNotFound + } + return nil + }) + if err != nil { + return UserCommand{}, err + } + return c, nil +} + +// DeleteUserCommandAccountChecked returns the deleted name from the guarded +// transaction so the caller can unregister that exact command from its Bus. +func (s *Store) DeleteUserCommandAccountChecked(actor, id string, allowImplicitDefault bool) (UserCommand, error) { + var deleted UserCommand + err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + query := s.q(`SELECT id,name,title,template,created_at FROM user_commands WHERE id=? AND owner=?`) + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + err := tx.QueryRow(query, id, actor).Scan(&deleted.ID, &deleted.Name, &deleted.Title, &deleted.Template, &deleted.CreatedAt) + if err == sql.ErrNoRows { + return ErrNotFound + } + if err != nil { + return err + } + res, err := tx.Exec(s.q(`DELETE FROM user_commands WHERE id=? AND owner=?`), id, actor) + if err != nil { + return err + } + if n, err := res.RowsAffected(); err != nil { + return err + } else if n != 1 { + return ErrNotFound + } + return nil + }) + if err != nil { + return UserCommand{}, err + } + return deleted, nil +} + +// CheckEffectiveAccountForExternalEffect is a final state check immediately +// before a Bus mutation. The Bus is outside the account database transaction: +// disable can still commit between this check and the in-memory effect. +func (s *Store) CheckEffectiveAccountForExternalEffect(actor string, allowImplicitDefault bool) error { + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(*sql.Tx, Account) error { return nil }) +} + // MessageByID 读取一条消息。不存在时返回 ErrNotFound。 func (s *Store) MessageByID(id string) (core.Message, error) { var m core.Message diff --git a/internal/store/borrow_account_write_test.go b/internal/store/borrow_account_write_test.go new file mode 100644 index 0000000..e42cae4 --- /dev/null +++ b/internal/store/borrow_account_write_test.go @@ -0,0 +1,202 @@ +package store + +import ( + "errors" + "testing" + "time" +) + +func TestBorrowAccountCheckedMutationsRespectStateAndOwner(t *testing.T) { + st := openTestStore(t) + for _, a := range []struct { + owner string + admin bool + }{{"admin", true}, {"alice", false}, {"bob", false}} { + if err := st.CreateAccount(a.owner, "", "", a.admin); err != nil { + t.Fatal(err) + } + } + secret, err := st.AddSecretAccountChecked("alice", "model", "first", "top-secret", false) + if err != nil || secret.ID == "" { + t.Fatalf("active secret create: %+v %v", secret, err) + } + second, err := st.AddSecretAccountChecked("alice", "model", "second", "new-secret", false) + if err != nil { + t.Fatal(err) + } + if err := st.ActivateSecretAccountChecked("alice", secret.ID, false); err != nil { + t.Fatal(err) + } + if value, ok := st.ActiveSecret("alice", "model"); !ok || value != "top-secret" { + t.Fatalf("activation changed wrong service: %q %v", value, ok) + } + if err := st.RenameSecretAccountChecked("alice", secret.ID, "renamed", false); err != nil { + t.Fatal(err) + } + entry, err := st.SaveContextEntryAccountChecked("alice", ContextEntry{Content: "before"}, false) + if err != nil { + t.Fatal(err) + } + entry.Content = "after" + if _, err := st.SaveContextEntryAccountChecked("alice", entry, false); err != nil { + t.Fatal(err) + } + command, err := st.SaveUserCommandAccountChecked("alice", UserCommand{Name: "draft", Template: "before"}, false) + if err != nil || command.Name != "/draft" { + t.Fatalf("active command create: %+v %v", command, err) + } + command.Template = "after" + if _, err := st.SaveUserCommandAccountChecked("alice", command, false); err != nil { + t.Fatal(err) + } + throwaway, err := st.AddSecretAccountChecked("alice", "throwaway", "", "temporary", false) + if err != nil { + t.Fatal(err) + } + if err := st.DeleteSecretAccountChecked("alice", throwaway.ID, false); err != nil { + t.Fatal(err) + } + throwawayEntry, err := st.SaveContextEntryAccountChecked("alice", ContextEntry{Content: "temporary"}, false) + if err != nil { + t.Fatal(err) + } + if err := st.DeleteContextEntryAccountChecked("alice", throwawayEntry.ID, false); err != nil { + t.Fatal(err) + } + throwawayCommand, err := st.SaveUserCommandAccountChecked("alice", UserCommand{Name: "temporary", Template: "temporary"}, false) + if err != nil { + t.Fatal(err) + } + deleted, err := st.DeleteUserCommandAccountChecked("alice", throwawayCommand.ID, false) + if err != nil || deleted.Name != "/temporary" { + t.Fatalf("guarded command delete lost Bus unregister name: %+v %v", deleted, err) + } + for _, mutate := range []func() error{ + func() error { return st.ActivateSecretAccountChecked("bob", secret.ID, false) }, + func() error { return st.RenameSecretAccountChecked("bob", secret.ID, "foreign", false) }, + func() error { return st.DeleteSecretAccountChecked("bob", secret.ID, false) }, + func() error { + _, err := st.SaveContextEntryAccountChecked("bob", ContextEntry{ID: entry.ID, Content: "foreign"}, false) + return err + }, + func() error { return st.DeleteContextEntryAccountChecked("bob", entry.ID, false) }, + func() error { + _, err := st.SaveUserCommandAccountChecked("bob", UserCommand{ID: command.ID, Name: "draft", Template: "foreign"}, false) + return err + }, + func() error { _, err := st.DeleteUserCommandAccountChecked("bob", command.ID, false); return err }, + } { + if err := mutate(); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign owner changed borrowed resource: %v", err) + } + } + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + for _, mutate := range []func() error{ + func() error { _, err := st.AddSecretAccountChecked("alice", "late", "late", "late", false); return err }, + func() error { return st.ActivateSecretAccountChecked("alice", second.ID, false) }, + func() error { return st.RenameSecretAccountChecked("alice", secret.ID, "late", false) }, + func() error { return st.DeleteSecretAccountChecked("alice", secret.ID, false) }, + func() error { + _, err := st.SaveContextEntryAccountChecked("alice", ContextEntry{Content: "late"}, false) + return err + }, + func() error { return st.DeleteContextEntryAccountChecked("alice", entry.ID, false) }, + func() error { + _, err := st.SaveUserCommandAccountChecked("alice", UserCommand{Name: "late", Template: "late"}, false) + return err + }, + func() error { _, err := st.DeleteUserCommandAccountChecked("alice", command.ID, false); return err }, + func() error { return st.CheckEffectiveAccountForExternalEffect("alice", false) }, + } { + if err := mutate(); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled owner changed borrowed resource: %v", err) + } + } + if value, ok := st.ActiveSecret("alice", "model"); !ok || value != "top-secret" { + t.Fatalf("rejected secret mutation changed active value: %q %v", value, ok) + } + entries, err := st.ListContextEntries("alice", "", "") + if err != nil || len(entries) != 1 || entries[0].Content != "after" { + t.Fatalf("rejected context mutation changed rows: %+v %v", entries, err) + } + commands, err := st.ListUserCommands("alice") + if err != nil || len(commands) != 1 || commands[0].Template != "after" { + t.Fatalf("rejected command mutation changed rows: %+v %v", commands, err) + } + if err := st.DeleteSecretAccountChecked("bob", second.ID, false); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign delete of inactive secret: %v", err) + } +} + +func TestBorrowAccountCheckedImplicitDefault(t *testing.T) { + st := openTestStore(t) + if _, err := st.AddSecretAccountChecked(OwnerDefault, "service", "", "secret", false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("implicit owner admitted without allowance: %v", err) + } + secret, err := st.AddSecretAccountChecked(OwnerDefault, "service", "", "secret", true) + if err != nil { + t.Fatal(err) + } + if err := st.RenameSecretAccountChecked(OwnerDefault, secret.ID, "local", true); err != nil { + t.Fatal(err) + } + if _, err := st.SaveContextEntryAccountChecked(OwnerDefault, ContextEntry{Content: "local"}, true); err != nil { + t.Fatal(err) + } + if _, err := st.SaveUserCommandAccountChecked(OwnerDefault, UserCommand{Name: "local", Template: "local"}, true); err != nil { + t.Fatal(err) + } +} + +func TestPGBorrowSecretWriteSerializesWithDisable(t *testing.T) { + st := openTestStore(t) + if st.Dialect() != "postgres" { + t.Skip("real PostgreSQL required") + } + other := reopenAccountModelStore(t, st) + for _, a := range []struct { + owner string + admin bool + }{{"admin", true}, {"alice", false}} { + if err := st.CreateAccount(a.owner, "", "", a.admin); err != nil { + t.Fatal(err) + } + } + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + release := holdPGAccountWrite(t, st, "secret_keys", "INSERT", "borrow_secret") + writeDone := make(chan error, 1) + go func() { + _, err := st.AddSecretAccountChecked("alice", "service", "first", "secret", false) + writeDone <- err + }() + waitForPGAccountWrite(t, st, "borrow_secret") + disableDone := make(chan error, 1) + go func() { + _, err := other.SetAccountEnabled("admin", account.ID, account.StateVersion, false) + disableDone <- err + }() + select { + case err := <-disableDone: + t.Fatalf("disable committed ahead of paused secret insert: %v", err) + case <-time.After(100 * time.Millisecond): + } + release() + if err := awaitPGAccountResult(t, writeDone); err != nil { + t.Fatal(err) + } + if err := awaitPGAccountResult(t, disableDone); err != nil { + t.Fatal(err) + } + if _, err := st.AddSecretAccountChecked("alice", "service", "late", "late", false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("late secret insert passed after disable: %v", err) + } +} diff --git a/internal/store/cards_account_write.go b/internal/store/cards_account_write.go new file mode 100644 index 0000000..4895e9b --- /dev/null +++ b/internal/store/cards_account_write.go @@ -0,0 +1,244 @@ +package store + +import ( + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + core "agent-system-core" +) + +type storedCardLink struct { + owner, projectID, conversationID, ref, payload string + version int +} + +func (s *Store) cardLinkForWrite(tx *sql.Tx, id string, lock bool) (storedCardLink, bool, error) { + query := s.q(`SELECT owner,project_id,conversation_id,ref,payload,version FROM activity_cards WHERE id=?`) + if lock && s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + var link storedCardLink + err := tx.QueryRow(query, id).Scan(&link.owner, &link.projectID, &link.conversationID, &link.ref, &link.payload, &link.version) + if errors.Is(err, sql.ErrNoRows) { + return storedCardLink{}, false, nil + } + return link, err == nil, err +} + +// cardResourcesForWrite verifies the stored links after the actor lock and +// before taking the card row lock. A member can own a private conversation +// inside another creator's shared project; project ownership is a separate +// authorization decision made by the calling domain service. +func (s *Store) cardResourcesForWrite(tx *sql.Tx, actor, projectID, conversationID string) error { + if conversationID != "" && projectID == "" { + return ErrNotFound + } + if projectID != "" { + if conversationID == "" { + if _, err := s.projectForAccountWrite(tx, actor, projectID); err != nil { + return err + } + } else { + query := s.q(`SELECT id FROM projects WHERE id=?`) + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + var found string + if err := tx.QueryRow(query, projectID).Scan(&found); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + } + } + if conversationID != "" { + query := s.q(`SELECT owner,project_id FROM conversations WHERE id=?`) + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + var owner, project string + err := tx.QueryRow(query, conversationID).Scan(&owner, &project) + if errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return err + } + if owner != actor || project != projectID { + return ErrNotFound + } + } + return nil +} + +func (s *Store) saveCardAccountChecked(actor string, card *core.ActivityCard, ref string, expected *int, allowImplicitDefault bool) error { + if card == nil || card.ID == "" { + return fmt.Errorf("save card: id and card required") + } + payload, err := json.Marshal(card) + if err != nil { + return fmt.Errorf("save card %s: %w", card.ID, err) + } + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + // Read immutable links first, then acquire project, conversation and card + // row locks in that order. Recheck links after the row lock. + first, exists, err := s.cardLinkForWrite(tx, card.ID, false) + if err != nil { + return err + } + if exists && (first.owner != actor || first.projectID != card.ProjectID || first.conversationID != card.ConversationID) { + return ErrNotFound + } + if err := s.cardResourcesForWrite(tx, actor, card.ProjectID, card.ConversationID); err != nil { + return err + } + current, stillExists, err := s.cardLinkForWrite(tx, card.ID, true) + if err != nil { + return err + } + if stillExists != exists || (exists && (current.owner != actor || current.projectID != card.ProjectID || current.conversationID != card.ConversationID)) { + return ErrConflict + } + if expected != nil && current.version != *expected { + return ErrConflict + } + if !exists { + res, err := tx.Exec(s.q(`INSERT INTO activity_cards(id,project_id,conversation_id,ref,owner,payload,status,pinned,created_at,updated_at,version) + VALUES(?,?,?,?,?,?,?,?,?,?,1) ON CONFLICT(id) DO NOTHING`), card.ID, card.ProjectID, card.ConversationID, ref, actor, + string(payload), string(card.Status), boolInt(card.Pinned), card.CreatedAt.Format(time.RFC3339Nano), card.UpdatedAt.Format(time.RFC3339Nano)) + if err != nil { + return err + } + if n, err := res.RowsAffected(); err != nil { + return err + } else if n != 1 { + return ErrConflict + } + return nil + } + if ref == "" { + ref = current.ref + } + res, err := tx.Exec(s.q(`UPDATE activity_cards SET payload=?,status=?,pinned=?,ref=?,updated_at=?,version=version+1 WHERE id=? AND owner=? AND version=?`), + string(payload), string(card.Status), boolInt(card.Pinned), ref, card.UpdatedAt.Format(time.RFC3339Nano), card.ID, actor, current.version) + if err != nil { + return err + } + if n, err := res.RowsAffected(); err != nil { + return err + } else if n != 1 { + return ErrConflict + } + return nil + }) +} + +// SaveCardAccountChecked is the guarded counterpart of the legacy fixture API. +// The actor lock and effective-state check cover the final card mutation. +func (s *Store) SaveCardAccountChecked(actor string, card *core.ActivityCard, ref string, allowImplicitDefault bool) error { + return s.saveCardAccountChecked(actor, card, ref, nil, allowImplicitDefault) +} + +// SaveCardVersionedAccountChecked also enforces the stored card version. +func (s *Store) SaveCardVersionedAccountChecked(actor string, card *core.ActivityCard, ref string, expected int, allowImplicitDefault bool) error { + return s.saveCardAccountChecked(actor, card, ref, &expected, allowImplicitDefault) +} + +// ArchiveCardToProjectAccountChecked commits the project copy and origin +// dismissal together. The return flag is false on an idempotent retry. +func (s *Store) ArchiveCardToProjectAccountChecked(actor, id string, now time.Time, allowImplicitDefault bool) (core.ActivityCard, core.ActivityCard, bool, error) { + var archived, origin core.ActivityCard + created := false + err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + first, exists, err := s.cardLinkForWrite(tx, id, false) + if err != nil { + return err + } + if !exists || first.owner != actor { + return ErrNotFound + } + if first.conversationID == "" { + return errors.New("card: already project-level") + } + if err := s.cardResourcesForWrite(tx, actor, first.projectID, first.conversationID); err != nil { + return err + } + current, exists, err := s.cardLinkForWrite(tx, id, true) + if err != nil { + return err + } + if !exists || current.owner != actor || current.projectID != first.projectID || current.conversationID != first.conversationID { + return ErrNotFound + } + if strings.HasPrefix(current.ref, "hitl:") || strings.HasPrefix(current.ref, "cmd:") { + return errors.New("card: flow-bound card cannot be archived to project") + } + if err := json.Unmarshal([]byte(current.payload), &origin); err != nil { + return fmt.Errorf("card %s payload: %w", id, err) + } + if origin.ID != id || origin.ProjectID != current.projectID || origin.ConversationID != current.conversationID { + return ErrNotFound + } + if origin.Task != nil { + return errors.New("card: keep the original task reference in its conversation") + } + ref := "to_project:" + id + var prior string + err = tx.QueryRow(s.q(`SELECT id FROM activity_cards WHERE owner=? AND ref=? ORDER BY updated_at DESC LIMIT 1`), actor, ref).Scan(&prior) + if err == nil { + link, ok, err := s.cardLinkForWrite(tx, prior, true) + if err != nil { + return err + } + if !ok || link.owner != actor || link.projectID != current.projectID || link.conversationID != "" { + return ErrNotFound + } + return json.Unmarshal([]byte(link.payload), &archived) + } + if !errors.Is(err, sql.ErrNoRows) { + return err + } + archived = origin + archived.ID = NewID("card") + archived.ConversationID = "" + archived.AnchorMessageID = core.ID(id) + archived.Pinned = false + archived.CreatedAt, archived.UpdatedAt = now, now + archived.Body = append(append([]core.UINode{}, origin.Body...), core.UINode{Type: core.UIKV, KV: [][2]string{{"留档", "自会话卡 " + id}}}) + payload, err := json.Marshal(&archived) + if err != nil { + return err + } + if _, err := tx.Exec(s.q(`INSERT INTO activity_cards(id,project_id,conversation_id,ref,owner,payload,status,pinned,created_at,updated_at,version) + VALUES(?,?,?,?,?,?,?,?,?,?,1)`), archived.ID, archived.ProjectID, "", ref, actor, string(payload), string(archived.Status), boolInt(archived.Pinned), now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano)); err != nil { + return err + } + origin.Status = core.CardDismissed + origin.Tone = "info" + origin.UpdatedAt = now + payload, err = json.Marshal(&origin) + if err != nil { + return err + } + res, err := tx.Exec(s.q(`UPDATE activity_cards SET payload=?,status=?,updated_at=?,version=version+1 WHERE id=? AND owner=? AND version=?`), + string(payload), string(origin.Status), now.Format(time.RFC3339Nano), id, actor, current.version) + if err != nil { + return err + } + if n, err := res.RowsAffected(); err != nil { + return err + } else if n != 1 { + return ErrConflict + } + created = true + return nil + }) + if err != nil { + return core.ActivityCard{}, core.ActivityCard{}, false, err + } + return archived, origin, created, nil +} diff --git a/internal/store/cards_account_write_test.go b/internal/store/cards_account_write_test.go new file mode 100644 index 0000000..813878e --- /dev/null +++ b/internal/store/cards_account_write_test.go @@ -0,0 +1,232 @@ +package store + +import ( + "errors" + "testing" + "time" + + core "agent-system-core" +) + +func cardAccountFixture(t *testing.T, st *Store) (core.Project, core.Conversation, core.ActivityCard) { + t.Helper() + for _, a := range []struct { + owner string + admin bool + }{{"admin", true}, {"alice", false}, {"bob", false}} { + if err := st.CreateAccount(a.owner, "", "", a.admin); err != nil { + t.Fatal(err) + } + } + project, err := st.CreateProject("alice", "cards") + if err != nil { + t.Fatal(err) + } + conv, err := st.CreateConversation("alice", project.ID, "cards", "") + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + card := core.ActivityCard{ID: NewID("card"), ProjectID: project.ID, ConversationID: conv.ID, + Title: "Review", Status: core.CardActionRequired, CreatedAt: now, UpdatedAt: now} + return project, conv, card +} + +func TestCardAccountCheckedWriteUsesPersistedOwnerAndResourceLinks(t *testing.T) { + st := openTestStore(t) + project, conv, card := cardAccountFixture(t, st) + if err := st.SaveCardAccountChecked("alice", &card, "", false); err != nil { + t.Fatal(err) + } + card.Title = "foreign replacement" + if err := st.SaveCardVersionedAccountChecked("bob", &card, "", 1, false); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign actor changed card: %v", err) + } + card.Title = "linked elsewhere" + card.ProjectID = "other-project" + if err := st.SaveCardVersionedAccountChecked("alice", &card, "", 1, false); !errors.Is(err, ErrNotFound) { + t.Fatalf("payload changed immutable project link: %v", err) + } + card.ProjectID = project.ID + card.ConversationID = "other-conversation" + if err := st.SaveCardVersionedAccountChecked("alice", &card, "", 1, false); !errors.Is(err, ErrNotFound) { + t.Fatalf("payload changed immutable conversation link: %v", err) + } + card.ConversationID = conv.ID + if err := st.SaveCardVersionedAccountChecked("alice", &card, "", 1, false); err != nil { + t.Fatalf("owner update: %v", err) + } + if err := st.SaveCardVersionedAccountChecked("alice", &card, "", 1, false); !errors.Is(err, ErrConflict) { + t.Fatalf("stale version replaced card: %v", err) + } + foreignProject, err := st.CreateProject("bob", "foreign") + if err != nil { + t.Fatal(err) + } + foreign := card + foreign.ID = NewID("card") + foreign.ProjectID = foreignProject.ID + foreign.ConversationID = "" + if err := st.SaveCardAccountChecked("alice", &foreign, "", false); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign project accepted: %v", err) + } + foreign.ProjectID = project.ID + foreign.ConversationID = conv.ID + if err := st.SaveCardAccountChecked("bob", &foreign, "", false); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign conversation accepted: %v", err) + } + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + card.Title = "late" + if err := st.SaveCardVersionedAccountChecked("alice", &card, "", 2, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled actor changed card: %v", err) + } + if err := st.SaveCardAccountChecked("alice", &foreign, "", false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled actor created card: %v", err) + } + got, version, err := st.GetCardVersion("alice", card.ID) + if err != nil || got.Title != "linked elsewhere" || version != 2 { + t.Fatalf("denied write altered card: %+v version=%d err=%v", got, version, err) + } +} + +func TestSharedProjectMemberCanSaveOwnConversationCard(t *testing.T) { + st := openTestStore(t) + for _, owner := range []string{"creator", "member"} { + if err := st.CreateAccount(owner, "", "", false); err != nil { + t.Fatal(err) + } + } + project, err := st.CreateProject("creator", "shared") + if err != nil { + t.Fatal(err) + } + conv, err := st.CreateConversationAccountChecked("member", string(project.ID), "private work", "", false, false) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + card := core.ActivityCard{ID: NewID("card"), ProjectID: project.ID, ConversationID: conv.ID, + Title: "Member decision", Status: core.CardActionRequired, CreatedAt: now, UpdatedAt: now} + if err := st.SaveCardAccountChecked("member", &card, "hitl:member", false); err != nil { + t.Fatalf("member conversation card rejected: %v", err) + } + card.Status = core.CardResolved + if err := st.SaveCardVersionedAccountChecked("member", &card, "", 1, false); err != nil { + t.Fatalf("member card status rejected: %v", err) + } + if _, err := st.GetCard("member", card.ID); err != nil { + t.Fatal(err) + } +} + +func TestCardArchiveAccountCheckedCopiesAndDismissesAtomically(t *testing.T) { + st := openTestStore(t) + project, _, card := cardAccountFixture(t, st) + if err := st.SaveCardAccountChecked("alice", &card, "", false); err != nil { + t.Fatal(err) + } + if isSQLite(st) { + if _, err := st.db.Exec(`CREATE TRIGGER block_archive_dismiss BEFORE UPDATE ON activity_cards + WHEN NEW.status='dismissed' BEGIN SELECT RAISE(ABORT, 'blocked'); END`); err != nil { + t.Fatal(err) + } + if _, _, _, err := st.ArchiveCardToProjectAccountChecked("alice", card.ID, time.Now().UTC(), false); err == nil { + t.Fatal("archive should fail when origin dismissal fails") + } + if _, err := st.CardIDByRef("alice", "to_project:"+card.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("failed dismissal committed project copy: %v", err) + } + if _, err := st.db.Exec(`DROP TRIGGER block_archive_dismiss`); err != nil { + t.Fatal(err) + } + } + archived, origin, created, err := st.ArchiveCardToProjectAccountChecked("alice", card.ID, time.Now().UTC(), false) + if err != nil || !created || archived.ProjectID != project.ID || archived.ConversationID != "" || archived.AnchorMessageID != core.ID(card.ID) || origin.Status != core.CardDismissed { + t.Fatalf("archive did not atomically copy and dismiss: archived=%+v origin=%+v created=%v err=%v", archived, origin, created, err) + } + again, _, created, err := st.ArchiveCardToProjectAccountChecked("alice", card.ID, time.Now().UTC(), false) + if err != nil || created || again.ID != archived.ID { + t.Fatalf("retry duplicated archive: %+v created=%v err=%v", again, created, err) + } + if _, _, _, err := st.ArchiveCardToProjectAccountChecked("bob", card.ID, time.Now().UTC(), false); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign actor archived card: %v", err) + } + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + if _, _, _, err := st.ArchiveCardToProjectAccountChecked("alice", card.ID, time.Now().UTC(), false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled actor passed idempotent archive: %v", err) + } +} + +func TestCardAccountCheckedImplicitDefaultRequiresAllowance(t *testing.T) { + st := openTestStore(t) + card := core.ActivityCard{ID: NewID("card"), Title: "local", CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()} + if err := st.SaveCardAccountChecked(OwnerDefault, &card, "", false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("implicit owner admitted without allowance: %v", err) + } + if err := st.SaveCardAccountChecked(OwnerDefault, &card, "", true); err != nil { + t.Fatal(err) + } + if err := st.EnsureUser(OwnerDefault, ""); err != nil { + t.Fatal(err) + } + if _, err := st.DB().Exec(st.q(`UPDATE users SET enabled=FALSE WHERE owner=?`), OwnerDefault); err != nil { + t.Fatal(err) + } + if err := st.SaveCardVersionedAccountChecked(OwnerDefault, &card, "", 1, true); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("explicitly disabled local actor updated card: %v", err) + } +} + +func TestPGCardAccountFenceSerializesWithDisable(t *testing.T) { + st := openTestStore(t) + if st.Dialect() != "postgres" { + t.Skip("real PostgreSQL required") + } + other := reopenAccountModelStore(t, st) + _, _, card := cardAccountFixture(t, st) + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + release := holdPGAccountWrite(t, st, "activity_cards", "INSERT", "card_create") + writeDone := make(chan error, 1) + go func() { writeDone <- st.SaveCardAccountChecked("alice", &card, "", false) }() + waitForPGAccountWrite(t, st, "card_create") + disableDone := make(chan error, 1) + go func() { + _, err := other.SetAccountEnabled("admin", account.ID, account.StateVersion, false) + disableDone <- err + }() + select { + case err := <-disableDone: + t.Fatalf("disable committed ahead of paused card insert: %v", err) + case <-time.After(100 * time.Millisecond): + } + release() + if err := awaitPGAccountResult(t, writeDone); err != nil { + t.Fatal(err) + } + if err := awaitPGAccountResult(t, disableDone); err != nil { + t.Fatal(err) + } + card.Title = "late" + if err := st.SaveCardVersionedAccountChecked("alice", &card, "", 1, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("card write crossed disable boundary: %v", err) + } + got, version, err := st.GetCardVersion("alice", card.ID) + if err != nil || got.Title != "Review" || version != 1 { + t.Fatalf("late write changed card: %+v version=%d err=%v", got, version, err) + } +} diff --git a/internal/store/config.go b/internal/store/config.go index 47f6b19..1b79683 100644 --- a/internal/store/config.go +++ b/internal/store/config.go @@ -3,6 +3,7 @@ package store import ( "database/sql" "errors" + "strings" core "agent-system-core" ) @@ -63,6 +64,67 @@ func (s *Store) DeleteProfile(owner, id string) error { return err } +// SaveProfileAccountChecked writes a user profile only while its actor remains +// effective. Profile IDs are global, so an existing foreign or builtin row +// cannot be taken over through the upsert path. +func (s *Store) SaveProfileAccountChecked(actor string, p core.AgentProfile, allowImplicitDefault bool) error { + if p.Builtin || (p.Owner != "" && p.Owner != actor) { + return ErrAccountForbidden + } + p.Owner = actor + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + res, err := tx.Exec(s.q(`INSERT INTO profiles(id,owner,builtin,payload) VALUES(?,?,0,?) + ON CONFLICT(id) DO UPDATE SET payload=excluded.payload + WHERE profiles.owner=excluded.owner AND profiles.builtin=0`), + p.ID, actor, marshalJSON(p)) + if err != nil { + return err + } + if n, err := res.RowsAffected(); err != nil { + return err + } else if n != 1 { + return ErrNotFound + } + return nil + }) +} + +// DeleteProfileAccountChecked resolves owner and builtin state from the row +// inside the final actor-guarded transaction before deleting it. +func (s *Store) DeleteProfileAccountChecked(actor, id string, allowImplicitDefault bool) error { + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + var owner string + var builtin int + query := s.q(`SELECT owner,builtin FROM profiles WHERE id=?`) + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + err := tx.QueryRow(query, id).Scan(&owner, &builtin) + if errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return err + } + if owner != actor { + return ErrNotFound + } + if builtin == 1 { + return errors.New("store: builtin profile cannot be deleted") + } + res, err := tx.Exec(s.q(`DELETE FROM profiles WHERE id=? AND owner=? AND builtin=0`), id, actor) + if err != nil { + return err + } + if n, err := res.RowsAffected(); err != nil { + return err + } else if n != 1 { + return ErrNotFound + } + return nil + }) +} + // ---- 设置(scope = user | project:;PK=(owner,scope),租户隔离)---- func (s *Store) GetSettingsScope(owner, scope string) (map[string]any, error) { @@ -149,6 +211,74 @@ func (s *Store) UpdateSettingsScope(owner, scope string, fn func(cur map[string] return tx.Commit() } +// UpdateSettingsScopeAccountChecked resolves the request actor independently +// from the persisted settings owner. The actor lock is acquired before the +// settings scope and project row locks in the final write transaction. +func (s *Store) UpdateSettingsScopeAccountChecked(actor, owner, scope string, allowImplicitDefault bool, fn func(cur map[string]any) (map[string]any, error)) error { + if fn == nil { + return ErrAccountForbidden + } + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, account Account) error { + if err := s.dialect.lockScope(tx, owner, scope); err != nil { + return err + } + if err := s.checkSettingsScopeWriteActor(tx, actor, owner, scope, account, allowImplicitDefault); err != nil { + return err + } + var payload string + err := tx.QueryRow(s.q(`SELECT "values" FROM settings WHERE owner=? AND scope=?`), owner, scope).Scan(&payload) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + cur := map[string]any{} + if payload != "" { + cur, err = unmarshalJSON[map[string]any](payload) + if err != nil { + return err + } + if cur == nil { + cur = map[string]any{} + } + } + next, err := fn(cur) + if err != nil { + return err + } + if next == nil { + next = map[string]any{} + } + if marshalJSON(cur) == marshalJSON(next) { + return nil + } + _, err = tx.Exec(s.q(`INSERT INTO settings(owner,scope,"values") VALUES(?,?,?) + ON CONFLICT(owner,scope) DO UPDATE SET "values"=excluded."values"`), owner, scope, marshalJSON(next)) + return err + }) +} + +// checkSettingsScopeWriteActor keeps settings ownership and administrator +// policy separate from the actor's enabled-state admission. +func (s *Store) checkSettingsScopeWriteActor(tx *sql.Tx, actor, owner, scope string, account Account, allowImplicitDefault bool) error { + switch { + case scope == "system" && owner == "": + if account.IsAdmin || (allowImplicitDefault && actor == OwnerDefault) { + return nil + } + return ErrAccountForbidden + case scope == "user" && owner == actor: + return nil + case strings.HasPrefix(scope, "project:") && owner == actor: + projectID := strings.TrimPrefix(scope, "project:") + if projectID == "" { + return ErrNotFound + } + _, err := s.projectForAccountWrite(tx, actor, projectID) + return err + default: + return ErrAccountForbidden + } +} + // MergeSettingsScope 读改写走事务 + scope 锁:并发合并不再丢失更新。 func (s *Store) MergeSettingsScope(owner, scope string, values map[string]any) error { return s.UpdateSettingsScope(owner, scope, func(cur map[string]any) (map[string]any, error) { diff --git a/internal/store/content.go b/internal/store/content.go index c740f98..5a9464e 100644 --- a/internal/store/content.go +++ b/internal/store/content.go @@ -5,6 +5,7 @@ import ( "database/sql" "errors" "fmt" + "strings" "time" core "agent-system-core" @@ -34,27 +35,128 @@ func (s *Store) CreateArtifact(a *core.Artifact, content []byte) error { return nil } +// CreateArtifactAccountChecked stages content under a fresh, unreferenced key. +// The artifact only becomes visible when its row commits with the actor's +// effective-account check. A rejected transaction cannot expose staged bytes. +func (s *Store) CreateArtifactAccountChecked(actor string, a *core.Artifact, content []byte, allowImplicitDefault bool) error { + if a == nil || a.Owner != actor { + return ErrNotFound + } + if actor == "" { + return ErrAccountForbidden + } + if a.ID == "" { + a.ID = newID("art") + } + now := time.Now().UTC() + a.CreatedAt, a.UpdatedAt = now, now + a.Size = int64(len(content)) + ref, err := s.blobs.Put(newID("blob"), content) + if err != nil { + return err + } + var callbackReached bool + var callbackErr error + err = s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + callbackReached = true + _, callbackErr = tx.Exec(s.q(`INSERT INTO artifacts(id,project_id,owner,name,kind,mime,size,source_run,content_path,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)`), + a.ID, a.ProjectID, actor, a.Name, a.Kind, a.MIME, a.Size, a.SourceRun, ref, nowISO(), nowISO()) + return callbackErr + }) + if err != nil && (!callbackReached || callbackErr != nil) { + _ = s.blobs.Delete(ref) + } + return err +} + func (s *Store) UpdateArtifactContent(owner, id string, content []byte) error { - var path string - err := s.db.QueryRow(s.q(`SELECT content_path FROM artifacts WHERE id=? AND owner=?`), id, owner).Scan(&path) - if errors.Is(err, sql.ErrNoRows) { - return ErrNotFound + // Legacy callers retain the old signature, but share immutable replacement: + // account-checked creations may already point at a key other than the ID. + ref, err := s.blobs.Put(newID("blob"), content) + if err != nil { + return err + } + tx, err := s.db.Begin() + if err != nil { + _ = s.blobs.Delete(ref) + return err + } + defer tx.Rollback() + oldRef, err := s.switchArtifactContentTx(tx, owner, id, ref, len(content)) + if err != nil { + _ = tx.Rollback() + _ = s.blobs.Delete(ref) + return err + } + if err := tx.Commit(); err != nil { + // The commit may have reached the DB. Preserve the staged object. + return err + } + // The DB pointer is authoritative. Blob deletion is external and may fail + // after a successful commit; callers can retry cleanup separately. + if oldRef == "" { + return nil + } + return s.blobs.Delete(oldRef) +} + +// UpdateArtifactContentAccountChecked swaps only the visible DB reference in +// the account-guarded transaction. Never overwrite the old referenced object. +func (s *Store) UpdateArtifactContentAccountChecked(actor, id string, content []byte, allowImplicitDefault bool) error { + if actor == "" { + return ErrAccountForbidden + } + ref, err := s.blobs.Put(newID("blob"), content) + if err != nil { + return err + } + var oldRef string + var callbackReached bool + var callbackErr error + err = s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + callbackReached = true + oldRef, callbackErr = s.switchArtifactContentTx(tx, actor, id, ref, len(content)) + return callbackErr + }) + if err != nil && (!callbackReached || callbackErr != nil) { + _ = s.blobs.Delete(ref) + return err } if err != nil { return err } - // 同 ID 原子覆写(临时文件+rename);行内引用不变,避免行/对象漂移。 - if _, err := s.blobs.Put(id, content); err != nil { - return err + // The old object can be removed only after the new reference commits. + if oldRef == "" { + return nil } - res, err := s.db.Exec(s.q(`UPDATE artifacts SET size=?, updated_at=? WHERE id=? AND owner=?`), len(content), nowISO(), id, owner) + return s.blobs.Delete(oldRef) +} + +func (s *Store) switchArtifactContentTx(tx *sql.Tx, owner, id, newRef string, size int) (string, error) { + // Serialize overwrites with task projection source-version witnesses. + if err := s.dialect.lockScope(tx, owner, "artifact:"+id); err != nil { + return "", err + } + query := s.q(`SELECT content_path FROM artifacts WHERE id=? AND owner=?`) + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + var oldRef string + if err := tx.QueryRow(query, id, owner).Scan(&oldRef); errors.Is(err, sql.ErrNoRows) { + return "", ErrNotFound + } else if err != nil { + return "", err + } + res, err := tx.Exec(s.q(`UPDATE artifacts SET content_path=?, size=?, updated_at=? WHERE id=? AND owner=?`), newRef, size, nowISO(), id, owner) if err != nil { - return err + return "", err } - if n, _ := res.RowsAffected(); n == 0 { - return ErrNotFound + if n, err := res.RowsAffected(); err != nil { + return "", err + } else if n != 1 { + return "", ErrNotFound } - return nil + return oldRef, nil } const artifactColumns = `id,project_id,owner,name,kind,mime,size,source_run,created_at,updated_at` @@ -158,23 +260,75 @@ func (s *Store) ListProjectArtifactsAll(projectID string) ([]core.Artifact, erro // DeleteArtifact 先删行后删对象:崩溃间隙只可能残留无主文件(可 GC),不悬空引用。 func (s *Store) DeleteArtifact(owner, id string) error { + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if err = s.dialect.lockScope(tx, owner, "artifact:"+id); err != nil { + return err + } var path string - err := s.db.QueryRow(s.q(`SELECT content_path FROM artifacts WHERE id=? AND owner=?`), id, owner).Scan(&path) + err = tx.QueryRow(s.q(`SELECT content_path FROM artifacts WHERE id=? AND owner=?`), id, owner).Scan(&path) if errors.Is(err, sql.ErrNoRows) { return ErrNotFound } if err != nil { return err } - if _, err := s.db.Exec(s.q(`DELETE FROM artifacts WHERE id=? AND owner=?`), id, owner); err != nil { + if _, err = tx.Exec(s.q(`DELETE FROM artifacts WHERE id=? AND owner=?`), id, owner); err != nil { return err } - // 联动清理检索索引(知识行 + 向量;失败不阻断删除,残留可由重建索引收敛)。 + if err = tx.Commit(); err != nil { + return err + } + // Preserve the existing lexical/vector cleanup after the authoritative delete. _ = s.DeleteKnowledgeByRef(owner, id) _ = s.DeleteKnowledgeVectors(context.Background(), owner, id) return s.blobs.Delete(path) } +// DeleteArtifactAccountChecked removes Backend-owned rows in one guarded +// transaction. The blob is unlinked only after commit; an unlink failure may +// leave an orphan, but cannot restore a deleted DB row or authorize a late write. +func (s *Store) DeleteArtifactAccountChecked(actor, id string, allowImplicitDefault bool) error { + var ref string + err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + if err := s.dialect.lockScope(tx, actor, "artifact:"+id); err != nil { + return err + } + query := s.q(`SELECT content_path FROM artifacts WHERE id=? AND owner=?`) + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + if err := tx.QueryRow(query, id, actor).Scan(&ref); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + if _, err := tx.Exec(s.q(`DELETE FROM knowledge_vectors WHERE ref=? AND owner=?`), id, actor); err != nil { + return err + } + if _, err := tx.Exec(s.q(`DELETE FROM knowledge WHERE ref=? AND owner=?`), id, actor); err != nil { + return err + } + res, err := tx.Exec(s.q(`DELETE FROM artifacts WHERE id=? AND owner=?`), id, actor) + if err != nil { + return err + } + if n, err := res.RowsAffected(); err != nil { + return err + } else if n != 1 { + return ErrNotFound + } + return nil + }) + if err != nil { + return err + } + return s.blobs.Delete(ref) +} + // ---- HITL 卡片(resolve 原子化:条件更新消灭先查后改窗口)---- func (s *Store) SaveHITLCard(owner string, c *core.HITLCard) error { @@ -196,6 +350,18 @@ func (s *Store) GetHITLCard(owner, id string) (core.HITLCard, error) { return unmarshalJSON[core.HITLCard](payload) } +func (s *Store) HITLResolution(owner, id string) (core.HITLResolvedPayload, error) { + var payload string + err := s.db.QueryRow(s.q(`SELECT resolution FROM hitl_cards WHERE id=? AND owner=? AND resolved=1`), id, owner).Scan(&payload) + if errors.Is(err, sql.ErrNoRows) { + return core.HITLResolvedPayload{}, ErrNotFound + } + if err != nil { + return core.HITLResolvedPayload{}, err + } + return unmarshalJSON[core.HITLResolvedPayload](payload) +} + // ResolveHITLCard 原子置为已解决;已解决的重复请求返回明确错误(先查后改竞态修复)。 func (s *Store) ResolveHITLCard(owner, id string, resolution core.HITLResolvedPayload) error { c, err := s.GetHITLCard(owner, id) @@ -292,6 +458,79 @@ func (s *Store) AddKnowledge(docs ...core.KnowledgeDoc) error { return err } defer tx.Rollback() + if err := s.addKnowledgeTx(tx, docs); err != nil { + return err + } + return tx.Commit() +} + +// AddKnowledgeAccountChecked keeps the whole batch behind one final account +// check and transaction. A caller cannot publish rows under another owner. +func (s *Store) AddKnowledgeAccountChecked(actor string, allowImplicitDefault bool, docs ...core.KnowledgeDoc) error { + if len(docs) == 0 { + return nil + } + for _, doc := range docs { + if doc.Owner != core.ID(actor) { + return ErrNotFound + } + } + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + return s.addKnowledgeTx(tx, docs) + }) +} + +// ReplaceKnowledgeAccountChecked atomically removes the affected publication +// refs and inserts all replacement chunks. A failed insert rolls the removals +// back; embeddings and their later vectors are separate guarded operations. +// scoped=true limits each ref to the exact project and plugin namespace. +func (s *Store) ReplaceKnowledgeAccountChecked(ctx context.Context, actor, pluginID string, scoped, allowImplicitDefault bool, docs ...core.KnowledgeDoc) error { + if len(docs) == 0 { + return nil + } + if scoped && pluginID == "" { + return ErrNotFound + } + prefix := "plugin:" + pluginID + type publicationRef struct{ projectID, ref string } + refs := map[publicationRef]bool{} + for _, doc := range docs { + if doc.Owner != core.ID(actor) { + return ErrNotFound + } + if scoped && doc.Source != prefix && !strings.HasPrefix(doc.Source, prefix+":") { + return ErrNotFound + } + if doc.Ref != "" { + refs[publicationRef{projectID: string(doc.ProjectID), ref: doc.Ref}] = true + } + } + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + if err := ctx.Err(); err != nil { + return err + } + for ref := range refs { + pred := `owner=? AND ref=?` + args := []any{actor, ref.ref} + if scoped { + pred += ` AND project_id=? AND (source=? OR substr(source,1,length(?))=?)` + args = append(args, ref.projectID, prefix, prefix+":", prefix+":") + } else if ref.projectID != "" { + pred += ` AND project_id=?` + args = append(args, ref.projectID) + } + if _, err := s.deleteKnowledgeAndVectorsTx(ctx, tx, pred, args...); err != nil { + return err + } + } + if err := ctx.Err(); err != nil { + return err + } + return s.addKnowledgeTx(tx, docs) + }) +} + +func (s *Store) addKnowledgeTx(tx *sql.Tx, docs []core.KnowledgeDoc) error { ts := nowISO() // 按下标写回生成的 ID:调用方(rag 管线)用 ID 关联向量。 for i := range docs { @@ -303,7 +542,7 @@ func (s *Store) AddKnowledge(docs ...core.KnowledgeDoc) error { return err } } - return tx.Commit() + return nil } // DeleteKnowledgeByRef 删除引用对应的知识(工件删除时联动清索引)。 @@ -312,6 +551,35 @@ func (s *Store) DeleteKnowledgeByRef(owner, ref string) error { return err } +// DeleteKnowledgeByRefAccountChecked removes the index rows and their vectors +// in the same account-guarded transaction. +func (s *Store) DeleteKnowledgeByRefAccountChecked(ctx context.Context, actor, ref string, allowImplicitDefault bool) error { + _, err := s.deleteKnowledgeAccountChecked(ctx, actor, allowImplicitDefault, `owner=? AND ref=?`, actor, ref) + return err +} + +// DeleteKnowledgeBySourceAccountChecked treats the prefix literally and only +// matches its namespace (prefix itself or prefix followed by ':'). SQL LIKE +// would treat plugin IDs containing '_' or '%' as wildcards. +func (s *Store) DeleteKnowledgeBySourceAccountChecked(ctx context.Context, actor, sourcePrefix string, allowImplicitDefault bool) (int64, error) { + if sourcePrefix == "" { + return 0, ErrNotFound + } + childPrefix := sourcePrefix + ":" + return s.deleteKnowledgeAccountChecked(ctx, actor, allowImplicitDefault, + `owner=? AND (source=? OR substr(source,1,length(?))=?)`, actor, sourcePrefix, childPrefix, childPrefix) +} + +func (s *Store) deleteKnowledgeAccountChecked(ctx context.Context, actor string, allowImplicitDefault bool, pred string, args ...any) (int64, error) { + var deleted int64 + err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + var err error + deleted, err = s.deleteKnowledgeAndVectorsTx(ctx, tx, pred, args...) + return err + }) + return deleted, err +} + // DeleteKnowledgeBySource 按来源前缀撤销知识(EXT-9):删除该 owner 下 source 匹配 // 前缀的行,并经 ref 关联清理向量;幂等(无匹配返回 0, nil)。供宿主在 // KnowledgeDelete(前缀已强制 plugin:<插件ID>)后调用——不作用他人来源。 @@ -421,6 +689,19 @@ func (s *Store) DeleteProjectKnowledgeByRef(ctx context.Context, owner, projectI return s.deleteKnowledgeAndVectors(ctx, pred, args...) } +// DeleteProjectKnowledgeByRefAccountChecked retains legacy project scoping: +// empty projectID means all of this actor's projects for the given ref. +func (s *Store) DeleteProjectKnowledgeByRefAccountChecked(ctx context.Context, actor, projectID, ref string, allowImplicitDefault bool) error { + args := []any{actor, ref} + pred := `owner=? AND ref=?` + if projectID != "" { + pred += ` AND project_id=?` + args = append(args, projectID) + } + _, err := s.deleteKnowledgeAccountChecked(ctx, actor, allowImplicitDefault, pred, args...) + return err +} + // DeletePluginProjectKnowledgeByRef replaces only this actor/plugin publication // scope. Empty project is an exact unscoped domain, never a wildcard. The source // prefix is compared literally, including any percent/underscore in plugin IDs. @@ -433,21 +714,45 @@ func (s *Store) DeletePluginProjectKnowledgeByRef(ctx context.Context, owner, pr return s.deleteKnowledgeAndVectors(ctx, pred, owner, projectID, ref, prefix, prefix+":", prefix+":") } +// DeletePluginProjectKnowledgeByRefAccountChecked retains the exact project, +// ref and plugin namespace restriction, including literal '%' and '_' IDs. +func (s *Store) DeletePluginProjectKnowledgeByRefAccountChecked(ctx context.Context, actor, projectID, ref, pluginID string, allowImplicitDefault bool) error { + if pluginID == "" { + return ErrNotFound + } + prefix := "plugin:" + pluginID + pred := `owner=? AND project_id=? AND ref=? AND (source=? OR substr(source,1,length(?))=?)` + _, err := s.deleteKnowledgeAccountChecked(ctx, actor, allowImplicitDefault, pred, + actor, projectID, ref, prefix, prefix+":", prefix+":") + return err +} + func (s *Store) deleteKnowledgeAndVectors(ctx context.Context, pred string, args ...any) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() - if _, err := tx.ExecContext(ctx, s.q("DELETE FROM knowledge_vectors WHERE doc_id IN (SELECT id FROM knowledge WHERE "+pred+")"), args...); err != nil { - return err - } - if _, err := tx.ExecContext(ctx, s.q("DELETE FROM knowledge WHERE "+pred), args...); err != nil { + if _, err := s.deleteKnowledgeAndVectorsTx(ctx, tx, pred, args...); err != nil { return err } return tx.Commit() } +func (s *Store) deleteKnowledgeAndVectorsTx(ctx context.Context, tx *sql.Tx, pred string, args ...any) (int64, error) { + if err := ctx.Err(); err != nil { + return 0, err + } + if _, err := tx.ExecContext(ctx, s.q("DELETE FROM knowledge_vectors WHERE doc_id IN (SELECT id FROM knowledge WHERE "+pred+")"), args...); err != nil { + return 0, err + } + res, err := tx.ExecContext(ctx, s.q("DELETE FROM knowledge WHERE "+pred), args...) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + func (s *Store) searchKnowledgeSQLite(owner string, q core.RetrievalQuery) ([]core.RetrievalHit, error) { out, err := s.searchKnowledgeFTS(ownerVisible(owner, q), q) if err != nil { @@ -500,12 +805,20 @@ func (s *Store) searchKnowledgeFTSCandidates(pred knowledgePred, q core.Retrieva return nil, err } defer rows.Close() - out := []core.RetrievalHit{} + var rawHits []core.RetrievalHit for rows.Next() { h, err := scanKnowledgeHit(rows) if err != nil { return nil, err } + rawHits = append(rawHits, h) + } + if err := rows.Err(); err != nil { + return nil, err + } + _ = rows.Close() + out := []core.RetrievalHit{} + for _, h := range rawHits { if !pred(h.Doc) { continue } @@ -514,7 +827,7 @@ func (s *Store) searchKnowledgeFTSCandidates(pred knowledgePred, q core.Retrieva break } } - return out, rows.Err() + return out, nil } func (s *Store) searchKnowledgeLikeShared(requester string, q core.RetrievalQuery, pred knowledgePred) ([]core.RetrievalHit, error) { @@ -537,7 +850,7 @@ func (s *Store) searchKnowledgeLikeSharedCandidates(requester string, q core.Ret return nil, err } defer rows.Close() - out := []core.RetrievalHit{} + var rawDocs []core.KnowledgeDoc for rows.Next() { var d core.KnowledgeDoc var own, ca string @@ -546,6 +859,14 @@ func (s *Store) searchKnowledgeLikeSharedCandidates(requester string, q core.Ret } d.Owner = own d.CreatedAt, _ = parseTime(ca) + rawDocs = append(rawDocs, d) + } + if err := rows.Err(); err != nil { + return nil, err + } + _ = rows.Close() + out := []core.RetrievalHit{} + for _, d := range rawDocs { if !pred(d) { continue } @@ -554,7 +875,7 @@ func (s *Store) searchKnowledgeLikeSharedCandidates(requester string, q core.Ret break } } - return out, rows.Err() + return out, nil } func (s *Store) searchKnowledgeLike(owner string, q core.RetrievalQuery, topK int) ([]core.RetrievalHit, error) { diff --git a/internal/store/context_manifest.go b/internal/store/context_manifest.go index 779f7b2..48e52a9 100644 --- a/internal/store/context_manifest.go +++ b/internal/store/context_manifest.go @@ -11,12 +11,10 @@ import ( const contextManifestAction = "model.context_manifest" -// ContextManifestScope records the exact trusted Resolve selector separately -// from the public final-input manifest. Bound distinguishes an intentionally -// empty goal from a legacy receipt with no recoverable selector. +// ContextManifestScope records only that the request came from a trusted model +// guard. Raw goals and model output are never persisted in this receipt. type ContextManifestScope struct { - Goal, TargetID string - Bound bool + Bound bool } // AppendContextManifest writes to the trusted receipt table, never to the @@ -50,17 +48,16 @@ func (s *Store) appendContextManifest(owner, targetID string, m extension.Projec } defer tx.Rollback() if m.Delivery != "attempted" { - var attemptedGoal, attemptedTarget string var attemptedBound int - err := tx.QueryRow(s.q(`SELECT resolve_goal,resolve_target_id,scope_bound FROM context_manifests WHERE attempt_id=? AND delivery='attempted' AND owner=? AND target_id=? AND project_id=? AND input_sha256=?`), - m.AttemptID, owner, targetID, m.ProjectID, m.InputSHA256).Scan(&attemptedGoal, &attemptedTarget, &attemptedBound) + err := tx.QueryRow(s.q(`SELECT scope_bound FROM context_manifests WHERE attempt_id=? AND delivery='attempted' AND owner=? AND target_id=? AND project_id=? AND input_sha256=?`), + m.AttemptID, owner, targetID, m.ProjectID, m.InputSHA256).Scan(&attemptedBound) if errors.Is(err, sql.ErrNoRows) { return errors.New("context manifest terminal receipt lacks matching attempt") } if err != nil { return err } - if attemptedGoal != scope.Goal || attemptedTarget != scope.TargetID || (attemptedBound != 0) != scope.Bound { + if (attemptedBound != 0) != scope.Bound { return errors.New("context manifest terminal resolve scope changed") } var terminal int @@ -76,8 +73,8 @@ func (s *Store) appendContextManifest(owner, targetID string, m extension.Projec if scope.Bound { bound = 1 } - _, err = tx.Exec(s.q(`INSERT INTO context_manifests(id,attempt_id,owner,target_id,project_id,input_sha256,delivery,manifest_json,created_at,resolve_goal,resolve_target_id,scope_bound) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`), - NewID("ctxm"), m.AttemptID, owner, targetID, m.ProjectID, m.InputSHA256, m.Delivery, string(raw), nowISO(), scope.Goal, scope.TargetID, bound) + _, err = tx.Exec(s.q(`INSERT INTO context_manifests(id,attempt_id,owner,target_id,project_id,input_sha256,delivery,manifest_json,created_at,scope_bound) VALUES(?,?,?,?,?,?,?,?,?,?)`), + NewID("ctxm"), m.AttemptID, owner, targetID, m.ProjectID, m.InputSHA256, m.Delivery, string(raw), nowISO(), bound) if err != nil { return fmt.Errorf("insert context manifest: %w", err) } diff --git a/internal/store/context_manifest_test.go b/internal/store/context_manifest_test.go index c4411b8..449eecb 100644 --- a/internal/store/context_manifest_test.go +++ b/internal/store/context_manifest_test.go @@ -1,7 +1,10 @@ package store import ( + "bytes" + "os" "path/filepath" + "strings" "testing" "agent-system-backend/pkg/extension" @@ -37,6 +40,221 @@ func TestContextManifestTerminalRequiresMatchingAttempt(t *testing.T) { } } +func TestContextManifestDoesNotPersistWorkflowOutputInResolveScope(t *testing.T) { + st, err := OpenSQLite(filepath.Join(t.TempDir(), "scope-privacy.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + const restricted = "OLD_RESTRICTED_BODY" + m := extension.ProjectContextManifest{ProjectID: "project-1", AttemptID: "judge-attempt", InputSHA256: "input-hash", Delivery: "attempted"} + scope := ContextManifestScope{Bound: true} + if err := st.AppendContextManifestWithScope("alice", "run-1", m, scope); err != nil { + t.Fatal(err) + } + m.Delivery = "responded" + if err := st.AppendContextManifestWithScope("alice", "run-1", m, scope); err != nil { + t.Fatal(err) + } + rows, err := st.db.Query(st.q(`SELECT delivery,manifest_json FROM context_manifests WHERE attempt_id=? ORDER BY created_at,id`), m.AttemptID) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + seen := 0 + for rows.Next() { + var delivery, manifest string + if err := rows.Scan(&delivery, &manifest); err != nil { + t.Fatal(err) + } + seen++ + if strings.Contains(manifest, restricted) { + t.Errorf("%s trusted receipt persisted workflow output body", delivery) + } + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + if seen != 2 { + t.Fatalf("expected attempted and responded receipts, got %d", seen) + } + var rawColumns int + if err := st.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('context_manifests') WHERE name IN ('resolve_goal','resolve_target_id')`).Scan(&rawColumns); err != nil || rawColumns != 0 { + t.Fatalf("fresh receipt schema retains raw scope columns: columns=%d err=%v", rawColumns, err) + } +} + +func TestCandidateV35RawScopeColumnsAreScrubbedOnUpgrade(t *testing.T) { + path := filepath.Join(t.TempDir(), "candidate-v38.db") + st, err := OpenSQLite(path) + if err != nil { + t.Fatal(err) + } + m := extension.ProjectContextManifest{ProjectID: "p1", AttemptID: "old-model", InputSHA256: "input", Delivery: "attempted"} + if err := st.AppendContextManifestWithScope("alice", "run-1", m, ContextManifestScope{Bound: true}); err != nil { + t.Fatal(err) + } + for _, query := range []string{ + `DELETE FROM schema_migrations WHERE version=41`, + `ALTER TABLE context_manifests ADD COLUMN resolve_goal TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE context_manifests ADD COLUMN resolve_target_id TEXT NOT NULL DEFAULT ''`, + `UPDATE context_manifests SET resolve_goal='OLD_RESTRICTED_BODY',resolve_target_id='doc-private'`, + } { + if _, err := st.db.Exec(query); err != nil { + t.Fatal(err) + } + } + if err := st.Close(); err != nil { + t.Fatal(err) + } + upgraded, err := OpenSQLite(path) + if err != nil { + t.Fatal(err) + } + defer upgraded.Close() + var columns int + if err := upgraded.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('context_manifests') WHERE name IN ('resolve_goal','resolve_target_id')`).Scan(&columns); err != nil || columns != 0 { + t.Fatalf("candidate raw scope columns survived v41: columns=%d err=%v", columns, err) + } + var count int + if err := upgraded.db.QueryRow(`SELECT COUNT(*) FROM context_manifests WHERE attempt_id='old-model'`).Scan(&count); err != nil || count != 1 { + t.Fatalf("trusted manifest disappeared during scrub: count=%d err=%v", count, err) + } + for _, suffix := range []string{"", "-wal"} { + data, err := os.ReadFile(path + suffix) + if err == nil && bytes.Contains(data, []byte("OLD_RESTRICTED_BODY")) { + t.Fatalf("candidate raw scope bytes remain in SQLite %s after upgrade", suffix) + } + } +} + +func TestCandidateV35CommittedDropKeepsPendingScrubAcrossRestart(t *testing.T) { + path := filepath.Join(t.TempDir(), "candidate-crash.db") + st, err := OpenSQLite(path) + if err != nil { + t.Fatal(err) + } + m := extension.ProjectContextManifest{ProjectID: "p1", AttemptID: "crash-model", InputSHA256: "input", Delivery: "attempted"} + if err := st.AppendContextManifestWithScope("alice", "run-1", m, ContextManifestScope{Bound: true}); err != nil { + t.Fatal(err) + } + for _, query := range []string{ + `DELETE FROM schema_migrations WHERE version=41`, + `ALTER TABLE context_manifests ADD COLUMN resolve_goal TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE context_manifests ADD COLUMN resolve_target_id TEXT NOT NULL DEFAULT ''`, + `UPDATE context_manifests SET resolve_goal='OLD_RESTRICTED_BODY',resolve_target_id='doc-private'`, + } { + if _, err := st.db.Exec(query); err != nil { + t.Fatal(err) + } + } + // Apply only the transactional DDL and ledger. A process crash at this + // boundary must not make the next open mistake the file for scrubbed. + if err := st.migratePass(); err != nil { + t.Fatal(err) + } + var pending int + if err := st.db.QueryRow(`SELECT pending FROM context_scope_scrub_state WHERE id=1`).Scan(&pending); err != nil || pending != 1 { + t.Fatalf("committed v41 lost physical scrub obligation: pending=%d err=%v", pending, err) + } + if err := st.Close(); err != nil { + t.Fatal(err) + } + restarted, err := OpenSQLite(path) + if err != nil { + t.Fatal(err) + } + defer restarted.Close() + if err := restarted.db.QueryRow(`SELECT pending FROM context_scope_scrub_state WHERE id=1`).Scan(&pending); err != nil || pending != 0 { + t.Fatalf("restart did not complete pending scrub: pending=%d err=%v", pending, err) + } + for _, suffix := range []string{"", "-wal"} { + data, err := os.ReadFile(path + suffix) + if err == nil && bytes.Contains(data, []byte("OLD_RESTRICTED_BODY")) { + t.Fatalf("restart left candidate raw bytes in SQLite %s", suffix) + } + } +} + +func TestEarlierV36WithoutMarkerIsConservativelyRescrubbed(t *testing.T) { + path := filepath.Join(t.TempDir(), "old-v39.db") + st, err := OpenSQLite(path) + if err != nil { + t.Fatal(err) + } + m := extension.ProjectContextManifest{ProjectID: "p1", AttemptID: "old-v39-model", InputSHA256: "input", Delivery: "attempted"} + if err := st.AppendContextManifestWithScope("alice", "run-1", m, ContextManifestScope{Bound: true}); err != nil { + t.Fatal(err) + } + for _, query := range []string{ + `ALTER TABLE context_manifests ADD COLUMN resolve_goal TEXT NOT NULL DEFAULT ''`, + `UPDATE context_manifests SET resolve_goal='OLD_RESTRICTED_BODY'`, + `UPDATE context_manifests SET resolve_goal=''`, + `ALTER TABLE context_manifests DROP COLUMN resolve_goal`, + `DROP TABLE context_scope_scrub_state`, + } { + if _, err := st.db.Exec(query); err != nil { + t.Fatal(err) + } + } + if err := st.Close(); err != nil { + t.Fatal(err) + } + restarted, err := OpenSQLite(path) + if err != nil { + t.Fatal(err) + } + defer restarted.Close() + var pending int + if err := restarted.db.QueryRow(`SELECT pending FROM context_scope_scrub_state WHERE id=1`).Scan(&pending); err != nil || pending != 0 { + t.Fatalf("earlier v39 without marker was not re-scrubbed: pending=%d err=%v", pending, err) + } + for _, suffix := range []string{"", "-wal"} { + data, err := os.ReadFile(path + suffix) + if err == nil && bytes.Contains(data, []byte("OLD_RESTRICTED_BODY")) { + t.Fatalf("old v39 raw bytes survived re-scrub in SQLite %s", suffix) + } + } +} + +func TestCandidateV35PostgresRawScopeScrubAndFreshSchema(t *testing.T) { + st := openTestStore(t) + if st.Dialect() != "postgres" { + t.Skip("isolated PostgreSQL DSN not configured") + } + checkColumns := func(want int) { + t.Helper() + var count int + if err := st.db.QueryRow(`SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=current_schema() AND table_name='context_manifests' AND column_name IN ('resolve_goal','resolve_target_id')`).Scan(&count); err != nil || count != want { + t.Fatalf("raw scope columns=%d want=%d err=%v", count, want, err) + } + } + checkColumns(0) + m := extension.ProjectContextManifest{ProjectID: "p1", AttemptID: "candidate-model", InputSHA256: "input", Delivery: "attempted"} + if err := st.AppendContextManifestWithScope("alice", "run-1", m, ContextManifestScope{Bound: true}); err != nil { + t.Fatal(err) + } + for _, query := range []string{ + `DELETE FROM schema_migrations WHERE version=41`, + `ALTER TABLE context_manifests ADD COLUMN resolve_goal TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE context_manifests ADD COLUMN resolve_target_id TEXT NOT NULL DEFAULT ''`, + `UPDATE context_manifests SET resolve_goal='OLD_RESTRICTED_BODY',resolve_target_id='doc-private'`, + } { + if _, err := st.db.Exec(query); err != nil { + t.Fatal(err) + } + } + checkColumns(2) + if err := st.migrate(); err != nil { + t.Fatal(err) + } + checkColumns(0) + var count int + if err := st.db.QueryRow(`SELECT COUNT(*) FROM context_manifests WHERE attempt_id='candidate-model'`).Scan(&count); err != nil || count != 1 { + t.Fatalf("trusted manifest lost during PG scrub: count=%d err=%v", count, err) + } +} + func TestContextManifestTerminalCannotChangeTrustedResolveScope(t *testing.T) { st, err := OpenSQLite(filepath.Join(t.TempDir(), "scope.db")) if err != nil { @@ -44,14 +262,11 @@ func TestContextManifestTerminalCannotChangeTrustedResolveScope(t *testing.T) { } defer st.Close() m := extension.ProjectContextManifest{ProjectID: "p1", AttemptID: "model-1", WorkflowStepAttemptID: "engine/draft/1", InputSHA256: "input-hash", Delivery: "attempted"} - scope := ContextManifestScope{Goal: "draft from A", Bound: true} + scope := ContextManifestScope{Bound: true} if err := st.AppendContextManifestWithScope("alice", "run-1", m, scope); err != nil { t.Fatal(err) } m.Delivery = "responded" - if err := st.AppendContextManifestWithScope("alice", "run-1", m, ContextManifestScope{Goal: "draft from B", Bound: true}); err == nil { - t.Fatal("terminal receipt changed its trusted Resolve goal") - } if err := st.AppendContextManifest("alice", "run-1", m); err == nil { t.Fatal("terminal receipt dropped its trusted Resolve scope") } diff --git a/internal/store/conversation_account_write.go b/internal/store/conversation_account_write.go new file mode 100644 index 0000000..1a10b62 --- /dev/null +++ b/internal/store/conversation_account_write.go @@ -0,0 +1,35 @@ +package store + +import ( + "database/sql" + "errors" + "time" + + core "agent-system-core" +) + +// CreateConversationAccountChecked creates a conversation only while its actor +// is effective. The project lookup and insert use the same guarded transaction; +// shared-project authorization remains the caller's separate domain decision. +func (s *Store) CreateConversationAccountChecked(actor, projectID, title, profileID string, allowImplicitDefault, requireProjectOwner bool) (core.Conversation, error) { + c := core.Conversation{ID: newID("cnv"), ProjectID: projectID, Owner: actor, Title: title, ProfileID: profileID, + AllowCrossRead: true, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()} + err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + query := s.q(`SELECT 1 FROM projects WHERE id=?`) + args := []any{projectID} + if requireProjectOwner { + query = s.q(`SELECT 1 FROM projects WHERE id=? AND owner=?`) + args = append(args, actor) + } + var one int + if err := tx.QueryRow(query, args...).Scan(&one); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + _, err := tx.Exec(s.q(`INSERT INTO conversations(id,project_id,owner,title,profile_id,created_at,updated_at) VALUES(?,?,?,?,?,?,?)`), + c.ID, c.ProjectID, actor, c.Title, c.ProfileID, nowISO(), nowISO()) + return err + }) + return c, err +} diff --git a/internal/store/conversation_metadata_fence_test.go b/internal/store/conversation_metadata_fence_test.go new file mode 100644 index 0000000..34ba900 --- /dev/null +++ b/internal/store/conversation_metadata_fence_test.go @@ -0,0 +1,167 @@ +package store + +import ( + "errors" + "testing" + "time" +) + +func TestConversationMetadataAccountCheckedStateAndOwner(t *testing.T) { + st := openTestStore(t) + t.Logf("conversation metadata fence dialect=%s", st.Dialect()) + for _, account := range []struct { + owner string + admin bool + }{{"admin", true}, {"alice", false}, {"bob", false}} { + if err := st.CreateAccount(account.owner, "", "", account.admin); err != nil { + t.Fatal(err) + } + } + project, err := st.CreateProject("alice", "p") + if err != nil { + t.Fatal(err) + } + conversation, err := st.CreateConversation("alice", project.ID, "before", "profile-before") + if err != nil { + t.Fatal(err) + } + yes := true + for _, mutate := range []func(string) error{ + func(actor string) error { + return st.UpdateConversationAccountChecked(actor, conversation.ID, "after", "profile-after", false) + }, + func(actor string) error { + return st.SetConversationFlagsAccountChecked(actor, conversation.ID, &yes, nil, nil, nil, false) + }, + func(actor string) error { + return st.SetPermissionModeAccountChecked(actor, conversation.ID, "plan", false) + }, + func(actor string) error { + return st.SetSessionNoteAccountChecked(actor, conversation.ID, "note", false) + }, + func(actor string) error { return st.DeleteConversationAccountChecked(actor, conversation.ID, false) }, + } { + if err := mutate("bob"); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign actor changed conversation: %v", err) + } + if err := mutate("missing"); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("missing actor changed conversation: %v", err) + } + } + if err := st.UpdateConversationAccountChecked("alice", conversation.ID, "after", "profile-after", false); err != nil { + t.Fatal(err) + } + if err := st.SetConversationFlagsAccountChecked("alice", conversation.ID, &yes, nil, nil, nil, false); err != nil { + t.Fatal(err) + } + if err := st.SetPermissionModeAccountChecked("alice", conversation.ID, "plan", false); err != nil { + t.Fatal(err) + } + if err := st.SetSessionNoteAccountChecked("alice", conversation.ID, "note", false); err != nil { + t.Fatal(err) + } + got, err := st.GetConversation("alice", conversation.ID) + if err != nil || got.Title != "after" || got.ProfileID != "profile-after" || !got.Pinned || got.PermissionMode != "plan" || got.SessionNote != "note" { + t.Fatalf("metadata writes did not persist: %+v %v", got, err) + } + if err := st.SetPermissionModeAccountChecked("alice", conversation.ID, "invalid", false); err == nil { + t.Fatal("invalid permission mode was accepted") + } + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + for _, mutate := range []func() error{ + func() error { + return st.UpdateConversationAccountChecked("alice", conversation.ID, "late", "late", false) + }, + func() error { + return st.SetConversationFlagsAccountChecked("alice", conversation.ID, nil, nil, nil, nil, false) + }, + func() error { return st.SetPermissionModeAccountChecked("alice", conversation.ID, "auto_edit", false) }, + func() error { return st.SetSessionNoteAccountChecked("alice", conversation.ID, "late", false) }, + func() error { return st.DeleteConversationAccountChecked("alice", conversation.ID, false) }, + } { + if err := mutate(); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled actor changed conversation: %v", err) + } + } + got, err = st.GetConversation("alice", conversation.ID) + if err != nil || got.Title != "after" || got.ProfileID != "profile-after" || got.PermissionMode != "plan" || got.SessionNote != "note" { + t.Fatalf("disabled metadata write changed conversation: %+v %v", got, err) + } + + bobProject, err := st.CreateProject("bob", "bob project") + if err != nil { + t.Fatal(err) + } + bobConversation, err := st.CreateConversation("bob", bobProject.ID, "temporary", "") + if err != nil { + t.Fatal(err) + } + if err := st.DeleteConversationAccountChecked("bob", bobConversation.ID, false); err != nil { + t.Fatal(err) + } + if _, err := st.GetConversation("bob", bobConversation.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("successful delete left conversation behind: %v", err) + } +} + +func TestPGConversationMetadataFenceSerializesWithDisable(t *testing.T) { + st := openTestStore(t) + if st.Dialect() != "postgres" { + t.Skip("real PostgreSQL required") + } + other := reopenAccountModelStore(t, st) + if err := st.CreateAccount("admin", "", "", true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("alice", "", "", false); err != nil { + t.Fatal(err) + } + project, err := st.CreateProject("alice", "p") + if err != nil { + t.Fatal(err) + } + conversation, err := st.CreateConversation("alice", project.ID, "before", "") + if err != nil { + t.Fatal(err) + } + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + release := holdPGAccountWrite(t, st, "conversations", "UPDATE", "conversation_metadata") + updateDone := make(chan error, 1) + go func() { + updateDone <- st.UpdateConversationAccountChecked("alice", conversation.ID, "after", "", false) + }() + waitForPGAccountWrite(t, st, "conversation_metadata") + disableDone := make(chan error, 1) + go func() { + _, err := other.SetAccountEnabled("admin", account.ID, account.StateVersion, false) + disableDone <- err + }() + select { + case err := <-disableDone: + t.Fatalf("disable committed ahead of paused metadata write: %v", err) + case <-time.After(100 * time.Millisecond): + } + release() + if err := awaitPGAccountResult(t, updateDone); err != nil { + t.Fatal(err) + } + if err := awaitPGAccountResult(t, disableDone); err != nil { + t.Fatal(err) + } + if err := st.SetSessionNoteAccountChecked("alice", conversation.ID, "late", false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("metadata write passed after disable: %v", err) + } + got, err := st.GetConversation("alice", conversation.ID) + if err != nil || got.Title != "after" || got.SessionNote != "" { + t.Fatalf("metadata crossed disable boundary: %+v %v", got, err) + } +} diff --git a/internal/store/dialect.go b/internal/store/dialect.go index 9bf2c84..5c54cea 100644 --- a/internal/store/dialect.go +++ b/internal/store/dialect.go @@ -20,6 +20,8 @@ type dialect interface { // lockBootstrap 锁定首次管理员引导的「核验无用户→建号→激活 Key」串行段 //(KTD12:防两个进程并发引导出双管理员)。 lockBootstrap(tx *sql.Tx) error + // lockAccount serializes credential and session admission for one owner. + lockAccount(tx *sql.Tx, owner string) error } // ---- SQLite(嵌入式档)---- @@ -32,6 +34,7 @@ func (sqliteDialect) lockConversation(*sql.Tx, string) error { return nil } func (sqliteDialect) lockScope(*sql.Tx, string, string) error { return nil } func (sqliteDialect) lockAdmission(*sql.Tx) error { return nil } func (sqliteDialect) lockBootstrap(*sql.Tx) error { return nil } +func (sqliteDialect) lockAccount(*sql.Tx, string) error { return nil } // ---- PostgreSQL(多用户/多实例档)---- @@ -72,3 +75,8 @@ func (postgresDialect) lockBootstrap(tx *sql.Tx) error { _, err := tx.Exec(`SELECT pg_advisory_xact_lock(hashtext($1))`, "bootstrap:first-admin") return err } + +func (postgresDialect) lockAccount(tx *sql.Tx, owner string) error { + _, err := tx.Exec(`SELECT pg_advisory_xact_lock(hashtext($1))`, "account:"+owner) + return err +} diff --git a/internal/store/document_delivery.go b/internal/store/document_delivery.go new file mode 100644 index 0000000..28bcc40 --- /dev/null +++ b/internal/store/document_delivery.go @@ -0,0 +1,475 @@ +package store + +import ( + "crypto/sha256" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "regexp" + "strings" + "time" + + core "agent-system-core" +) + +type DocumentDeliveryState string + +const ( + DocumentDeliveryFrozen DocumentDeliveryState = "frozen" + DocumentDeliverySourceReady DocumentDeliveryState = "source-ready" + DocumentDeliveryFailed DocumentDeliveryState = "failed" + DocumentDeliveryComplete DocumentDeliveryState = "complete" +) + +type DocumentDeliveryArtifactRole string + +const ( + DocumentArtifactMarkdown DocumentDeliveryArtifactRole = "markdown" + DocumentArtifactDOCX DocumentDeliveryArtifactRole = "docx" + DocumentArtifactManifest DocumentDeliveryArtifactRole = "manifest" + DocumentArtifactArchive DocumentDeliveryArtifactRole = "archive" +) + +const ( + DocumentDeliveryFaultAfterMarkdownBlob = "after-markdown-blob" + DocumentDeliveryFaultBeforeMarkdownCommit = "before-markdown-commit" + DocumentDeliveryFaultAfterDOCXBlob = "after-docx-blob" + DocumentDeliveryFaultAfterManifestBlob = "after-manifest-blob" + DocumentDeliveryFaultAfterArchiveBlob = "after-archive-blob" + DocumentDeliveryFaultBeforeDerivedCommit = "before-derived-commit" + DocumentDeliveryFaultAfterDerivedCommit = "after-derived-commit" +) + +var ( + ErrDocumentDeliveryConflict = errors.New("document delivery operation conflicts with its frozen request") + ErrDocumentDeliveryOutcomeUnknown = errors.New("document delivery commit outcome is unknown") + deliveryOperationPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$`) +) + +type DocumentDeliveryBeginInput struct { + OperationID string + RequestSHA256 string + ProjectID string + ConversationID string + SourceRun string + DocumentID string + Mode string + RendererIdentity string +} + +type DocumentDeliveryArtifactInput struct { + Role DocumentDeliveryArtifactRole + Name string + Kind string + MIME string + SHA256 string + Content []byte +} + +type DocumentDeliveryArtifact struct { + Role DocumentDeliveryArtifactRole `json:"role"` + Artifact core.Artifact `json:"artifact"` + SHA256 string `json:"sha256"` +} + +type DocumentDeliveryRecord struct { + OperationID string `json:"operationId"` + Owner string `json:"owner"` + ProjectID string `json:"projectId"` + ConversationID string `json:"conversationId"` + SourceRun string `json:"sourceRun,omitempty"` + RequestSHA256 string `json:"requestSha256"` + State DocumentDeliveryState `json:"state"` + FrozenAt time.Time `json:"frozenAt"` + RendererIdentity string `json:"rendererIdentity"` + Mode string `json:"mode"` + DocumentID string `json:"documentId"` + CanonicalHash string `json:"canonicalHash,omitempty"` + ProfileHash string `json:"profileHash,omitempty"` + ReviewHash string `json:"reviewHash,omitempty"` + DeliveryKey string `json:"deliveryKey,omitempty"` + MarkdownArtifactID string `json:"markdownArtifactId,omitempty"` + Error string `json:"error,omitempty"` + Artifacts []DocumentDeliveryArtifact `json:"artifacts,omitempty"` +} + +type storedDocumentDelivery struct { + DocumentDeliveryRecord + markdownSHA, docxID, docxSHA, manifestID, manifestSHA, archiveID, archiveSHA string +} + +const documentDeliveryColumns = `operation_id,owner,project_id,conversation_id,source_run,request_sha256,state,frozen_at,renderer_identity,mode,document_id,canonical_hash,profile_hash,review_hash,delivery_key,markdown_artifact_id,markdown_sha256,docx_artifact_id,docx_sha256,manifest_artifact_id,manifest_sha256,archive_artifact_id,archive_sha256,error_text` + +func (s *Store) BeginDocumentDeliveryAccountChecked(actor string, input DocumentDeliveryBeginInput, allowImplicitDefault bool) (DocumentDeliveryRecord, error) { + if err := validateDocumentDeliveryBegin(actor, input); err != nil { + return DocumentDeliveryRecord{}, err + } + var record DocumentDeliveryRecord + err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + if err := s.cardResourcesForWrite(tx, actor, input.ProjectID, input.ConversationID); err != nil { + return err + } + stored, found, err := s.documentDeliveryForWrite(tx, actor, input.OperationID) + if err != nil { + return err + } + if found { + if !sameDocumentDeliveryInput(stored.DocumentDeliveryRecord, input) { + return ErrDocumentDeliveryConflict + } + record = stored.DocumentDeliveryRecord + return nil + } + now := time.Now().UTC() + _, err = tx.Exec(s.q(`INSERT INTO document_render_deliveries(operation_id,owner,project_id,conversation_id,source_run,request_sha256,state,frozen_at,renderer_identity,mode,document_id,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`), + input.OperationID, actor, input.ProjectID, input.ConversationID, input.SourceRun, input.RequestSHA256, + DocumentDeliveryFrozen, now.Format(time.RFC3339Nano), input.RendererIdentity, input.Mode, input.DocumentID, + now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano)) + if err == nil { + record = DocumentDeliveryRecord{OperationID: input.OperationID, Owner: actor, ProjectID: input.ProjectID, + ConversationID: input.ConversationID, SourceRun: input.SourceRun, RequestSHA256: input.RequestSHA256, + State: DocumentDeliveryFrozen, FrozenAt: now, RendererIdentity: input.RendererIdentity, Mode: input.Mode, DocumentID: input.DocumentID} + } + return err + }) + if err != nil { + return record, err + } + return s.GetDocumentDelivery(actor, input.OperationID) +} + +func (s *Store) PersistDocumentDeliveryMarkdownAccountChecked(actor, operationID string, artifact DocumentDeliveryArtifactInput, canonicalHash string, allowImplicitDefault bool) (DocumentDeliveryRecord, error) { + if artifact.Role != DocumentArtifactMarkdown || canonicalHash == "" { + return DocumentDeliveryRecord{}, errors.New("document delivery Markdown role and canonical hash are required") + } + if err := validateDeliveryArtifactInput(artifact); err != nil { + return DocumentDeliveryRecord{}, err + } + current, err := s.GetDocumentDelivery(actor, operationID) + if err != nil { + return DocumentDeliveryRecord{}, err + } + if current.MarkdownArtifactID != "" { + return current, nil + } + artifactID := stableDeliveryArtifactID(actor, operationID, artifact.Role) + ref, err := s.blobs.Put(newID("blob"), artifact.Content) + if err != nil { + return current, err + } + if fault := s.takeDocumentDeliveryFault(DocumentDeliveryFaultAfterMarkdownBlob); fault != nil { + _ = s.blobs.Delete(ref) + return current, fault + } + used := false + err = s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + stored, found, err := s.documentDeliveryForWrite(tx, actor, operationID) + if err != nil || !found { + if !found && err == nil { + err = ErrNotFound + } + return err + } + if stored.MarkdownArtifactID != "" { + return nil + } + if fault := s.takeDocumentDeliveryFault(DocumentDeliveryFaultBeforeMarkdownCommit); fault != nil { + return fault + } + now := time.Now().UTC() + if err := insertDeliveryArtifactTx(s, tx, actor, stored.ProjectID, stored.SourceRun, artifactID, ref, artifact, now); err != nil { + return err + } + _, err = tx.Exec(s.q(`UPDATE document_render_deliveries SET state=?,canonical_hash=?,markdown_artifact_id=?,markdown_sha256=?,error_text='',updated_at=? WHERE owner=? AND operation_id=?`), + DocumentDeliverySourceReady, canonicalHash, artifactID, artifact.SHA256, now.Format(time.RFC3339Nano), actor, operationID) + if err == nil { + used = true + } + return err + }) + if !used { + _ = s.blobs.Delete(ref) + } + record, readErr := s.GetDocumentDelivery(actor, operationID) + if err != nil { + if used && readErr == nil { + return record, ErrDocumentDeliveryOutcomeUnknown + } + return current, err + } + return record, readErr +} + +func (s *Store) CommitDocumentDeliveryDerivedAccountChecked(actor, operationID, deliveryKey, profileHash, reviewHash string, artifacts []DocumentDeliveryArtifactInput, allowImplicitDefault bool) (DocumentDeliveryRecord, error) { + if deliveryKey == "" || profileHash == "" || reviewHash == "" { + return DocumentDeliveryRecord{}, errors.New("document delivery key, profile hash, and review hash are required") + } + ordered, err := validateDerivedArtifacts(artifacts) + if err != nil { + return DocumentDeliveryRecord{}, err + } + current, err := s.GetDocumentDelivery(actor, operationID) + if err != nil { + return DocumentDeliveryRecord{}, err + } + if current.State == DocumentDeliveryComplete { + if current.DeliveryKey != deliveryKey || current.ProfileHash != profileHash || current.ReviewHash != reviewHash { + return current, ErrDocumentDeliveryConflict + } + return current, nil + } + if current.MarkdownArtifactID == "" { + return current, errors.New("document delivery has no authoritative Markdown") + } + type stagedArtifact struct { + input DocumentDeliveryArtifactInput + id, ref string + } + staged := make([]stagedArtifact, 0, len(ordered)) + cleanup := func() { + for _, item := range staged { + _ = s.blobs.Delete(item.ref) + } + } + for _, input := range ordered { + ref, err := s.blobs.Put(newID("blob"), input.Content) + if err != nil { + cleanup() + return current, err + } + staged = append(staged, stagedArtifact{input: input, id: stableDeliveryArtifactID(actor, operationID, input.Role), ref: ref}) + stage := map[DocumentDeliveryArtifactRole]string{DocumentArtifactDOCX: DocumentDeliveryFaultAfterDOCXBlob, DocumentArtifactManifest: DocumentDeliveryFaultAfterManifestBlob, DocumentArtifactArchive: DocumentDeliveryFaultAfterArchiveBlob}[input.Role] + if fault := s.takeDocumentDeliveryFault(stage); fault != nil { + cleanup() + return current, fault + } + } + used := false + err = s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + stored, found, err := s.documentDeliveryForWrite(tx, actor, operationID) + if err != nil || !found { + if !found && err == nil { + err = ErrNotFound + } + return err + } + if stored.State == DocumentDeliveryComplete { + if stored.DeliveryKey != deliveryKey || stored.ProfileHash != profileHash || stored.ReviewHash != reviewHash { + return ErrDocumentDeliveryConflict + } + return nil + } + if stored.MarkdownArtifactID == "" { + return errors.New("document delivery has no authoritative Markdown") + } + if fault := s.takeDocumentDeliveryFault(DocumentDeliveryFaultBeforeDerivedCommit); fault != nil { + return fault + } + now := time.Now().UTC() + for _, item := range staged { + if err := insertDeliveryArtifactTx(s, tx, actor, stored.ProjectID, stored.SourceRun, item.id, item.ref, item.input, now); err != nil { + return err + } + } + _, err = tx.Exec(s.q(`UPDATE document_render_deliveries SET state=?,profile_hash=?,review_hash=?,delivery_key=?,docx_artifact_id=?,docx_sha256=?,manifest_artifact_id=?,manifest_sha256=?,archive_artifact_id=?,archive_sha256=?,error_text='',updated_at=? WHERE owner=? AND operation_id=?`), + DocumentDeliveryComplete, profileHash, reviewHash, deliveryKey, + staged[0].id, staged[0].input.SHA256, staged[1].id, staged[1].input.SHA256, staged[2].id, staged[2].input.SHA256, + now.Format(time.RFC3339Nano), actor, operationID) + if err == nil { + used = true + } + return err + }) + if !used { + cleanup() + } + record, readErr := s.GetDocumentDelivery(actor, operationID) + if err != nil { + if used && readErr == nil { + return record, ErrDocumentDeliveryOutcomeUnknown + } + return current, err + } + if fault := s.takeDocumentDeliveryFault(DocumentDeliveryFaultAfterDerivedCommit); fault != nil { + return record, fmt.Errorf("%w: %v", ErrDocumentDeliveryOutcomeUnknown, fault) + } + return record, readErr +} + +func (s *Store) MarkDocumentDeliveryFailedAccountChecked(actor, operationID, message string, allowImplicitDefault bool) (DocumentDeliveryRecord, error) { + err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + stored, found, err := s.documentDeliveryForWrite(tx, actor, operationID) + if err != nil || !found { + if !found && err == nil { + return ErrNotFound + } + return err + } + if stored.State == DocumentDeliveryComplete { + return nil + } + _, err = tx.Exec(s.q(`UPDATE document_render_deliveries SET state=?,error_text=?,updated_at=? WHERE owner=? AND operation_id=?`), + DocumentDeliveryFailed, message, nowISO(), actor, operationID) + return err + }) + record, readErr := s.GetDocumentDelivery(actor, operationID) + if err != nil { + return record, err + } + return record, readErr +} + +func (s *Store) GetDocumentDelivery(owner, operationID string) (DocumentDeliveryRecord, error) { + stored, err := scanDocumentDelivery(s.db.QueryRow(s.q(`SELECT `+documentDeliveryColumns+` FROM document_render_deliveries WHERE owner=? AND operation_id=?`), owner, operationID).Scan) + if errors.Is(err, sql.ErrNoRows) { + return DocumentDeliveryRecord{}, ErrNotFound + } + if err != nil { + return DocumentDeliveryRecord{}, err + } + return s.hydrateDocumentDelivery(stored) +} + +func (s *Store) DocumentDeliveryMarkdown(owner, operationID string) ([]byte, error) { + record, err := s.GetDocumentDelivery(owner, operationID) + if err != nil { + return nil, err + } + if record.MarkdownArtifactID == "" { + return nil, ErrNotFound + } + return s.ArtifactContent(owner, record.MarkdownArtifactID) +} + +func (s *Store) SetDocumentDeliveryFault(stage string, err error) { + s.documentDeliveryMu.Lock() + s.documentDeliveryFaultStage, s.documentDeliveryFault = stage, err + s.documentDeliveryMu.Unlock() +} + +func (s *Store) takeDocumentDeliveryFault(stage string) error { + if stage == "" { + return nil + } + s.documentDeliveryMu.Lock() + defer s.documentDeliveryMu.Unlock() + if s.documentDeliveryFaultStage != stage { + return nil + } + err := s.documentDeliveryFault + s.documentDeliveryFaultStage, s.documentDeliveryFault = "", nil + return err +} + +func (s *Store) documentDeliveryForWrite(tx *sql.Tx, owner, operationID string) (storedDocumentDelivery, bool, error) { + query := s.q(`SELECT ` + documentDeliveryColumns + ` FROM document_render_deliveries WHERE owner=? AND operation_id=?`) + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + stored, err := scanDocumentDelivery(tx.QueryRow(query, owner, operationID).Scan) + if errors.Is(err, sql.ErrNoRows) { + return storedDocumentDelivery{}, false, nil + } + return stored, err == nil, err +} + +func scanDocumentDelivery(scan func(...any) error) (storedDocumentDelivery, error) { + var stored storedDocumentDelivery + var frozen string + err := scan(&stored.OperationID, &stored.Owner, &stored.ProjectID, &stored.ConversationID, &stored.SourceRun, + &stored.RequestSHA256, &stored.State, &frozen, &stored.RendererIdentity, &stored.Mode, &stored.DocumentID, + &stored.CanonicalHash, &stored.ProfileHash, &stored.ReviewHash, &stored.DeliveryKey, + &stored.MarkdownArtifactID, &stored.markdownSHA, &stored.docxID, &stored.docxSHA, + &stored.manifestID, &stored.manifestSHA, &stored.archiveID, &stored.archiveSHA, &stored.Error) + if err == nil { + stored.FrozenAt, err = time.Parse(time.RFC3339Nano, frozen) + } + return stored, err +} + +func (s *Store) hydrateDocumentDelivery(stored storedDocumentDelivery) (DocumentDeliveryRecord, error) { + record := stored.DocumentDeliveryRecord + for _, item := range []struct { + role DocumentDeliveryArtifactRole + id, hash string + }{ + {DocumentArtifactMarkdown, stored.MarkdownArtifactID, stored.markdownSHA}, + {DocumentArtifactDOCX, stored.docxID, stored.docxSHA}, + {DocumentArtifactManifest, stored.manifestID, stored.manifestSHA}, + {DocumentArtifactArchive, stored.archiveID, stored.archiveSHA}, + } { + if item.id == "" { + continue + } + artifact, err := s.GetArtifact(record.Owner, item.id) + if err != nil { + return DocumentDeliveryRecord{}, err + } + record.Artifacts = append(record.Artifacts, DocumentDeliveryArtifact{Role: item.role, Artifact: artifact, SHA256: item.hash}) + } + return record, nil +} + +func validateDocumentDeliveryBegin(actor string, input DocumentDeliveryBeginInput) error { + if actor == "" || !deliveryOperationPattern.MatchString(input.OperationID) || !validDeliveryHash(input.RequestSHA256) || + input.ProjectID == "" || input.ConversationID == "" || input.DocumentID == "" || input.RendererIdentity == "" || + (input.Mode != "working" && input.Mode != "preview") { + return errors.New("invalid document delivery begin input") + } + return nil +} + +func sameDocumentDeliveryInput(record DocumentDeliveryRecord, input DocumentDeliveryBeginInput) bool { + return record.ProjectID == input.ProjectID && record.ConversationID == input.ConversationID && + record.RequestSHA256 == input.RequestSHA256 && record.RendererIdentity == input.RendererIdentity && record.Mode == input.Mode && record.DocumentID == input.DocumentID +} + +func validateDeliveryArtifactInput(input DocumentDeliveryArtifactInput) error { + if input.Name == "" || input.Kind == "" || input.MIME == "" || !validDeliveryHash(input.SHA256) { + return errors.New("invalid document delivery artifact") + } + sum := sha256.Sum256(input.Content) + if hex.EncodeToString(sum[:]) != input.SHA256 { + return errors.New("document delivery artifact hash mismatch") + } + return nil +} + +func validateDerivedArtifacts(inputs []DocumentDeliveryArtifactInput) ([]DocumentDeliveryArtifactInput, error) { + want := map[DocumentDeliveryArtifactRole]bool{DocumentArtifactDOCX: true, DocumentArtifactManifest: true, DocumentArtifactArchive: true} + byRole := make(map[DocumentDeliveryArtifactRole]DocumentDeliveryArtifactInput, len(inputs)) + for _, input := range inputs { + if !want[input.Role] || byRole[input.Role].Role != "" { + return nil, errors.New("derived delivery requires unique DOCX, manifest, and archive artifacts") + } + if err := validateDeliveryArtifactInput(input); err != nil { + return nil, err + } + byRole[input.Role] = input + } + if len(byRole) != len(want) { + return nil, errors.New("derived delivery requires DOCX, manifest, and archive artifacts") + } + return []DocumentDeliveryArtifactInput{byRole[DocumentArtifactDOCX], byRole[DocumentArtifactManifest], byRole[DocumentArtifactArchive]}, nil +} + +func insertDeliveryArtifactTx(s *Store, tx *sql.Tx, owner, projectID, sourceRun, id, ref string, input DocumentDeliveryArtifactInput, now time.Time) error { + _, err := tx.Exec(s.q(`INSERT INTO artifacts(id,project_id,owner,name,kind,mime,size,source_run,content_path,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)`), + id, projectID, owner, input.Name, input.Kind, input.MIME, len(input.Content), sourceRun, ref, + now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano)) + return err +} + +func stableDeliveryArtifactID(owner, operationID string, role DocumentDeliveryArtifactRole) string { + sum := sha256.Sum256([]byte(owner + "\x00" + operationID + "\x00" + string(role))) + return "art_doc_" + hex.EncodeToString(sum[:12]) +} + +func validDeliveryHash(value string) bool { + if len(value) != 64 || strings.ToLower(value) != value { + return false + } + _, err := hex.DecodeString(value) + return err == nil +} diff --git a/internal/store/document_delivery_test.go b/internal/store/document_delivery_test.go new file mode 100644 index 0000000..e3ddcba --- /dev/null +++ b/internal/store/document_delivery_test.go @@ -0,0 +1,229 @@ +package store + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "path/filepath" + "reflect" + "testing" + + core "agent-system-core" +) + +func TestDocumentDeliveryFreezesOnceAndRejectsOperationConflict(t *testing.T) { + st := openTestStore(t) + conversation := seedConversation(t, st) + input := deliveryBeginFixture(conversation, "operation-freeze") + + first, err := st.BeginDocumentDeliveryAccountChecked(OwnerDefault, input, true) + if err != nil { + t.Fatal(err) + } + second, err := st.BeginDocumentDeliveryAccountChecked(OwnerDefault, input, true) + if err != nil { + t.Fatal(err) + } + if first.FrozenAt.IsZero() || !first.FrozenAt.Equal(second.FrozenAt) || first.State != DocumentDeliveryFrozen { + t.Fatalf("freeze records differ: first=%#v second=%#v", first, second) + } + + conflict := input + conflict.RequestSHA256 = hashText("different request") + if _, err := st.BeginDocumentDeliveryAccountChecked(OwnerDefault, conflict, true); !errors.Is(err, ErrDocumentDeliveryConflict) { + t.Fatalf("conflicting operation error = %v, want ErrDocumentDeliveryConflict", err) + } +} + +func TestDocumentDeliveryPersistsMarkdownFirstAndCommitsDerivedSetAtomically(t *testing.T) { + st := openTestStore(t) + conversation := seedConversation(t, st) + input := deliveryBeginFixture(conversation, "operation-complete") + record, err := st.BeginDocumentDeliveryAccountChecked(OwnerDefault, input, true) + if err != nil { + t.Fatal(err) + } + markdown := deliveryArtifactFixture(DocumentArtifactMarkdown, "report.md", "text/markdown; charset=utf-8", []byte("# report\n")) + record, err = st.PersistDocumentDeliveryMarkdownAccountChecked(OwnerDefault, input.OperationID, markdown, hashText("canonical"), true) + if err != nil { + t.Fatal(err) + } + if record.State != DocumentDeliverySourceReady || len(record.Artifacts) != 1 || record.Artifacts[0].Role != DocumentArtifactMarkdown { + t.Fatalf("source receipt = %#v", record) + } + if raw, err := st.ArtifactContent(OwnerDefault, record.Artifacts[0].Artifact.ID); err != nil || string(raw) != "# report\n" { + t.Fatalf("persisted Markdown = %q err=%v", raw, err) + } + + derived := []DocumentDeliveryArtifactInput{ + deliveryArtifactFixture(DocumentArtifactDOCX, "report.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", []byte("docx")), + deliveryArtifactFixture(DocumentArtifactManifest, "report.manifest.json", "application/json", []byte("manifest")), + deliveryArtifactFixture(DocumentArtifactArchive, "report.zip", "application/zip", []byte("archive")), + } + record, err = st.CommitDocumentDeliveryDerivedAccountChecked(OwnerDefault, input.OperationID, "delivery-key", "profile-hash", "review-hash", derived, true) + if err != nil { + t.Fatal(err) + } + if record.State != DocumentDeliveryComplete || len(record.Artifacts) != 4 || record.DeliveryKey != "delivery-key" { + t.Fatalf("complete receipt = %#v", record) + } + firstIDs := deliveryArtifactIDs(record.Artifacts) + retry, err := st.CommitDocumentDeliveryDerivedAccountChecked(OwnerDefault, input.OperationID, "delivery-key", "profile-hash", "review-hash", derived, true) + if err != nil || !reflect.DeepEqual(firstIDs, deliveryArtifactIDs(retry.Artifacts)) { + t.Fatalf("idempotent derived retry = %#v err=%v", retry, err) + } + artifacts, err := st.ListArtifacts(OwnerDefault, string(conversation.ProjectID)) + if err != nil || len(artifacts) != 4 { + t.Fatalf("visible artifact set = %d err=%v", len(artifacts), err) + } +} + +func TestDocumentDeliveryFailureAfterEveryWriteIsDurableAndRetryable(t *testing.T) { + stages := []string{ + DocumentDeliveryFaultAfterMarkdownBlob, + DocumentDeliveryFaultBeforeMarkdownCommit, + DocumentDeliveryFaultAfterDOCXBlob, + DocumentDeliveryFaultAfterManifestBlob, + DocumentDeliveryFaultAfterArchiveBlob, + DocumentDeliveryFaultBeforeDerivedCommit, + DocumentDeliveryFaultAfterDerivedCommit, + } + for _, stage := range stages { + t.Run(stage, func(t *testing.T) { + st := openTestStore(t) + conversation := seedConversation(t, st) + operationID := "fault-" + stage + input := deliveryBeginFixture(conversation, operationID) + if _, err := st.BeginDocumentDeliveryAccountChecked(OwnerDefault, input, true); err != nil { + t.Fatal(err) + } + markdown := deliveryArtifactFixture(DocumentArtifactMarkdown, "fault.md", "text/markdown", []byte("source")) + derived := []DocumentDeliveryArtifactInput{ + deliveryArtifactFixture(DocumentArtifactDOCX, "fault.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", []byte("docx")), + deliveryArtifactFixture(DocumentArtifactManifest, "fault.manifest.json", "application/json", []byte("manifest")), + deliveryArtifactFixture(DocumentArtifactArchive, "fault.zip", "application/zip", []byte("archive")), + } + st.SetDocumentDeliveryFault(stage, errors.New("injected "+stage)) + var receipt DocumentDeliveryRecord + var err error + if stage == DocumentDeliveryFaultAfterMarkdownBlob || stage == DocumentDeliveryFaultBeforeMarkdownCommit { + receipt, err = st.PersistDocumentDeliveryMarkdownAccountChecked(OwnerDefault, operationID, markdown, hashText("canonical"), true) + } else { + if _, err = st.PersistDocumentDeliveryMarkdownAccountChecked(OwnerDefault, operationID, markdown, hashText("canonical"), true); err != nil { + t.Fatal(err) + } + receipt, err = st.CommitDocumentDeliveryDerivedAccountChecked(OwnerDefault, operationID, "delivery-key", "profile-hash", "review-hash", derived, true) + } + if err == nil || receipt.OperationID != operationID { + t.Fatalf("fault receipt=%#v err=%v", receipt, err) + } + if stage == DocumentDeliveryFaultAfterDerivedCommit { + if !errors.Is(err, ErrDocumentDeliveryOutcomeUnknown) || receipt.State != DocumentDeliveryComplete { + t.Fatalf("post-commit outcome=%#v err=%v", receipt, err) + } + } else if stage != DocumentDeliveryFaultAfterMarkdownBlob && stage != DocumentDeliveryFaultBeforeMarkdownCommit && receipt.MarkdownArtifactID == "" { + t.Fatalf("derived failure lost authoritative Markdown: %#v", receipt) + } + visible, listErr := st.ListArtifacts(OwnerDefault, string(conversation.ProjectID)) + wantVisible := 1 + if stage == DocumentDeliveryFaultAfterMarkdownBlob || stage == DocumentDeliveryFaultBeforeMarkdownCommit { + wantVisible = 0 + } + if stage == DocumentDeliveryFaultAfterDerivedCommit { + wantVisible = 4 + } + if listErr != nil || len(visible) != wantVisible { + t.Fatalf("failure exposed %d artifacts, want %d: %v", len(visible), wantVisible, listErr) + } + + st.SetDocumentDeliveryFault("", nil) + if receipt.MarkdownArtifactID == "" { + if _, err := st.PersistDocumentDeliveryMarkdownAccountChecked(OwnerDefault, operationID, markdown, hashText("canonical"), true); err != nil { + t.Fatal(err) + } + } + complete, err := st.CommitDocumentDeliveryDerivedAccountChecked(OwnerDefault, operationID, "delivery-key", "profile-hash", "review-hash", derived, true) + if err != nil || complete.State != DocumentDeliveryComplete || len(complete.Artifacts) != 4 { + t.Fatalf("retry did not complete: %#v err=%v", complete, err) + } + }) + } +} + +func TestDocumentDeliveryRetryAfterRestartReturnsSameIDsAndHashes(t *testing.T) { + directory := t.TempDir() + database := filepath.Join(directory, "delivery.db") + st, err := OpenSQLite(database) + if err != nil { + t.Fatal(err) + } + project, err := st.CreateProject(OwnerDefault, "delivery") + if err != nil { + t.Fatal(err) + } + conversation, err := st.CreateConversation(OwnerDefault, project.ID, "delivery", "") + if err != nil { + t.Fatal(err) + } + input := deliveryBeginFixture(conversation, "restart-operation") + if _, err := st.BeginDocumentDeliveryAccountChecked(OwnerDefault, input, true); err != nil { + t.Fatal(err) + } + markdown := deliveryArtifactFixture(DocumentArtifactMarkdown, "restart.md", "text/markdown", []byte("source")) + if _, err := st.PersistDocumentDeliveryMarkdownAccountChecked(OwnerDefault, input.OperationID, markdown, hashText("canonical"), true); err != nil { + t.Fatal(err) + } + derived := []DocumentDeliveryArtifactInput{ + deliveryArtifactFixture(DocumentArtifactDOCX, "restart.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", []byte("docx")), + deliveryArtifactFixture(DocumentArtifactManifest, "restart.manifest.json", "application/json", []byte("manifest")), + deliveryArtifactFixture(DocumentArtifactArchive, "restart.zip", "application/zip", []byte("archive")), + } + first, err := st.CommitDocumentDeliveryDerivedAccountChecked(OwnerDefault, input.OperationID, "restart-key", "profile", "review", derived, true) + if err != nil { + t.Fatal(err) + } + if err := st.Close(); err != nil { + t.Fatal(err) + } + + reopened, err := OpenSQLite(database) + if err != nil { + t.Fatal(err) + } + defer reopened.Close() + begin, err := reopened.BeginDocumentDeliveryAccountChecked(OwnerDefault, input, true) + if err != nil { + t.Fatal(err) + } + retry, err := reopened.CommitDocumentDeliveryDerivedAccountChecked(OwnerDefault, input.OperationID, "restart-key", "profile", "review", derived, true) + if err != nil || !begin.FrozenAt.Equal(first.FrozenAt) || !reflect.DeepEqual(deliveryArtifactIDs(first.Artifacts), deliveryArtifactIDs(retry.Artifacts)) { + t.Fatalf("restart retry changed identity: first=%#v begin=%#v retry=%#v err=%v", first, begin, retry, err) + } +} + +func deliveryBeginFixture(conversation core.Conversation, operationID string) DocumentDeliveryBeginInput { + return DocumentDeliveryBeginInput{ + OperationID: operationID, RequestSHA256: hashText("request-" + operationID), + ProjectID: string(conversation.ProjectID), ConversationID: string(conversation.ID), + SourceRun: "run-1", DocumentID: "document-1", Mode: "preview", RendererIdentity: "renderer@revision", + } +} + +func deliveryArtifactFixture(role DocumentDeliveryArtifactRole, name, mime string, content []byte) DocumentDeliveryArtifactInput { + return DocumentDeliveryArtifactInput{Role: role, Name: name, Kind: "document", MIME: mime, SHA256: hashBytesForTest(content), Content: content} +} + +func deliveryArtifactIDs(artifacts []DocumentDeliveryArtifact) map[DocumentDeliveryArtifactRole]string { + ids := make(map[DocumentDeliveryArtifactRole]string, len(artifacts)) + for _, artifact := range artifacts { + ids[artifact.Role] = string(artifact.Artifact.ID) + ":" + artifact.SHA256 + } + return ids +} + +func hashText(value string) string { return hashBytesForTest([]byte(value)) } + +func hashBytesForTest(value []byte) string { + sum := sha256.Sum256(value) + return hex.EncodeToString(sum[:]) +} diff --git a/internal/store/durability.go b/internal/store/durability.go new file mode 100644 index 0000000..b295b3c --- /dev/null +++ b/internal/store/durability.go @@ -0,0 +1,67 @@ +package store + +import ( + "fmt" + "strings" +) + +// DurabilityRefusal 判断 PostgreSQL 耐久参数。off 或缺失即拒绝。 +// synchronous_commit=local 不是 off,不拒绝。 +func DurabilityRefusal(settings map[string]string) error { + for _, key := range []string{"fsync", "full_page_writes", "synchronous_commit"} { + value, ok := settings[key] + if !ok || strings.EqualFold(strings.TrimSpace(value), "off") { + return fmt.Errorf("durability: %s=%q", key, value) + } + } + return nil +} + +func (s *Store) verifyDurability() error { + switch s.dialect.name() { + case "sqlite": + return s.verifySQLiteDurability() + case "postgres": + return s.verifyPostgresDurability() + default: + return fmt.Errorf("durability: unknown dialect %s", s.dialect.name()) + } +} + +func (s *Store) verifySQLiteDurability() error { + var journal string + if err := s.db.QueryRow(`PRAGMA journal_mode`).Scan(&journal); err != nil { + return fmt.Errorf("durability: journal_mode: %w", err) + } + var syncLevel int64 + if err := s.db.QueryRow(`PRAGMA synchronous`).Scan(&syncLevel); err != nil { + return fmt.Errorf("durability: synchronous: %w", err) + } + if !strings.EqualFold(journal, "wal") || syncLevel != 2 { + return fmt.Errorf("durability: journal_mode=%s synchronous=%d", journal, syncLevel) + } + return nil +} + +func (s *Store) verifyPostgresDurability() error { + rows, err := s.db.Query(`SELECT name, setting FROM pg_settings WHERE name IN ('fsync','full_page_writes','synchronous_commit')`) + if err != nil { + return fmt.Errorf("durability: pg_settings: %w", err) + } + defer rows.Close() + got := map[string]string{} + for rows.Next() { + var name, setting string + if err := rows.Scan(&name, &setting); err != nil { + return err + } + got[name] = setting + } + if err := rows.Err(); err != nil { + return err + } + if err := DurabilityRefusal(got); err != nil { + return err + } + return nil +} diff --git a/internal/store/durability_test.go b/internal/store/durability_test.go new file mode 100644 index 0000000..5f7beac --- /dev/null +++ b/internal/store/durability_test.go @@ -0,0 +1,95 @@ +package store + +import ( + "os" + "path/filepath" + "strings" + "testing" + + core "agent-system-core" +) + +func TestDurabilityRefusal(t *testing.T) { + ok := map[string]string{ + "fsync": "on", "full_page_writes": "on", "synchronous_commit": "local", + } + if err := DurabilityRefusal(ok); err != nil { + t.Fatal(err) + } + for _, key := range []string{"fsync", "full_page_writes", "synchronous_commit"} { + bad := map[string]string{ + "fsync": "on", "full_page_writes": "on", "synchronous_commit": "on", + } + bad[key] = "off" + if err := DurabilityRefusal(bad); err == nil { + t.Fatalf("%s off should refuse", key) + } + delete(bad, key) + if err := DurabilityRefusal(bad); err == nil { + t.Fatalf("missing %s should refuse", key) + } + } +} + +func TestSQLitePragmaReadback(t *testing.T) { + st, err := OpenSQLite(filepath.Join(t.TempDir(), "agent-system.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + var journal string + if err := st.db.QueryRow(`PRAGMA journal_mode`).Scan(&journal); err != nil { + t.Fatal(err) + } + var syncLevel int64 + if err := st.db.QueryRow(`PRAGMA synchronous`).Scan(&syncLevel); err != nil { + t.Fatal(err) + } + if !strings.EqualFold(journal, "wal") || syncLevel != 2 { + t.Fatalf("journal=%s synchronous=%d", journal, syncLevel) + } + marker, ok, err := st.LoadInstanceGuard() + if err != nil || ok || marker.InstanceID != "" { + t.Fatalf("fresh db has no guard row: ok=%v err=%v", ok, err) + } +} + +func TestOpenExistingDoesNotCreateMissingParent(t *testing.T) { + missing := filepath.Join(t.TempDir(), "absent", "agent-system.db") + if _, err := OpenExistingSQLite(missing); err == nil { + t.Fatal("missing file opened") + } + if _, err := os.Stat(filepath.Dir(missing)); !os.IsNotExist(err) { + t.Fatalf("parent created: %v", err) + } +} + +func TestRunSurvivesReopen(t *testing.T) { + path := filepath.Join(t.TempDir(), "agent-system.db") + st, err := OpenSQLite(path) + if err != nil { + t.Fatal(err) + } + run := &core.Run{ + ID: "run_keep", ConversationID: "c", ProjectID: "p", Owner: "o", + Kind: core.RunConversation, Status: core.RunWaitingHITL, + } + if err := st.CreateRun(run); err != nil { + t.Fatal(err) + } + if err := st.Close(); err != nil { + t.Fatal(err) + } + st, err = OpenSQLite(path) + if err != nil { + t.Fatal(err) + } + defer st.Close() + got, err := st.GetRunByID("run_keep") + if err != nil { + t.Fatal(err) + } + if got.Status != core.RunWaitingHITL { + t.Fatalf("status %s", got.Status) + } +} diff --git a/internal/store/durability_unix_test.go b/internal/store/durability_unix_test.go new file mode 100644 index 0000000..14c4f87 --- /dev/null +++ b/internal/store/durability_unix_test.go @@ -0,0 +1,35 @@ +//go:build unix + +package store + +import ( + "os/signal" + "path/filepath" + "strings" + "syscall" + "testing" +) + +func TestWriteFailureIsReturned(t *testing.T) { + st, err := OpenSQLite(filepath.Join(t.TempDir(), "agent-system.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + signal.Ignore(syscall.SIGXFSZ) + t.Cleanup(func() { signal.Reset(syscall.SIGXFSZ) }) + var lim syscall.Rlimit + if err := syscall.Getrlimit(syscall.RLIMIT_FSIZE, &lim); err != nil { + t.Fatal(err) + } + old := lim + lim.Cur = 4096 + if err := syscall.Setrlimit(syscall.RLIMIT_FSIZE, &lim); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = syscall.Setrlimit(syscall.RLIMIT_FSIZE, &old) }) + _, err = st.CreateProject("owner", strings.Repeat("x", 64*1024)) + if err == nil { + t.Fatal("oversized write returned success") + } +} diff --git a/internal/store/exec_env.go b/internal/store/exec_env.go new file mode 100644 index 0000000..535566e --- /dev/null +++ b/internal/store/exec_env.go @@ -0,0 +1,149 @@ +package store + +import ( + "database/sql" + "encoding/json" + "errors" +) + +// ExecEnvRequest 是一次扩展包申请(分层 Python 环境)。 +type ExecEnvRequest struct { + ID string + ConversationID string + Owner string + Spec string + Reason string + Status string + LayerID string + Detail string + Approver string + CreatedAt string + UpdatedAt string +} + +// ExecEnvBinding 是会话挂载的层。Layers 是扩展层 ID;Isolated 非空时替换基础层。 +type ExecEnvBinding struct { + ConversationID string + BaseID string + Layers []string + Isolated string + UpdatedAt string +} + +func (s *Store) InsertExecEnvRequest(r ExecEnvRequest) error { + _, err := s.db.Exec(s.q(`INSERT INTO exec_env_requests( + id,conversation_id,owner,spec,reason,status,layer_id,detail,approver,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?)`), + r.ID, r.ConversationID, r.Owner, r.Spec, r.Reason, r.Status, r.LayerID, r.Detail, r.Approver, r.CreatedAt, r.UpdatedAt) + return err +} + +func (s *Store) GetExecEnvRequest(id string) (ExecEnvRequest, error) { + var r ExecEnvRequest + err := s.db.QueryRow(s.q(`SELECT id,conversation_id,owner,spec,reason,status,layer_id,detail,approver,created_at,updated_at + FROM exec_env_requests WHERE id=?`), id).Scan( + &r.ID, &r.ConversationID, &r.Owner, &r.Spec, &r.Reason, &r.Status, &r.LayerID, &r.Detail, &r.Approver, &r.CreatedAt, &r.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return ExecEnvRequest{}, ErrNotFound + } + return r, err +} + +func (s *Store) ListExecEnvRequests(conversationID string, limit int) ([]ExecEnvRequest, error) { + if limit <= 0 { + limit = 20 + } + q := `SELECT id,conversation_id,owner,spec,reason,status,layer_id,detail,approver,created_at,updated_at + FROM exec_env_requests` + var args []any + if conversationID != "" { + q += ` WHERE conversation_id=?` + args = append(args, conversationID) + } + q += ` ORDER BY created_at DESC LIMIT ?` + args = append(args, limit) + rows, err := s.db.Query(s.q(q), args...) + if err != nil { + return nil, err + } + defer rows.Close() + return scanExecEnvRequests(rows) +} + +func scanExecEnvRequests(rows *sql.Rows) ([]ExecEnvRequest, error) { + var out []ExecEnvRequest + for rows.Next() { + var r ExecEnvRequest + if err := rows.Scan(&r.ID, &r.ConversationID, &r.Owner, &r.Spec, &r.Reason, &r.Status, &r.LayerID, &r.Detail, &r.Approver, &r.CreatedAt, &r.UpdatedAt); err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} + +func (s *Store) UpdateExecEnvRequest(id, status, layerID, detail, approver string) error { + res, err := s.db.Exec(s.q(`UPDATE exec_env_requests SET status=?, layer_id=?, detail=?, approver=?, updated_at=? WHERE id=?`), + status, layerID, detail, approver, nowISO(), id) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return ErrNotFound + } + return nil +} + +func (s *Store) GetExecEnvBinding(conversationID string) (ExecEnvBinding, error) { + var b ExecEnvBinding + var layers string + err := s.db.QueryRow(s.q(`SELECT conversation_id,base_id,layers,isolated,updated_at FROM exec_env_bindings WHERE conversation_id=?`), conversationID). + Scan(&b.ConversationID, &b.BaseID, &layers, &b.Isolated, &b.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return ExecEnvBinding{}, nil + } + if err != nil { + return ExecEnvBinding{}, err + } + _ = json.Unmarshal([]byte(layers), &b.Layers) + return b, nil +} + +func (s *Store) PutExecEnvBinding(b ExecEnvBinding) error { + raw, err := json.Marshal(b.Layers) + if err != nil { + return err + } + _, err = s.db.Exec(s.q(`INSERT INTO exec_env_bindings(conversation_id,base_id,layers,isolated,updated_at) + VALUES(?,?,?,?,?) + ON CONFLICT(conversation_id) DO UPDATE SET base_id=excluded.base_id, layers=excluded.layers, isolated=excluded.isolated, updated_at=excluded.updated_at`), + b.ConversationID, b.BaseID, string(raw), b.Isolated, b.UpdatedAt) + return err +} + +func (s *Store) ListExecEnvBindings() ([]ExecEnvBinding, error) { + rows, err := s.db.Query(s.q(`SELECT conversation_id,base_id,layers,isolated,updated_at FROM exec_env_bindings`)) + if err != nil { + return nil, err + } + defer rows.Close() + var out []ExecEnvBinding + for rows.Next() { + var b ExecEnvBinding + var layers string + if err := rows.Scan(&b.ConversationID, &b.BaseID, &layers, &b.Isolated, &b.UpdatedAt); err != nil { + return nil, err + } + _ = json.Unmarshal([]byte(layers), &b.Layers) + out = append(out, b) + } + return out, rows.Err() +} + +func (s *Store) InsertExecEnvAudit(owner, action, actor, target, outcome, entry string) error { + _, err := s.db.Exec(s.q(`INSERT INTO audit_events(time,owner,action,actor,target,evidence,trace_id,entry,instance_id,operation_id,outcome) + VALUES(?,?,?,?,?,?,?,?,?,?,?)`), + nowISO(), owner, action, actor, target, entry, "", "execenv", s.instanceID, "", outcome) + return err +} diff --git a/internal/store/execution.go b/internal/store/execution.go index a2f761d..c7b4ab1 100644 --- a/internal/store/execution.go +++ b/internal/store/execution.go @@ -37,9 +37,6 @@ type ExecutionState struct { // TryClaimExecution 占用(或租约到期后接管)会话执行权。 // 返回接管后的 epoch(接管必然 +1)。被他人持有且未到期 → ErrExecutionBusy。 func (s *Store) TryClaimExecution(conversationID, instanceID, runID string, leaseSec int) (int64, error) { - if leaseSec <= 0 { - leaseSec = 90 - } tx, err := s.db.Begin() if err != nil { return 0, err @@ -48,20 +45,63 @@ func (s *Store) TryClaimExecution(conversationID, instanceID, runID string, leas if err := s.lockAdmission(tx); err != nil { // 复用全局账本锁串行化清点 return 0, err } + epoch, err := s.claimExecutionTx(tx, conversationID, instanceID, runID, leaseSec) + if err != nil { + return epoch, err + } + if err := tx.Commit(); err != nil { + return 0, err + } + return epoch, nil +} + +// TryClaimExecutionAccountChecked locks the actor before the admission ledger +// and verifies the conversation/run owner in the same final write transaction. +func (s *Store) TryClaimExecutionAccountChecked(actor, conversationID, instanceID, runID string, leaseSec int, allowImplicitDefault bool) (int64, error) { + var epoch int64 + err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + if err := s.lockAdmission(tx); err != nil { + return err + } + var owner string + if err := tx.QueryRow(s.q(`SELECT owner FROM conversations WHERE id=?`), conversationID).Scan(&owner); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + if owner != actor { + return ErrNotFound + } + if runID != "" { + var one int + if err := tx.QueryRow(s.q(`SELECT 1 FROM runs WHERE id=? AND owner=? AND conversation_id=?`), runID, actor, conversationID).Scan(&one); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + } + var err error + epoch, err = s.claimExecutionTx(tx, conversationID, instanceID, runID, leaseSec) + return err + }) + return epoch, err +} + +func (s *Store) claimExecutionTx(tx *sql.Tx, conversationID, instanceID, runID string, leaseSec int) (int64, error) { + if leaseSec <= 0 { + leaseSec = 90 + } now := time.Now().UTC() until := now.Add(time.Duration(leaseSec) * time.Second).UTC().Format(time.RFC3339Nano) var inst, lease string var epoch int64 - err = tx.QueryRow(s.q(`SELECT instance_id, epoch, lease_until FROM conversation_executions WHERE conversation_id=?`), conversationID).Scan(&inst, &epoch, &lease) + err := tx.QueryRow(s.q(`SELECT instance_id, epoch, lease_until FROM conversation_executions WHERE conversation_id=?`), conversationID).Scan(&inst, &epoch, &lease) switch { case errors.Is(err, sql.ErrNoRows): if _, err := tx.Exec(s.q(`INSERT INTO conversation_executions (conversation_id, instance_id, run_id, epoch, lease_until, updated_at) VALUES (?,?,?,1,?,?)`), conversationID, instanceID, runID, until, nowISO()); err != nil { return 0, err } - if err := tx.Commit(); err != nil { - return 0, err - } return 1, nil case err != nil: return 0, err @@ -74,9 +114,6 @@ func (s *Store) TryClaimExecution(conversationID, instanceID, runID string, leas instanceID, runID, until, nowISO(), conversationID); err != nil { return 0, err } - if err := tx.Commit(); err != nil { - return 0, err - } return epoch + 1, nil } @@ -95,6 +132,39 @@ func (s *Store) RenewExecution(conversationID, instanceID string, epoch int64, l return n > 0 } +// RenewExecutionAccountChecked only extends the lease while the conversation +// owner is effective in the same transaction as the conditional UPDATE. +func (s *Store) RenewExecutionAccountChecked(actor, conversationID, instanceID string, epoch int64, leaseSec int, allowImplicitDefault bool) bool { + if leaseSec <= 0 { + leaseSec = 90 + } + until := time.Now().UTC().Add(time.Duration(leaseSec) * time.Second).UTC().Format(time.RFC3339Nano) + updated := false + err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + var owner string + if err := tx.QueryRow(s.q(`SELECT owner FROM conversations WHERE id=?`), conversationID).Scan(&owner); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + if owner != actor { + return ErrNotFound + } + res, err := tx.Exec(s.q(`UPDATE conversation_executions SET lease_until=?, updated_at=? WHERE conversation_id=? AND instance_id=? AND epoch=?`), + until, nowISO(), conversationID, instanceID, epoch) + if err != nil { + return err + } + n, err := res.RowsAffected() + if err != nil { + return err + } + updated = n == 1 + return nil + }) + return err == nil && updated +} + // ReleaseExecution 主动释放(优雅停机):行保留、租约置为已过期——epoch 保留, // 下一次占用仍递增(fencing 单调)。 func (s *Store) ReleaseExecution(conversationID, instanceID string, epoch int64) { @@ -173,6 +243,28 @@ func (s *Store) EnqueueCommand(commandID, kind, runID, conversationID string, ep return nil } +// EnqueueCommandAccountChecked resolves the run owner and conversation from +// persisted rows in the final account-guarded transaction. A client-supplied +// run ID can locate a command target but cannot authorize another owner's run. +func (s *Store) EnqueueCommandAccountChecked(actor, commandID, kind, runID string, epoch int64, payload any, allowImplicitDefault bool) error { + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + var owner, conversationID string + query := s.q(`SELECT owner,conversation_id FROM runs WHERE id=?`) + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + if err := tx.QueryRow(query, runID).Scan(&owner, &conversationID); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + if owner != actor { + return ErrNotFound + } + return s.EnqueueCommandInTx(tx, commandID, kind, runID, conversationID, epoch, payload) + }) +} + // ClaimPendingCommands 认领待处理命令(原子 pending→consumed)。 // PG:FOR UPDATE SKIP LOCKED(多实例并发认领不重复);SQLite:单写者条件更新。 // 只认领「本实例相关」的命令由调用方过滤——收件箱按执行权路由:命令在 diff --git a/internal/store/execution_account_write_test.go b/internal/store/execution_account_write_test.go new file mode 100644 index 0000000..38fa879 --- /dev/null +++ b/internal/store/execution_account_write_test.go @@ -0,0 +1,152 @@ +package store + +import ( + "errors" + "path/filepath" + "testing" + + core "agent-system-core" +) + +type executionAccountWriter interface { + TryClaimExecutionAccountChecked(actor, conversationID, instanceID, runID string, leaseSec int, allowImplicitDefault bool) (int64, error) + RenewExecutionAccountChecked(actor, conversationID, instanceID string, epoch int64, leaseSec int, allowImplicitDefault bool) bool + TryAcquireExecutionSlotAccountChecked(actor, runID string, budget ExecutionBudget, allowImplicitDefault bool) error +} + +func executionWriter(t *testing.T, st *Store) executionAccountWriter { + t.Helper() + w, ok := any(st).(executionAccountWriter) + if !ok { + t.Fatal("Store is missing checked execution lease/admission APIs") + } + return w +} + +func executionFenceFixture(t *testing.T) (*Store, core.Conversation, core.Run) { + t.Helper() + st, err := OpenSQLite(filepath.Join(t.TempDir(), "execution.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = st.Close() }) + for _, owner := range []string{"alice", "bob"} { + if err := st.EnsureUser(owner, ""); err != nil { + t.Fatal(err) + } + } + p, err := st.CreateProject("alice", "p") + if err != nil { + t.Fatal(err) + } + conv, err := st.CreateConversation("alice", p.ID, "c", "") + if err != nil { + t.Fatal(err) + } + run := core.Run{ID: "run-exec-account", Owner: "alice", ConversationID: conv.ID, ProjectID: p.ID, Status: core.RunPending} + if err := st.CreateRun(&run); err != nil { + t.Fatal(err) + } + return st, conv, run +} + +func TestExecutionAccountFenceClaimRejectsDisabledAndForeignActor(t *testing.T) { + st, conv, _ := executionFenceFixture(t) + w := executionWriter(t, st) + if _, err := w.TryClaimExecutionAccountChecked("bob", conv.ID, "instance", "", 60, false); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign claim: got %v, want ErrNotFound", err) + } + if _, err := st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner='alice'`); err != nil { + t.Fatal(err) + } + if _, err := w.TryClaimExecutionAccountChecked("alice", conv.ID, "instance", "", 60, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled claim: got %v, want ErrAccountForbidden", err) + } + if _, err := st.GetExecution(conv.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("rejected claim left execution lease: %v", err) + } +} + +func TestExecutionAccountFenceRenewDoesNotExtendAfterDisable(t *testing.T) { + st, conv, run := executionFenceFixture(t) + w := executionWriter(t, st) + epoch, err := w.TryClaimExecutionAccountChecked("alice", conv.ID, "instance", run.ID, 60, false) + if err != nil || epoch != 1 { + t.Fatalf("initial claim: epoch=%d err=%v", epoch, err) + } + before, err := st.GetExecution(conv.ID) + if err != nil { + t.Fatal(err) + } + if _, err := st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner='alice'`); err != nil { + t.Fatal(err) + } + if w.RenewExecutionAccountChecked("alice", conv.ID, "instance", epoch, 180, false) { + t.Fatal("disabled actor renewed execution") + } + after, err := st.GetExecution(conv.ID) + if err != nil || after.LeaseUntil != before.LeaseUntil || after.Epoch != before.Epoch { + t.Fatalf("disabled renew changed lease: before=%+v after=%+v err=%v", before, after, err) + } +} + +func TestExecutionAccountFenceSlotRejectsDisabledForeignRunAndBudgetOff(t *testing.T) { + st, _, run := executionFenceFixture(t) + w := executionWriter(t, st) + budget := ExecutionBudget{Global: 2, PerUser: 1} + if err := w.TryAcquireExecutionSlotAccountChecked("bob", run.ID, budget, false); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign run slot: got %v, want ErrNotFound", err) + } + if _, err := st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner='alice'`); err != nil { + t.Fatal(err) + } + if err := w.TryAcquireExecutionSlotAccountChecked("alice", run.ID, budget, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled slot: got %v, want ErrAccountForbidden", err) + } + if err := w.TryAcquireExecutionSlotAccountChecked("alice", run.ID, ExecutionBudget{}, false); err != nil { + t.Fatalf("off budget should remain a no-write fast path: %v", err) + } + if n, _, err := st.ActiveExecutionSlots(); err != nil || n != 0 { + t.Fatalf("rejected/off slot created %d leases: %v", n, err) + } +} + +func TestExecutionAccountFenceImplicitLocalStopsAtRecordedDisable(t *testing.T) { + st, err := OpenSQLite(filepath.Join(t.TempDir(), "execution.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = st.Close() }) + w := executionWriter(t, st) + p, err := st.CreateProject(OwnerDefault, "local") + if err != nil { + t.Fatal(err) + } + conv, err := st.CreateConversation(OwnerDefault, p.ID, "c", "") + if err != nil { + t.Fatal(err) + } + run := core.Run{ID: "run-local-exec", Owner: OwnerDefault, ConversationID: conv.ID, ProjectID: p.ID, Status: core.RunPending} + if err := st.CreateRun(&run); err != nil { + t.Fatal(err) + } + epoch, err := w.TryClaimExecutionAccountChecked(OwnerDefault, conv.ID, "instance", run.ID, 60, true) + if err != nil || epoch != 1 { + t.Fatalf("implicit local claim: epoch=%d err=%v", epoch, err) + } + if err := w.TryAcquireExecutionSlotAccountChecked(OwnerDefault, run.ID, ExecutionBudget{Global: 1}, true); err != nil { + t.Fatalf("implicit local slot: %v", err) + } + if err := st.EnsureUser(OwnerDefault, ""); err != nil { + t.Fatal(err) + } + if _, err := st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner=?`, OwnerDefault); err != nil { + t.Fatal(err) + } + if w.RenewExecutionAccountChecked(OwnerDefault, conv.ID, "instance", epoch, 180, true) { + t.Fatal("recorded disabled local account renewed lease") + } + if err := w.TryAcquireExecutionSlotAccountChecked(OwnerDefault, run.ID, ExecutionBudget{Global: 1}, true); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("recorded disabled local account reacquired slot: %v", err) + } +} diff --git a/internal/store/explain.go b/internal/store/explain.go new file mode 100644 index 0000000..2e32285 --- /dev/null +++ b/internal/store/explain.go @@ -0,0 +1,224 @@ +package store + +import ( + "database/sql" + "errors" + "strings" + + "agent-system-backend/internal/authz/catalog" +) + +const ( + CapabilitySourceRole = "role" + CapabilitySourceSystemGrant = "system-grant" + CapabilitySourceBootstrapAdmin = "bootstrap-admin" + + CapabilityScopeSystem = "system" + CapabilityScopeOwnProjects = "own-projects" + CapabilityScopeMemberProjects = "member-projects" +) + +// memberBasicCollaborateActions are the role defaults a member brings into a +// project; documentQualifyDuties are the three assignable document duties. +var ( + memberBasicCollaborateActions = []string{"document.read", "trace.edit", "knowledge.publish", "project.audit.read"} + documentQualifyDuties = []string{"document.author", "document.review", "document.approve"} +) + +type CapabilitySubject struct { + UserID string `json:"userId"` + Owner string `json:"owner"` + AuthorityEpoch int64 `json:"authorityEpoch"` + RoleID string `json:"roleId,omitempty"` + RoleName string `json:"roleName,omitempty"` + RoleEnabled bool `json:"roleEnabled"` +} + +type CapabilityActionExplain struct { + ActionID string `json:"actionId"` + Scope string `json:"scope"` + Allowed bool `json:"allowed"` + CapAllowed bool `json:"capAllowed"` + Source string `json:"source,omitempty"` + LinkedVia string `json:"linkedVia,omitempty"` + Conflict bool `json:"conflict,omitempty"` + Reason string `json:"reason,omitempty"` +} + +type CapabilityExplain struct { + ProjectCreate CapabilityActionExplain `json:"projectCreate"` + Linked []CapabilityActionExplain `json:"linked"` + Conflicts []string `json:"conflicts"` + MigrationNote string `json:"migrationNote,omitempty"` +} + +type MemberBasicCollaborate struct { + Actions []CapabilityActionExplain `json:"actions"` +} + +type DocumentDutyQualify struct { + ActionID string `json:"actionId"` + Qualified bool `json:"qualified"` + DefaultAllowed bool `json:"defaultAllowed"` + Source string `json:"source,omitempty"` + Reason string `json:"reason,omitempty"` +} + +type DocumentQualify struct { + Duties []DocumentDutyQualify `json:"duties"` +} + +// CapabilityExplanation is one actor's creator, member and document-duty +// explanation computed from a single authority snapshot. +type CapabilityExplanation struct { + CapabilitySubject + Capability CapabilityExplain `json:"capability"` + MemberBasicCollaborate MemberBasicCollaborate `json:"memberBasicCollaborate"` + DocumentQualify DocumentQualify `json:"documentQualify"` +} + +func (s *Store) rolePermissionsTx(tx *sql.Tx, roleID string) (map[string]RolePermissionChange, error) { + rows, err := tx.Query(s.q(`SELECT action_id,allowed,cap_allowed FROM role_permissions WHERE role_id=?`), roleID) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[string]RolePermissionChange{} + for rows.Next() { + var permission RolePermissionChange + if err := rows.Scan(&permission.ActionID, &permission.Allowed, &permission.CapAllowed); err != nil { + return nil, err + } + out[permission.ActionID] = permission + } + return out, rows.Err() +} + +// roleActionExplain uses the same reason vocabulary as the Host role check. +func roleActionExplain(actionID, scope string, bound, enabled bool, permissions map[string]RolePermissionChange) CapabilityActionExplain { + out := CapabilityActionExplain{ActionID: actionID, Scope: scope} + if !bound { + out.Reason = "role-unassigned" + return out + } + if !enabled { + out.Reason = "role-disabled" + return out + } + permission, ok := permissions[actionID] + switch { + case !ok: + out.Reason = "action-unconfigured" + case !permission.CapAllowed: + out.Reason = "role-cap" + case !permission.Allowed: + out.CapAllowed, out.Reason = true, "role-default-deny" + default: + out.Allowed, out.CapAllowed, out.Source = true, true, CapabilitySourceRole + } + return out +} + +func capabilityExplainNote(projectCreate bool, conflicts, kept []string) string { + if projectCreate && len(conflicts) > 0 { + return "project.create linked actions capped by the enabled role: " + strings.Join(conflicts, ",") + + "; the cap wins on created projects until the role row is changed explicitly" + } + if !projectCreate && len(kept) > 0 { + return "project.create not held: linked capability rows " + strings.Join(kept, ",") + + " remain on the role; remove them explicitly to revoke own-project management" + } + return "" +} + +// ExplainCapabilities explains the owner's own capabilities. A missing or +// disabled account is ErrAccountForbidden so callers cannot probe presence. +func (s *Store) ExplainCapabilities(owner string) (CapabilityExplanation, error) { + out := CapabilityExplanation{} + if owner == "" { + return out, ErrAccountForbidden + } + tx, err := s.db.Begin() + if err != nil { + return out, err + } + defer tx.Rollback() + if err := tx.QueryRow(`SELECT authority_epoch FROM authority_state WHERE id=1`).Scan(&out.AuthorityEpoch); err != nil { + return out, err + } + var enabled, isAdmin bool + err = tx.QueryRow(s.q(`SELECT id,owner,enabled,is_admin FROM users WHERE owner=?`), owner).Scan(&out.UserID, &out.Owner, &enabled, &isAdmin) + if errors.Is(err, sql.ErrNoRows) || (err == nil && !enabled) { + return CapabilityExplanation{}, ErrAccountForbidden + } + if err != nil { + return CapabilityExplanation{}, err + } + state, err := s.loadCapabilityAssociationTx(tx, []string{out.UserID}, nil) + if err != nil { + return CapabilityExplanation{}, err + } + roleID, bound := state.bindings[out.UserID] + permissions := map[string]RolePermissionChange{} + if bound { + role, err := s.roleTx(tx, roleID, false) + if err != nil { + return CapabilityExplanation{}, err + } + out.RoleID, out.RoleName, out.RoleEnabled = role.RoleID, role.Name, role.Enabled + if permissions, err = s.rolePermissionsTx(tx, roleID); err != nil { + return CapabilityExplanation{}, err + } + } + var enabledPermissions map[string]RolePermissionChange + if out.RoleEnabled { + enabledPermissions = permissions + } + + create := roleActionExplain(catalog.ProjectCreateAction, CapabilityScopeSystem, bound, out.RoleEnabled, permissions) + switch { + case isAdmin: + create.Allowed, create.Source, create.Reason = true, CapabilitySourceBootstrapAdmin, "" + case state.creators[out.UserID]: + create.Allowed, create.Source, create.Reason = true, CapabilitySourceSystemGrant, "" + case create.Allowed: + case create.Reason == "role-unassigned" || create.Reason == "action-unconfigured": + create.Reason = "not-granted" + } + capability := CapabilityExplain{ProjectCreate: create, Linked: []CapabilityActionExplain{}, Conflicts: []string{}} + kept := []string{} + for _, actionID := range catalog.ProjectCreateLinkedActions() { + if _, exists := permissions[actionID]; exists { + kept = append(kept, actionID) + } + linked := CapabilityActionExplain{ActionID: actionID, Scope: CapabilityScopeOwnProjects} + switch { + case !create.Allowed: + linked.Reason = "project-create-not-held" + case linkedActionDenied(enabledPermissions, actionID): + linked.LinkedVia, linked.Conflict, linked.Reason = catalog.ProjectCreateAction, true, "role-cap" + capability.Conflicts = append(capability.Conflicts, actionID) + default: + linked.Allowed, linked.CapAllowed, linked.Source, linked.LinkedVia = true, true, create.Source, catalog.ProjectCreateAction + } + capability.Linked = append(capability.Linked, linked) + } + capability.MigrationNote = capabilityExplainNote(create.Allowed, capability.Conflicts, kept) + out.Capability = capability + + out.MemberBasicCollaborate.Actions = make([]CapabilityActionExplain, 0, len(memberBasicCollaborateActions)) + for _, actionID := range memberBasicCollaborateActions { + out.MemberBasicCollaborate.Actions = append(out.MemberBasicCollaborate.Actions, + roleActionExplain(actionID, CapabilityScopeMemberProjects, bound, out.RoleEnabled, permissions)) + } + out.DocumentQualify.Duties = make([]DocumentDutyQualify, 0, len(documentQualifyDuties)) + for _, actionID := range documentQualifyDuties { + decision := roleActionExplain(actionID, CapabilityScopeMemberProjects, bound, out.RoleEnabled, permissions) + duty := DocumentDutyQualify{ActionID: actionID, Qualified: decision.CapAllowed, DefaultAllowed: decision.Allowed, Reason: decision.Reason} + if duty.Qualified { + duty.Source = CapabilitySourceRole + } + out.DocumentQualify.Duties = append(out.DocumentQualify.Duties, duty) + } + return out, tx.Commit() +} diff --git a/internal/store/explain_test.go b/internal/store/explain_test.go new file mode 100644 index 0000000..29d406f --- /dev/null +++ b/internal/store/explain_test.go @@ -0,0 +1,269 @@ +package store + +import ( + "errors" + "strings" + "testing" +) + +func explainActionMap(actions []CapabilityActionExplain) map[string]CapabilityActionExplain { + out := map[string]CapabilityActionExplain{} + for _, action := range actions { + out[action.ActionID] = action + } + return out +} + +func TestExplainCapabilityCreatorSourcesMatchLinkedCapabilities(t *testing.T) { + st := openTestStore(t) + _, alice, bob := rolePolicyAccounts(t, st) + role, err := st.CreateRole("admin", RoleCreate{Name: "项目负责人"}) + if err != nil { + t.Fatal(err) + } + if _, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, + []RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}}, "explain-create"); err != nil { + t.Fatal(err) + } + if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "explain-assign"); err != nil { + t.Fatal(err) + } + epoch, _ := st.AuthorityEpoch() + if _, err := st.ReplaceSystemGrants("admin", bob.ID, epoch, []string{"project.create"}, "explain-grant"); err != nil { + t.Fatal(err) + } + for owner, source := range map[string]string{"alice": "role", "bob": "system-grant", "admin": "bootstrap-admin"} { + explanation, err := st.ExplainCapabilities(owner) + if err != nil { + t.Fatalf("%s: %v", owner, err) + } + linked, err := st.LinkedCapabilities(owner) + if err != nil { + t.Fatal(err) + } + capability := explanation.Capability + if explanation.Owner != owner || !capability.ProjectCreate.Allowed || capability.ProjectCreate.Source != source || + capability.ProjectCreate.Source != linked.Source || capability.ProjectCreate.Scope != CapabilityScopeSystem || + len(capability.Conflicts) != 0 || capability.MigrationNote != "" || explanation.AuthorityEpoch != linked.AuthorityEpoch { + t.Fatalf("%s explanation=%+v linked=%+v", owner, explanation, linked) + } + allowed := []string{} + for _, action := range capability.Linked { + if !action.Allowed || action.Source != source || action.LinkedVia != "project.create" || action.Scope != CapabilityScopeOwnProjects { + t.Fatalf("%s linked action=%+v", owner, action) + } + allowed = append(allowed, action.ActionID) + } + if strings.Join(allowed, ",") != strings.Join(linked.LinkedActions, ",") { + t.Fatalf("%s explain linked=%v association=%v", owner, allowed, linked.LinkedActions) + } + } + alicePolicy, _ := st.ExplainCapabilities("alice") + if alicePolicy.RoleID != role.RoleID || alicePolicy.RoleName != "项目负责人" || !alicePolicy.RoleEnabled { + t.Fatalf("alice subject=%+v", alicePolicy.CapabilitySubject) + } +} + +func TestExplainCapabilityReportsConflictsAndMigrationNote(t *testing.T) { + st := openTestStore(t) + admin, alice, _ := rolePolicyAccounts(t, st) + // Bootstrap admin keeps project.create through is_admin even without the + // grant, so a role cap on a linked action is reported as a conflict. + epoch, _ := st.AuthorityEpoch() + if _, err := st.ReplaceSystemGrants("admin", admin.ID, epoch, []string{"system.permissions.manage", "system.roles.manage", "system.roles.assign"}, "drop-admin-create"); err != nil { + t.Fatal(err) + } + capped, err := st.CreateRole("admin", RoleCreate{Name: "受限管理"}) + if err != nil { + t.Fatal(err) + } + if _, err := st.ReplaceRolePermissions("admin", capped.RoleID, capped.RoleVersion, + []RolePermissionChange{{ActionID: "project.members.manage", Allowed: false, CapAllowed: false}}, "cap-members"); err != nil { + t.Fatal(err) + } + admin, _ = st.GetAccount(admin.ID) + if _, err := st.AssignUserRole("admin", admin.ID, capped.RoleID, admin.StateVersion, "assign-admin-capped"); err != nil { + t.Fatal(err) + } + explanation, err := st.ExplainCapabilities("admin") + if err != nil { + t.Fatal(err) + } + linked, _ := st.LinkedCapabilities("admin") + capability := explanation.Capability + if capability.ProjectCreate.Source != "bootstrap-admin" || strings.Join(capability.Conflicts, ",") != "project.members.manage" || + strings.Join(capability.Conflicts, ",") != strings.Join(linked.Conflicts, ",") { + t.Fatalf("conflicts=%+v linked=%+v", capability, linked) + } + members := explainActionMap(capability.Linked)["project.members.manage"] + if members.Allowed || !members.Conflict || members.Reason != "role-cap" || members.Source != "" { + t.Fatalf("conflicting linked action=%+v", members) + } + if !strings.Contains(capability.MigrationNote, "project.members.manage") { + t.Fatalf("conflict note=%q", capability.MigrationNote) + } + + // Disabling project.create keeps linked rows; the explanation says so. + creator, err := st.CreateRole("admin", RoleCreate{Name: "创建者"}) + if err != nil { + t.Fatal(err) + } + receipt, err := st.ReplaceRolePermissions("admin", creator.RoleID, creator.RoleVersion, []RolePermissionChange{ + {ActionID: "project.create", Allowed: true, CapAllowed: true}, + {ActionID: "project.settings", Allowed: true, CapAllowed: true}, + }, "enable-create") + if err != nil { + t.Fatal(err) + } + if _, err := st.AssignUserRole("admin", alice.ID, creator.RoleID, alice.StateVersion, "assign-alice-creator"); err != nil { + t.Fatal(err) + } + if _, err := st.ReplaceRolePermissions("admin", creator.RoleID, receipt.RoleVersion, + []RolePermissionChange{{ActionID: "document.read", Allowed: true, CapAllowed: true}}, "disable-create"); err != nil { + t.Fatal(err) + } + disabled, err := st.ExplainCapabilities("alice") + if err != nil { + t.Fatal(err) + } + if disabled.Capability.ProjectCreate.Allowed || disabled.Capability.ProjectCreate.Reason != "not-granted" { + t.Fatalf("disabled create=%+v", disabled.Capability.ProjectCreate) + } + for _, action := range disabled.Capability.Linked { + if action.Allowed || action.Reason != "project-create-not-held" { + t.Fatalf("linked after disable=%+v", action) + } + } + if note := disabled.Capability.MigrationNote; !strings.Contains(note, "project.create not held") || !strings.Contains(note, "project.settings") { + t.Fatalf("migration note=%q", note) + } +} + +func TestExplainCapabilityMemberBasicCollaborate(t *testing.T) { + st := openTestStore(t) + _, alice, _ := rolePolicyAccounts(t, st) + role, err := st.CreateRole("admin", RoleCreate{Name: "协作成员"}) + if err != nil { + t.Fatal(err) + } + if _, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, []RolePermissionChange{ + {ActionID: "document.read", Allowed: true, CapAllowed: true}, + {ActionID: "trace.edit", Allowed: false, CapAllowed: true}, + {ActionID: "knowledge.publish", Allowed: false, CapAllowed: false}, + }, "member-policy"); err != nil { + t.Fatal(err) + } + if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "member-assign"); err != nil { + t.Fatal(err) + } + explanation, err := st.ExplainCapabilities("alice") + if err != nil { + t.Fatal(err) + } + actions := explainActionMap(explanation.MemberBasicCollaborate.Actions) + want := map[string]CapabilityActionExplain{ + "document.read": {ActionID: "document.read", Scope: CapabilityScopeMemberProjects, Allowed: true, CapAllowed: true, Source: "role"}, + "trace.edit": {ActionID: "trace.edit", Scope: CapabilityScopeMemberProjects, CapAllowed: true, Reason: "role-default-deny"}, + "knowledge.publish": {ActionID: "knowledge.publish", Scope: CapabilityScopeMemberProjects, Reason: "role-cap"}, + "project.audit.read": {ActionID: "project.audit.read", Scope: CapabilityScopeMemberProjects, Reason: "action-unconfigured"}, + } + if len(actions) != len(want) { + t.Fatalf("actions=%+v", explanation.MemberBasicCollaborate.Actions) + } + for actionID, expected := range want { + if actions[actionID] != expected { + t.Fatalf("%s=%+v want %+v", actionID, actions[actionID], expected) + } + } + unassigned, err := st.ExplainCapabilities("bob") + if err != nil { + t.Fatal(err) + } + for _, action := range unassigned.MemberBasicCollaborate.Actions { + if action.Allowed || action.Reason != "role-unassigned" { + t.Fatalf("unassigned member action=%+v", action) + } + } + // Bootstrap admin is a system source only; it grants no member content. + adminExplain, err := st.ExplainCapabilities("admin") + if err != nil { + t.Fatal(err) + } + for _, action := range adminExplain.MemberBasicCollaborate.Actions { + if action.Allowed || action.Source != "" { + t.Fatalf("admin member action=%+v", action) + } + } +} + +func TestExplainCapabilityDocumentQualify(t *testing.T) { + st := openTestStore(t) + _, alice, _ := rolePolicyAccounts(t, st) + role, err := st.CreateRole("admin", RoleCreate{Name: "文档职责"}) + if err != nil { + t.Fatal(err) + } + receipt, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, []RolePermissionChange{ + {ActionID: "document.author", Allowed: true, CapAllowed: true}, + {ActionID: "document.review", Allowed: false, CapAllowed: true}, + {ActionID: "document.approve", Allowed: false, CapAllowed: false}, + }, "duty-policy") + if err != nil { + t.Fatal(err) + } + if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "duty-assign"); err != nil { + t.Fatal(err) + } + explanation, err := st.ExplainCapabilities("alice") + if err != nil { + t.Fatal(err) + } + got := map[string]DocumentDutyQualify{} + for _, duty := range explanation.DocumentQualify.Duties { + got[duty.ActionID] = duty + } + want := map[string]DocumentDutyQualify{ + "document.author": {ActionID: "document.author", Qualified: true, DefaultAllowed: true, Source: "role"}, + "document.review": {ActionID: "document.review", Qualified: true, Source: "role", Reason: "role-default-deny"}, + "document.approve": {ActionID: "document.approve", Reason: "role-cap"}, + } + if len(got) != len(want) { + t.Fatalf("duties=%+v", explanation.DocumentQualify.Duties) + } + for actionID, expected := range want { + if got[actionID] != expected { + t.Fatalf("%s=%+v want %+v", actionID, got[actionID], expected) + } + } + disabled := false + if _, err := st.PatchRole("admin", role.RoleID, receipt.RoleVersion, RolePatch{Enabled: &disabled}); err != nil { + t.Fatal(err) + } + explanation, err = st.ExplainCapabilities("alice") + if err != nil { + t.Fatal(err) + } + for _, duty := range explanation.DocumentQualify.Duties { + if duty.Qualified || duty.Reason != "role-disabled" { + t.Fatalf("disabled role duty=%+v", duty) + } + } +} + +func TestExplainCapabilityRejectsMissingAndDisabledAccountsAlike(t *testing.T) { + st := openTestStore(t) + rolePolicyAccounts(t, st) + if err := st.CreateAccount("carol", "Carol", "hash", false); err != nil { + t.Fatal(err) + } + carol, _ := st.GetAccountByOwner("carol") + if _, err := st.SetAccountEnabled("admin", carol.ID, carol.StateVersion, false); err != nil { + t.Fatal(err) + } + for _, owner := range []string{"", "nobody", "carol"} { + explanation, err := st.ExplainCapabilities(owner) + if !errors.Is(err, ErrAccountForbidden) || explanation.UserID != "" || explanation.Owner != "" { + t.Fatalf("%q explanation=%+v err=%v", owner, explanation, err) + } + } +} diff --git a/internal/store/hitl_account_write.go b/internal/store/hitl_account_write.go new file mode 100644 index 0000000..4a6a7da --- /dev/null +++ b/internal/store/hitl_account_write.go @@ -0,0 +1,71 @@ +package store + +import ( + "database/sql" + "errors" + + core "agent-system-core" +) + +func (s *Store) SaveHITLCardAccountChecked(actor string, card *core.HITLCard, allowImplicitDefault bool) error { + if card == nil { + return ErrNotFound + } + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + var runOwner, conversationID string + if err := tx.QueryRow(s.q(`SELECT owner,conversation_id FROM runs WHERE id=?`), card.RunID).Scan(&runOwner, &conversationID); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + if runOwner != actor || conversationID != string(card.ConversationID) { + return ErrNotFound + } + _, err := tx.Exec(s.q(`INSERT INTO hitl_cards(id,owner,run_id,conversation_id,payload,created_at,resolved,resolution) VALUES(?,?,?,?,?,?,?,?) + ON CONFLICT(id) DO UPDATE SET payload=excluded.payload,resolved=excluded.resolved,resolution=excluded.resolution`), + card.ID, actor, card.RunID, card.ConversationID, marshalJSON(card), nowISO(), boolInt(card.Resolved), "") + return err + }) +} + +func (s *Store) ResolveHITLCardAccountChecked(actor, id string, resolution core.HITLResolvedPayload, allowImplicitDefault bool) error { + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + query := s.q(`SELECT payload,run_id,conversation_id,resolved FROM hitl_cards WHERE id=? AND owner=?`) + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + var payload, runID, conversationID string + var resolved bool + if err := tx.QueryRow(query, id, actor).Scan(&payload, &runID, &conversationID, &resolved); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + if resolved { + return ErrConflict + } + var runOwner, runConversation string + if err := tx.QueryRow(s.q(`SELECT owner,conversation_id FROM runs WHERE id=?`), runID).Scan(&runOwner, &runConversation); err != nil { + return ErrNotFound + } + if runOwner != actor || runConversation != conversationID { + return ErrNotFound + } + card, err := unmarshalJSON[core.HITLCard](payload) + if err != nil { + return err + } + card.Resolved = true + res, err := tx.Exec(s.q(`UPDATE hitl_cards SET resolved=1,payload=?,resolution=? WHERE id=? AND owner=? AND resolved=0`), + marshalJSON(card), marshalJSON(resolution), id, actor) + if err != nil { + return err + } + if n, err := res.RowsAffected(); err != nil { + return err + } else if n != 1 { + return ErrConflict + } + return nil + }) +} diff --git a/internal/store/human_decision.go b/internal/store/human_decision.go new file mode 100644 index 0000000..0742ff4 --- /dev/null +++ b/internal/store/human_decision.go @@ -0,0 +1,215 @@ +package store + +import ( + "crypto/sha256" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "time" +) + +const humanDecisionTTL = 15 * time.Minute + +// HumanDecision is the base's record of one pending or answered human card. +// Status stays "responded" after a proof is issued. Consumption belongs to +// the caller that receives the proof, not to this table. +type HumanDecision struct { + ID string + PluginID string + Audience string + CommandID string + HumanActor string + Owner string + ProjectID string + ProposalID string + ProposalRevision int + PayloadHash string + Question string + Status string + Decision string + ProofID string + ConversationID string + IssuedAt time.Time + ExpiresAt time.Time + RespondedAt time.Time +} + +func humanProofID(cardID, decision, payloadHash string) string { + sum := sha256.Sum256([]byte(cardID + "\n" + decision + "\n" + payloadHash)) + return "hdp-" + hex.EncodeToString(sum[:16]) +} + +// IssueHumanDecision persists a pending card and revokes older pending cards +// for the same proposal. A changed card cannot keep an earlier confirmation. +func (s *Store) IssueHumanDecision(rec HumanDecision, now time.Time) (HumanDecision, error) { + if rec.PluginID == "" || rec.Audience == "" || rec.CommandID == "" || rec.HumanActor == "" || + rec.Owner == "" || rec.ProjectID == "" || rec.ProposalID == "" || rec.ProposalRevision < 1 || + rec.PayloadHash == "" || rec.Question == "" { + return HumanDecision{}, fmt.Errorf("store: incomplete human decision") + } + rec.ID = newID("hd") + rec.Status = "pending" + rec.Decision = "" + rec.ProofID = "" + rec.IssuedAt = now.UTC() + rec.ExpiresAt = now.UTC().Add(humanDecisionTTL) + tx, err := s.db.Begin() + if err != nil { + return HumanDecision{}, err + } + defer tx.Rollback() + if _, err := tx.Exec(s.q(`UPDATE human_decisions SET status='revoked' + WHERE plugin_id=? AND audience=? AND project_id=? AND proposal_id=? AND status='pending'`), + rec.PluginID, rec.Audience, rec.ProjectID, rec.ProposalID); err != nil { + return HumanDecision{}, err + } + if _, err := tx.Exec(s.q(`INSERT INTO human_decisions( + id,plugin_id,audience,command_id,human_actor,owner,project_id,proposal_id,proposal_revision, + payload_hash,question,status,decision,proof_id,conversation_id,issued_at,expires_at,responded_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`), + rec.ID, rec.PluginID, rec.Audience, rec.CommandID, rec.HumanActor, rec.Owner, rec.ProjectID, + rec.ProposalID, rec.ProposalRevision, rec.PayloadHash, rec.Question, rec.Status, "", "", + rec.ConversationID, rec.IssuedAt.Format(time.RFC3339Nano), rec.ExpiresAt.Format(time.RFC3339Nano), ""); err != nil { + return HumanDecision{}, err + } + if err := tx.Commit(); err != nil { + return HumanDecision{}, err + } + return rec, nil +} + +func (s *Store) HumanDecision(id string) (HumanDecision, error) { + row := s.db.QueryRow(s.q(`SELECT id,plugin_id,audience,command_id,human_actor,owner,project_id,proposal_id, + proposal_revision,payload_hash,question,status,decision,proof_id,conversation_id,issued_at,expires_at,responded_at + FROM human_decisions WHERE id=?`), id) + return scanHumanDecision(row) +} + +// RespondHumanDecision records the authenticated person's choice. It does not +// mark the proof consumed. Repeating a resolved card fails closed. +func (s *Store) RespondHumanDecision(actor, cardID, decision string, now time.Time) (HumanDecision, error) { + if decision != "accept" && decision != "reject" && decision != "cancel" { + return HumanDecision{}, fmt.Errorf("store: human decision must be accept, reject, or cancel") + } + rec, err := s.HumanDecision(cardID) + if err != nil { + return HumanDecision{}, err + } + if rec.HumanActor != actor { + return HumanDecision{}, fmt.Errorf("store: human decision actor mismatch") + } + if rec.Status == "revoked" { + return HumanDecision{}, fmt.Errorf("store: human decision revoked") + } + if rec.Status != "pending" { + return HumanDecision{}, fmt.Errorf("store: human decision already answered") + } + if !now.Before(rec.ExpiresAt) { + return HumanDecision{}, fmt.Errorf("store: human decision expired") + } + proof := humanProofID(rec.ID, decision, rec.PayloadHash) + responded := now.UTC() + res, err := s.db.Exec(s.q(`UPDATE human_decisions SET status='responded', decision=?, proof_id=?, responded_at=? + WHERE id=? AND status='pending'`), decision, proof, responded.Format(time.RFC3339Nano), cardID) + if err != nil { + return HumanDecision{}, err + } + if n, _ := res.RowsAffected(); n == 0 { + return HumanDecision{}, fmt.Errorf("store: human decision already answered") + } + rec.Status = "responded" + rec.Decision = decision + rec.ProofID = proof + rec.RespondedAt = responded + return rec, nil +} + +// RevokeHumanDecision invalidates a card before it is answered. The same +// actor who was asked, and only that actor, may revoke it. +func (s *Store) RevokeHumanDecision(pluginID, actor, cardID string) error { + res, err := s.db.Exec(s.q(`UPDATE human_decisions SET status='revoked' + WHERE id=? AND plugin_id=? AND human_actor=? AND status='pending'`), cardID, pluginID, actor) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return fmt.Errorf("store: human decision cannot be revoked") + } + return nil +} + +// PendingHumanDecisions returns unanswered cards for one actor. +func (s *Store) PendingHumanDecisions(actor string, now time.Time) ([]HumanDecision, error) { + rows, err := s.db.Query(s.q(`SELECT id,plugin_id,audience,command_id,human_actor,owner,project_id,proposal_id, + proposal_revision,payload_hash,question,status,decision,proof_id,conversation_id,issued_at,expires_at,responded_at + FROM human_decisions WHERE human_actor=? AND status='pending' ORDER BY issued_at`), actor) + if err != nil { + return nil, err + } + defer rows.Close() + var out []HumanDecision + for rows.Next() { + rec, err := scanHumanDecision(rows) + if err != nil { + return nil, err + } + if now.Before(rec.ExpiresAt) { + out = append(out, rec) + } + } + return out, rows.Err() +} + +// SessionHumanGrant returns the grant for an authenticated retry of the exact +// answered card. It does not consume the proof. Missing or mismatched input +// is ErrNotFound so callers can leave the dispatch ungranted. +func (s *Store) SessionHumanGrant(actor, commandID, projectID, proposalID, payloadHash, decision string, revision int, now time.Time) (HumanDecision, error) { + row := s.db.QueryRow(s.q(`SELECT id,plugin_id,audience,command_id,human_actor,owner,project_id,proposal_id, + proposal_revision,payload_hash,question,status,decision,proof_id,conversation_id,issued_at,expires_at,responded_at + FROM human_decisions + WHERE human_actor=? AND command_id=? AND project_id=? AND proposal_id=? AND proposal_revision=? + AND payload_hash=? AND decision=? AND status='responded'`), + actor, commandID, projectID, proposalID, revision, payloadHash, decision) + rec, err := scanHumanDecision(row) + if err != nil { + return HumanDecision{}, err + } + if !now.Before(rec.ExpiresAt) { + return HumanDecision{}, ErrNotFound + } + return rec, nil +} + +type humanScanner interface { + Scan(dest ...any) error +} + +func scanHumanDecision(row humanScanner) (HumanDecision, error) { + var rec HumanDecision + var issued, expires, responded string + err := row.Scan(&rec.ID, &rec.PluginID, &rec.Audience, &rec.CommandID, &rec.HumanActor, &rec.Owner, + &rec.ProjectID, &rec.ProposalID, &rec.ProposalRevision, &rec.PayloadHash, &rec.Question, &rec.Status, + &rec.Decision, &rec.ProofID, &rec.ConversationID, &issued, &expires, &responded) + if errors.Is(err, sql.ErrNoRows) { + return HumanDecision{}, ErrNotFound + } + if err != nil { + return HumanDecision{}, err + } + rec.IssuedAt, err = time.Parse(time.RFC3339Nano, issued) + if err != nil { + return HumanDecision{}, err + } + rec.ExpiresAt, err = time.Parse(time.RFC3339Nano, expires) + if err != nil { + return HumanDecision{}, err + } + if responded != "" { + rec.RespondedAt, err = time.Parse(time.RFC3339Nano, responded) + if err != nil { + return HumanDecision{}, err + } + } + return rec, nil +} diff --git a/internal/store/instance_guard.go b/internal/store/instance_guard.go new file mode 100644 index 0000000..7265ef1 --- /dev/null +++ b/internal/store/instance_guard.go @@ -0,0 +1,47 @@ +package store + +import ( + "database/sql" + "errors" +) + +// InstanceGuardMarker 是 instance_guard 表的唯一一行。 +type InstanceGuardMarker struct { + DBDev uint64 + DBIno uint64 + LockDev uint64 + LockIno uint64 + InstanceID string + LockGen string +} + +// LoadInstanceGuard 读取守卫标记。没有行时 ok 为 false。 +func (s *Store) LoadInstanceGuard() (InstanceGuardMarker, bool, error) { + var m InstanceGuardMarker + var dbDev, dbIno, lockDev, lockIno int64 + err := s.db.QueryRow(`SELECT db_dev, db_ino, lock_dev, lock_ino, instance_id, lock_gen FROM instance_guard WHERE id=1`). + Scan(&dbDev, &dbIno, &lockDev, &lockIno, &m.InstanceID, &m.LockGen) + if errors.Is(err, sql.ErrNoRows) { + return InstanceGuardMarker{}, false, nil + } + if err != nil { + return InstanceGuardMarker{}, false, err + } + m.DBDev = uint64(dbDev) + m.DBIno = uint64(dbIno) + m.LockDev = uint64(lockDev) + m.LockIno = uint64(lockIno) + return m, true, nil +} + +// SaveInstanceGuard 写入或覆盖唯一一行。 +func (s *Store) SaveInstanceGuard(m InstanceGuardMarker) error { + _, err := s.db.Exec(s.q(`INSERT INTO instance_guard(id, db_dev, db_ino, lock_dev, lock_ino, instance_id, lock_gen) + VALUES(1,?,?,?,?,?,?) + ON CONFLICT(id) DO UPDATE SET + db_dev=excluded.db_dev, db_ino=excluded.db_ino, + lock_dev=excluded.lock_dev, lock_ino=excluded.lock_ino, + instance_id=excluded.instance_id, lock_gen=excluded.lock_gen`), + int64(m.DBDev), int64(m.DBIno), int64(m.LockDev), int64(m.LockIno), m.InstanceID, m.LockGen) + return err +} diff --git a/internal/store/knowledge_account_write_test.go b/internal/store/knowledge_account_write_test.go new file mode 100644 index 0000000..f9a9519 --- /dev/null +++ b/internal/store/knowledge_account_write_test.go @@ -0,0 +1,252 @@ +package store + +import ( + "context" + "errors" + "path/filepath" + "testing" + + core "agent-system-core" +) + +type knowledgeAccountWriter interface { + AddKnowledgeAccountChecked(actor string, allowImplicitDefault bool, docs ...core.KnowledgeDoc) error + DeleteKnowledgeBySourceAccountChecked(ctx context.Context, actor, sourcePrefix string, allowImplicitDefault bool) (int64, error) + DeleteKnowledgeByRefAccountChecked(ctx context.Context, actor, ref string, allowImplicitDefault bool) error + DeleteProjectKnowledgeByRefAccountChecked(ctx context.Context, actor, projectID, ref string, allowImplicitDefault bool) error + DeletePluginProjectKnowledgeByRefAccountChecked(ctx context.Context, actor, projectID, ref, pluginID string, allowImplicitDefault bool) error + ReplaceKnowledgeAccountChecked(ctx context.Context, actor, pluginID string, scoped, allowImplicitDefault bool, docs ...core.KnowledgeDoc) error + UpsertKnowledgeVectorExtAccountChecked(ctx context.Context, actor, docID, ref, model, contentHash string, vec []float32, allowImplicitDefault bool) error + DeleteKnowledgeVectorsAccountChecked(ctx context.Context, actor, ref string, allowImplicitDefault bool) error +} + +func knowledgeWriter(t *testing.T, st *Store) knowledgeAccountWriter { + t.Helper() + w, ok := any(st).(knowledgeAccountWriter) + if !ok { + t.Fatal("Store is missing the account-checked knowledge and vector API") + } + return w +} + +func knowledgeRows(t *testing.T, st *Store, table, owner, ref string) int { + t.Helper() + var n int + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM `+table+` WHERE owner=? AND ref=?`, owner, ref).Scan(&n); err != nil { + t.Fatal(err) + } + return n +} + +func TestKnowledgeAccountFenceRejectsDisabledAndMixedOwnerBatch(t *testing.T) { + st := openArtifactFenceStore(t, filepath.Join(t.TempDir(), "knowledge.db")) + w := knowledgeWriter(t, st) + if err := st.EnsureUser("alice", ""); err != nil { + t.Fatal(err) + } + good := core.KnowledgeDoc{ID: "knw-good", Owner: "alice", Ref: "batch", Source: "manual", Chunk: "good"} + foreign := core.KnowledgeDoc{ID: "knw-foreign", Owner: "bob", Ref: "batch", Source: "manual", Chunk: "foreign"} + if err := w.AddKnowledgeAccountChecked("alice", false, good, foreign); !errors.Is(err, ErrNotFound) { + t.Fatalf("mixed owner batch: got %v, want ErrNotFound", err) + } + if n := knowledgeRows(t, st, "knowledge", "alice", "batch"); n != 0 { + t.Fatalf("mixed owner batch wrote %d rows", n) + } + if _, err := st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner='alice'`); err != nil { + t.Fatal(err) + } + if err := w.AddKnowledgeAccountChecked("alice", false, good); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled add: got %v, want ErrAccountForbidden", err) + } + if n := knowledgeRows(t, st, "knowledge", "alice", "batch"); n != 0 { + t.Fatalf("disabled add wrote %d rows", n) + } +} + +func TestKnowledgeAccountFenceDeletesOnlyExactSourceAndScope(t *testing.T) { + ctx := context.Background() + st := openArtifactFenceStore(t, filepath.Join(t.TempDir(), "knowledge.db")) + w := knowledgeWriter(t, st) + for _, owner := range []string{"alice", "bob"} { + if err := st.EnsureUser(owner, ""); err != nil { + t.Fatal(err) + } + } + docs := []core.KnowledgeDoc{ + {ID: "knw-a1", Owner: "alice", ProjectID: "p1", Ref: "r1", Source: "plugin:a_b", Chunk: "a1"}, + {ID: "knw-a2", Owner: "alice", ProjectID: "p1", Ref: "r1", Source: "plugin:a_b:child", Chunk: "a2"}, + {ID: "knw-a3", Owner: "alice", ProjectID: "p1", Ref: "r1", Source: "plugin:axb", Chunk: "a3"}, + {ID: "knw-a4", Owner: "alice", ProjectID: "p2", Ref: "r1", Source: "plugin:a_b", Chunk: "a4"}, + {ID: "knw-b1", Owner: "bob", ProjectID: "p1", Ref: "r1", Source: "plugin:a_b", Chunk: "b1"}, + } + if err := st.AddKnowledge(docs...); err != nil { + t.Fatal(err) + } + for _, d := range docs { + if err := st.UpsertKnowledgeVectorExt(ctx, string(d.Owner), string(d.ID), d.Ref, "test", "hash", []float32{1, 0}); err != nil { + t.Fatal(err) + } + } + if n, err := w.DeleteKnowledgeBySourceAccountChecked(ctx, "alice", "plugin:a_b", false); err != nil || n != 3 { + t.Fatalf("exact source delete: %d, %v; want 3", n, err) + } + if n := knowledgeRows(t, st, "knowledge", "alice", "r1"); n != 1 { + t.Fatalf("literal prefix matched unrelated source: %d alice rows remain", n) + } + if n := knowledgeRows(t, st, "knowledge_vectors", "alice", "r1"); n != 1 { + t.Fatalf("source delete left wrong vector set: %d", n) + } + if n := knowledgeRows(t, st, "knowledge", "bob", "r1"); n != 1 { + t.Fatalf("source delete crossed owner: %d", n) + } + if err := w.DeletePluginProjectKnowledgeByRefAccountChecked(ctx, "alice", "p1", "r1", "axb", false); err != nil { + t.Fatal(err) + } + if n := knowledgeRows(t, st, "knowledge", "alice", "r1"); n != 0 { + t.Fatalf("plugin scoped delete left %d rows", n) + } +} + +func TestKnowledgeAccountFenceProjectPluginAndRefDeletesKeepOtherScopes(t *testing.T) { + ctx := context.Background() + st := openArtifactFenceStore(t, filepath.Join(t.TempDir(), "knowledge.db")) + w := knowledgeWriter(t, st) + if err := st.EnsureUser("alice", ""); err != nil { + t.Fatal(err) + } + docs := []core.KnowledgeDoc{ + {ID: "knw-p1-a", Owner: "alice", ProjectID: "p1", Ref: "shared", Source: "plugin:alpha", Chunk: "one"}, + {ID: "knw-p1-b", Owner: "alice", ProjectID: "p1", Ref: "shared", Source: "plugin:beta", Chunk: "two"}, + {ID: "knw-p2-a", Owner: "alice", ProjectID: "p2", Ref: "shared", Source: "plugin:alpha", Chunk: "three"}, + } + if err := st.AddKnowledge(docs...); err != nil { + t.Fatal(err) + } + for _, d := range docs { + if err := st.UpsertKnowledgeVectorExt(ctx, "alice", string(d.ID), d.Ref, "test", "hash", []float32{1, 0}); err != nil { + t.Fatal(err) + } + } + if err := w.DeletePluginProjectKnowledgeByRefAccountChecked(ctx, "alice", "p1", "shared", "alpha", false); err != nil { + t.Fatal(err) + } + if n := knowledgeRows(t, st, "knowledge", "alice", "shared"); n != 2 { + t.Fatalf("plugin delete removed other scope: %d remain", n) + } + if err := w.DeleteProjectKnowledgeByRefAccountChecked(ctx, "alice", "p1", "shared", false); err != nil { + t.Fatal(err) + } + if n := knowledgeRows(t, st, "knowledge", "alice", "shared"); n != 1 { + t.Fatalf("project delete removed other project: %d remain", n) + } + if err := w.DeleteKnowledgeByRefAccountChecked(ctx, "alice", "shared", false); err != nil { + t.Fatal(err) + } + if n := knowledgeRows(t, st, "knowledge", "alice", "shared"); n != 0 { + t.Fatalf("ref delete left %d knowledge rows", n) + } + if n := knowledgeRows(t, st, "knowledge_vectors", "alice", "shared"); n != 0 { + t.Fatalf("ref delete left %d vectors", n) + } +} + +func TestKnowledgeAccountFenceVectorRequiresOwnedMatchingKnowledge(t *testing.T) { + ctx := context.Background() + st := openArtifactFenceStore(t, filepath.Join(t.TempDir(), "knowledge.db")) + w := knowledgeWriter(t, st) + for _, owner := range []string{"alice", "bob"} { + if err := st.EnsureUser(owner, ""); err != nil { + t.Fatal(err) + } + } + if err := st.AddKnowledge( + core.KnowledgeDoc{ID: "knw-alice", Owner: "alice", Ref: "a", Chunk: "alice"}, + core.KnowledgeDoc{ID: "knw-bob", Owner: "bob", Ref: "b", Chunk: "bob"}, + ); err != nil { + t.Fatal(err) + } + if err := w.UpsertKnowledgeVectorExtAccountChecked(ctx, "alice", "knw-bob", "b", "test", "hash", []float32{1, 0}, false); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign doc vector: got %v, want ErrNotFound", err) + } + if err := w.UpsertKnowledgeVectorExtAccountChecked(ctx, "alice", "knw-alice", "wrong-ref", "test", "hash", []float32{1, 0}, false); !errors.Is(err, ErrNotFound) { + t.Fatalf("mismatched ref vector: got %v, want ErrNotFound", err) + } + if err := st.UpsertKnowledgeVectorExt(ctx, "bob", "knw-alice", "a", "legacy", "hash", []float32{0, 1}); err != nil { + t.Fatal(err) + } + if err := w.UpsertKnowledgeVectorExtAccountChecked(ctx, "alice", "knw-alice", "a", "test", "hash", []float32{1, 0}, false); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign existing vector takeover: got %v, want ErrNotFound", err) + } + if err := st.DeleteKnowledgeVectors(ctx, "bob", "a"); err != nil { + t.Fatal(err) + } + if err := w.UpsertKnowledgeVectorExtAccountChecked(ctx, "alice", "knw-alice", "a", "test", "hash", []float32{1, 0}, false); err != nil { + t.Fatal(err) + } + if _, err := st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner='alice'`); err != nil { + t.Fatal(err) + } + if err := w.UpsertKnowledgeVectorExtAccountChecked(ctx, "alice", "knw-alice", "a", "test", "hash2", []float32{0, 1}, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled vector upsert: got %v, want ErrAccountForbidden", err) + } + if err := w.DeleteKnowledgeVectorsAccountChecked(ctx, "alice", "a", false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled vector delete: got %v, want ErrAccountForbidden", err) + } + if n := knowledgeRows(t, st, "knowledge_vectors", "alice", "a"); n != 1 { + t.Fatalf("disabled actor changed %d vector rows", n) + } + if _, err := st.DB().Exec(`UPDATE users SET enabled=TRUE WHERE owner='alice'`); err != nil { + t.Fatal(err) + } + if err := w.DeleteKnowledgeVectorsAccountChecked(ctx, "alice", "a", false); err != nil { + t.Fatal(err) + } + if n := knowledgeRows(t, st, "knowledge_vectors", "alice", "a"); n != 0 { + t.Fatalf("guarded vector delete left %d rows", n) + } +} + +func TestKnowledgeAccountFenceReplaceRollsBackDeletionWhenInsertFails(t *testing.T) { + ctx := context.Background() + st := openArtifactFenceStore(t, filepath.Join(t.TempDir(), "knowledge.db")) + w := knowledgeWriter(t, st) + if err := st.EnsureUser("alice", ""); err != nil { + t.Fatal(err) + } + old := core.KnowledgeDoc{ID: "knw-old", Owner: "alice", ProjectID: "p1", Ref: "shared", Source: "plugin:alpha", Chunk: "old"} + other := core.KnowledgeDoc{ID: "knw-fixed", Owner: "alice", ProjectID: "p1", Ref: "shared", Source: "plugin:beta", Chunk: "other plugin"} + otherProject := core.KnowledgeDoc{ID: "knw-p2", Owner: "alice", ProjectID: "p2", Ref: "shared", Source: "plugin:alpha", Chunk: "other project"} + if err := st.AddKnowledge(old, other, otherProject); err != nil { + t.Fatal(err) + } + if err := st.UpsertKnowledgeVectorExt(ctx, "alice", string(old.ID), old.Ref, "test", "hash", []float32{1, 0}); err != nil { + t.Fatal(err) + } + first := core.KnowledgeDoc{ID: "knw-new", Owner: "alice", ProjectID: "p1", Ref: "shared", Source: "plugin:alpha", Chunk: "replacement"} + duplicate := core.KnowledgeDoc{ID: other.ID, Owner: "alice", ProjectID: "p1", Ref: "shared", Source: "plugin:alpha", Chunk: "duplicate id"} + if err := w.ReplaceKnowledgeAccountChecked(ctx, "alice", "alpha", true, false, first, duplicate); err == nil { + t.Fatal("replacement with duplicate ID unexpectedly committed") + } + var n int + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM knowledge WHERE id=?`, old.ID).Scan(&n); err != nil || n != 1 { + t.Fatalf("failed replacement removed original row: %d, %v", n, err) + } + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM knowledge WHERE id=?`, first.ID).Scan(&n); err != nil || n != 0 { + t.Fatalf("failed replacement inserted first row: %d, %v", n, err) + } + if n := knowledgeRows(t, st, "knowledge_vectors", "alice", "shared"); n != 1 { + t.Fatalf("failed replacement removed original vector: %d", n) + } + if err := w.ReplaceKnowledgeAccountChecked(ctx, "alice", "alpha", true, false, first); err != nil { + t.Fatal(err) + } + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM knowledge WHERE id IN (?,?,?)`, first.ID, other.ID, otherProject.ID).Scan(&n); err != nil || n != 3 { + t.Fatalf("successful replacement lost other scopes: %d, %v", n, err) + } + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM knowledge WHERE id=?`, old.ID).Scan(&n); err != nil || n != 0 { + t.Fatalf("successful replacement kept old row: %d, %v", n, err) + } + if n := knowledgeRows(t, st, "knowledge_vectors", "alice", "shared"); n != 0 { + t.Fatalf("successful replacement kept old vector: %d", n) + } +} diff --git a/internal/store/knowledge_owned_account_write.go b/internal/store/knowledge_owned_account_write.go new file mode 100644 index 0000000..950be78 --- /dev/null +++ b/internal/store/knowledge_owned_account_write.go @@ -0,0 +1,35 @@ +package store + +import ( + "database/sql" + "errors" + + core "agent-system-core" +) + +// AddKnowledgeForOwnedProjectAccountChecked is the HTTP owner's project path. +// Domain/plugin publication has a separate authorization callback and uses the +// broader account-checked method; this entry rechecks physical ownership in +// the final Backend transaction. +func (s *Store) AddKnowledgeForOwnedProjectAccountChecked(actor, projectID string, allowImplicitDefault bool, docs ...core.KnowledgeDoc) error { + if len(docs) == 0 { + return nil + } + for _, doc := range docs { + if doc.Owner != core.ID(actor) || doc.ProjectID != core.ID(projectID) { + return ErrNotFound + } + } + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + var owner string + if err := tx.QueryRow(s.q(`SELECT owner FROM projects WHERE id=?`), projectID).Scan(&owner); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + if owner != actor { + return ErrNotFound + } + return s.addKnowledgeTx(tx, docs) + }) +} diff --git a/internal/store/login_key_admin_write.go b/internal/store/login_key_admin_write.go new file mode 100644 index 0000000..a34479e --- /dev/null +++ b/internal/store/login_key_admin_write.go @@ -0,0 +1,66 @@ +package store + +import ( + "database/sql" + "errors" +) + +// RevokeLoginKeyCascadeAdmin rechecks the actual administrator and the target +// grant under sorted account locks before revoking it. The path owner is an +// exact-match locator, never an authorization source. +func (s *Store) RevokeLoginKeyCascadeAdmin(actor, targetOwner, id string) error { + if actor == "" || targetOwner == "" || id == "" { + return ErrNotFound + } + var routeOwner string + if err := s.db.QueryRow(s.q(`SELECT owner FROM login_keys WHERE id=?`), id).Scan(&routeOwner); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + if routeOwner != targetOwner { + return ErrNotFound + } + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + locked, err := s.lockAccountUsers(tx, actor, targetOwner) + if errors.Is(err, ErrNotFound) { + return ErrAccountForbidden + } + if err != nil { + return err + } + if !locked[actor].Enabled || !locked[actor].IsAdmin { + return ErrAccountForbidden + } + query := s.q(`SELECT owner FROM login_keys WHERE id=?`) + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + var currentOwner string + if err := tx.QueryRow(query, id).Scan(¤tOwner); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + if currentOwner != targetOwner { + return ErrNotFound + } + if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE id=? AND status=?`), + LoginKeyStatusRevoked, id, LoginKeyStatusUnused); err != nil { + return err + } + if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=? AND scope=?`), targetOwner, SessionScopeSetPasswordOnly); err != nil { + return err + } + if err := s.insertAccountEvent(tx, actor, "admin-http", targetOwner, "login_key_revoked", id); err != nil { + return err + } + if err := s.auditAccountMutation(tx, targetOwner, actor, "account.login_key.revoke"); err != nil { + return err + } + return tx.Commit() +} diff --git a/internal/store/login_keys.go b/internal/store/login_keys.go index cea3683..b06705a 100644 --- a/internal/store/login_keys.go +++ b/internal/store/login_keys.go @@ -6,6 +6,8 @@ import ( "encoding/hex" "errors" "fmt" + "os" + "strings" "time" ) @@ -33,12 +35,68 @@ const ( SessionScopeFull = "full" SessionScopeSetPasswordOnly = "set_password_only" - defaultLoginKeyTTL = 30 * time.Minute - minLoginKeyTTL = 5 * time.Minute - maxLoginKeyTTL = 120 * time.Minute - restrictedSessionTTL = 10 * time.Minute + defaultLoginKeyTTL = 30 * time.Minute + minLoginKeyTTL = 5 * time.Minute + maxLoginKeyTTL = 120 * time.Minute + restrictedSessionTTL = 10 * time.Minute + PasswordChangeModeGrantSetup = "grant_setup" + PasswordChangeModeCurrentRequired = "current_required" ) +// SessionPasswordChangeMode is server-derived. A legacy restricted session has +// no mode and therefore cannot enter either password-writing branch. +func (s *Store) SessionPasswordChangeMode(token string) (string, error) { + if token == "" { + return "", ErrNotFound + } + var method, origin, grantID, scope, exp string + var sessVersion, userVersion int + var enabled bool + err := s.db.QueryRow(s.q(`SELECT s.auth_method,s.credential_origin,s.grant_id,s.scope,s.expires_at,s.auth_version,u.auth_version,u.enabled + FROM sessions s JOIN users u ON u.owner=s.owner WHERE s.token_hash=?`), hashKey(token)). + Scan(&method, &origin, &grantID, &scope, &exp, &sessVersion, &userVersion, &enabled) + if errors.Is(err, sql.ErrNoRows) { + return "", ErrNotFound + } + if err != nil { + return "", err + } + expiry, err := time.Parse(time.RFC3339Nano, exp) + if err != nil || time.Now().After(expiry) || !enabled || sessVersion != userVersion { + return "", ErrNotFound + } + if method == AuthMethodPassword || (method == AuthMethodLegacyUnknown && scope == SessionScopeFull) { + return PasswordChangeModeCurrentRequired, nil + } + if method == AuthMethodOneTimePassword && scope == SessionScopeSetPasswordOnly && grantID != "" { + var grantStatus string + var issuedVersion int + if err := s.db.QueryRow(s.q(`SELECT status,issued_auth_version FROM login_keys WHERE id=?`), grantID). + Scan(&grantStatus, &issuedVersion); err != nil { + return "", ErrAccountForbidden + } + if grantStatus != LoginKeyStatusConsumed || issuedVersion != userVersion { + return "", ErrAccountForbidden + } + return PasswordChangeModeOneTimePassword, nil + } + if method == AuthMethodGrant && scope == SessionScopeSetPasswordOnly && grantID != "" && + CredentialOrigin(origin) == CredentialOriginOwnerInitialization { + var grantStatus, grantExpiry, grantOrigin string + var issuedVersion int + if err := s.db.QueryRow(s.q(`SELECT status,expires_at,grant_origin,proposal_id,issued_auth_version FROM login_keys WHERE id=?`), grantID). + Scan(&grantStatus, &grantExpiry, &grantOrigin, new(string), &issuedVersion); err != nil { + return "", ErrAccountForbidden + } + expiry, err := time.Parse(time.RFC3339Nano, grantExpiry) + if err != nil || time.Now().After(expiry) || grantStatus != LoginKeyStatusConsumed || grantOrigin != origin || issuedVersion != userVersion { + return "", ErrAccountForbidden + } + return PasswordChangeModeGrantSetup, nil + } + return "", ErrAccountForbidden +} + // LoginKeyMeta 对外可见元数据(无明文)。 type LoginKeyMeta struct { ID string `json:"id"` @@ -50,6 +108,13 @@ type LoginKeyMeta struct { CreatedBy string `json:"createdBy,omitempty"` } +type grantBinding struct { + origin CredentialOrigin + generation int64 + version int + proposalID string +} + // AccountEvent 账户生命周期审计行(不含任何凭据材料)。 type AccountEvent struct { ID string `json:"id"` @@ -112,6 +177,13 @@ func (s *Store) IssueLoginKey(owner, purpose, createdBy string, ttl time.Duratio return "", meta, err } defer tx.Rollback() + locked, err := s.lockAccountUsers(tx, owner) + if err != nil { + return "", meta, err + } + if !locked[owner].Enabled || locked[owner].IsSuperAdmin { + return "", meta, ErrAccountForbidden + } meta, err = s.insertLoginKeyTx(tx, owner, purpose, createdBy, plain, id, now, exp) if err != nil { return "", meta, err @@ -124,8 +196,12 @@ func (s *Store) IssueLoginKey(owner, purpose, createdBy string, ttl time.Duratio // insertLoginKeyTx 在既有事务内签发:撤销旧 Key、作废受限会话、插入新 Key、审计。 // (BootstrapFirstAdmin 与建号同事务复用——KTD12:管理员行与激活 Key 共同提交。) -func (s *Store) insertLoginKeyTx(tx *sql.Tx, owner, purpose, createdBy, plain, id string, now, exp time.Time) (LoginKeyMeta, error) { +func (s *Store) insertLoginKeyTx(tx *sql.Tx, owner, purpose, createdBy, plain, id string, now, exp time.Time, bindings ...grantBinding) (LoginKeyMeta, error) { meta := LoginKeyMeta{} + binding := grantBinding{origin: CredentialOriginLegacyUnknown} + if len(bindings) == 1 { + binding = bindings[0] + } if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND purpose=? AND status=?`), LoginKeyStatusRevoked, owner, purpose, LoginKeyStatusUnused); err != nil { return meta, err @@ -134,8 +210,10 @@ func (s *Store) insertLoginKeyTx(tx *sql.Tx, owner, purpose, createdBy, plain, i if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=? AND scope=?`), owner, SessionScopeSetPasswordOnly); err != nil { return meta, err } - if _, err := tx.Exec(s.q(`INSERT INTO login_keys(id,owner,purpose,key_hash,status,expires_at,created_at,created_by) VALUES(?,?,?,?,?,?,?,?)`), - id, owner, purpose, hashKey(plain), LoginKeyStatusUnused, exp.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), createdBy); err != nil { + if _, err := tx.Exec(s.q(`INSERT INTO login_keys(id,owner,purpose,key_hash,status,expires_at,created_at,created_by,grant_origin,generation,issued_auth_version,proposal_id) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`), + id, owner, purpose, hashKey(plain), LoginKeyStatusUnused, exp.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), createdBy, + binding.origin, binding.generation, binding.version, binding.proposalID); err != nil { return meta, err } if err := s.insertAccountEvent(tx, createdBy, entranceOf(createdBy), owner, "login_key_issued", purpose+" "+id); err != nil { @@ -146,60 +224,13 @@ func (s *Store) insertLoginKeyTx(tx *sql.Tx, owner, purpose, createdBy, plain, i // BootstrapFirstAdmin 首位管理员引导单事务:咨询锁串行(PG)→ 核验库内无用户 → // 建管理员行 + 激活 Key + 审计共同提交;任何用户已存在返回 ErrConflict(拒绝接管)。 +// createdBy 保留旧调用签名,但审计主体总从实际本地 OS UID 取得。 // SQLite 无咨询锁:插入后复查用户数恰为 1,双引导并发时后提交者回滚。 -func (s *Store) BootstrapFirstAdmin(owner, name, createdBy string) (plain string, err error) { - raw := make([]byte, 32) - if _, err := rand.Read(raw); err != nil { - return "", err - } - plain = "alk_" + hex.EncodeToString(raw) - idRaw := make([]byte, 8) - if _, err := rand.Read(idRaw); err != nil { - return "", err - } - id := "lk_" + hex.EncodeToString(idRaw) - now := time.Now().UTC() - exp := now.Add(defaultLoginKeyTTL) - tx, err := s.db.Begin() - if err != nil { - return "", err - } - defer tx.Rollback() - if err := s.dialect.lockBootstrap(tx); err != nil { - return "", err - } - var n int - if err := tx.QueryRow(s.q(`SELECT COUNT(1) FROM users`)).Scan(&n); err != nil { - return "", err - } - if n > 0 { - return "", ErrConflict // 有数据环境拒绝接管(R11) - } - if _, err := tx.Exec(s.q(`INSERT INTO users(id,owner,name,created_at,password_hash,password_set_at,must_change_password,is_admin) - VALUES(?,?,?,?,?,?,?,?)`), - newID("usr"), owner, name, nowISO(), "", "", false, true); err != nil { - if isUniqueViolation(err) { - return "", ErrConflict - } - return "", err - } - // SQLite 并发兜底:本事务内用户数必须恰为 1(另一引导已提交则回滚)。 - if err := tx.QueryRow(s.q(`SELECT COUNT(1) FROM users`)).Scan(&n); err != nil { - return "", err - } - if n != 1 { - return "", ErrConflict - } - if _, err := s.insertLoginKeyTx(tx, owner, LoginKeyPurposeActivate, createdBy, plain, id, now, exp); err != nil { - return "", err - } - if err := s.insertAccountEvent(tx, createdBy, entranceOf(createdBy), owner, "bootstrap_admin", owner); err != nil { - return "", err - } - if err := tx.Commit(); err != nil { - return "", err - } - return plain, nil +func (s *Store) BootstrapFirstAdmin(owner, name, createdBy string) (string, error) { + out, err := s.BootstrapFirstAdminWrite(AccountWrite{ + Owner: owner, Name: name, Actor: fmt.Sprintf("%d", os.Geteuid()), CreatedBy: fmt.Sprintf("local-owner:uid:%d", os.Geteuid()), + }) + return out.Plain, err } // RedeemBlocked 兑换入口跨实例退避:事务内清理窗口外失败记录,判定该来源 IP @@ -232,6 +263,12 @@ func entranceOf(createdBy string) string { if createdBy == "" { return "api" } + if strings.HasPrefix(createdBy, "local-owner:") { + return "local-owner-cli" + } + if strings.HasPrefix(createdBy, "owner-proof:") { + return "owner-http" + } if createdBy == "cli" || createdBy == "cli-bootstrap" || (len(createdBy) > 4 && createdBy[:4] == "cli-") { return "cli" } @@ -253,7 +290,7 @@ func (s *Store) RevokeLoginKey(id string) error { // RevokeLoginKeyCascade 撤销 Key 并作废该用户全部 set_password_only 会话(管理员 // HTTP 入口)。Key 已消费/过期时仍返回属主并作废会话;编号不存在才报 NotFound。 -func (s *Store) RevokeLoginKeyCascade(id, actor string) (owner string, err error) { +func (s *Store) RevokeLoginKeyCascade(id, actor string, expectedOwner ...string) (owner string, err error) { tx, err := s.db.Begin() if err != nil { return "", err @@ -266,6 +303,12 @@ func (s *Store) RevokeLoginKeyCascade(id, actor string) (owner string, err error if err != nil { return "", err } + if len(expectedOwner) > 0 && expectedOwner[0] != owner { + return "", ErrNotFound + } + if err := s.dialect.lockAccount(tx, owner); err != nil { + return "", err + } if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE id=? AND status=?`), LoginKeyStatusRevoked, id, LoginKeyStatusUnused); err != nil { return "", err @@ -329,24 +372,57 @@ func (s *Store) RedeemLoginKey(plain string) (token string, owner string, err er if plain == "" || len(plain) < 5 || plain[:4] != "alk_" { return "", "", ErrNotFound } - tx, err := s.db.Begin() - if err != nil { - return "", "", err - } - defer tx.Rollback() - var id, o, status, exp string - err = tx.QueryRow(s.q(`SELECT id,owner,status,expires_at FROM login_keys WHERE key_hash=?`), hashKey(plain)). - Scan(&id, &o, &status, &exp) + var routeOwner string + err = s.db.QueryRow(s.q(`SELECT owner FROM login_keys WHERE key_hash=?`), hashKey(plain)).Scan(&routeOwner) if errors.Is(err, sql.ErrNoRows) { return "", "", ErrNotFound } if err != nil { return "", "", err } + tx, err := s.db.Begin() + if err != nil { + return "", "", err + } + defer tx.Rollback() + if err := s.dialect.lockAccount(tx, routeOwner); err != nil { + return "", "", err + } + locked, err := s.lockAccountUsers(tx, routeOwner) + if err != nil { + return "", "", err + } + account := locked[routeOwner] + if !account.Enabled { + return "", "", ErrAccountForbidden + } + var id, o, status, exp, purpose, grantOrigin string + var generation int64 + var issuedVersion int + grantQuery := s.q(`SELECT id,owner,status,expires_at,purpose,grant_origin,generation,issued_auth_version FROM login_keys WHERE key_hash=?`) + if s.Dialect() == "postgres" { + grantQuery += ` FOR UPDATE` + } + err = tx.QueryRow(grantQuery, hashKey(plain)).Scan(&id, &o, &status, &exp, &purpose, &grantOrigin, &generation, &issuedVersion) + if errors.Is(err, sql.ErrNoRows) { + return "", "", ErrNotFound + } + if err != nil { + return "", "", err + } + if o != routeOwner || issuedVersion != account.AuthVersion { + return "", "", ErrConflict + } + switch { + case purpose == LoginKeyPurposeActivate && grantOrigin == string(CredentialOriginOwnerInitialization): + if account.InitializationState != InitializationPending || account.InitializationGeneration != generation || account.HasPassword { + return "", "", ErrAccountForbidden + } + default: + return "", "", ErrAccountForbidden + } expT, err := time.Parse(time.RFC3339Nano, exp) if err != nil || time.Now().After(expT) { - _, _ = tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE id=? AND status=?`), LoginKeyStatusExpired, id, LoginKeyStatusUnused) - _ = tx.Commit() return "", "", ErrNotFound } if status != LoginKeyStatusUnused { @@ -360,19 +436,14 @@ func (s *Store) RedeemLoginKey(plain string) (token string, owner string, err er if n, _ := res.RowsAffected(); n != 1 { return "", "", ErrConflict } - var authVersion int - err = tx.QueryRow(s.q(`SELECT auth_version FROM users WHERE owner=?`), o).Scan(&authVersion) - if err != nil { - return "", "", err // 账户已不存在:兑换失败整体回滚 - } raw := make([]byte, 32) if _, err := rand.Read(raw); err != nil { return "", "", err } token = "sess_" + hex.EncodeToString(raw) sessExp := time.Now().Add(restrictedSessionTTL) - if _, err := tx.Exec(s.q(`INSERT INTO sessions(token_hash,owner,created_at,expires_at,last_seen_at,scope,auth_version) VALUES(?,?,?,?,?,?,?)`), - hashKey(token), o, nowISO(), sessExp.UTC().Format(time.RFC3339Nano), nowISO(), SessionScopeSetPasswordOnly, authVersion); err != nil { + if _, err := tx.Exec(s.q(`INSERT INTO sessions(token_hash,owner,created_at,expires_at,last_seen_at,scope,auth_version,auth_method,credential_origin,grant_id) VALUES(?,?,?,?,?,?,?,?,?,?)`), + hashKey(token), o, nowISO(), sessExp.UTC().Format(time.RFC3339Nano), nowISO(), SessionScopeSetPasswordOnly, account.AuthVersion, AuthMethodGrant, grantOrigin, id); err != nil { return "", "", err } if err := s.insertAccountEvent(tx, o, "login-key", o, "login_key_redeemed", id); err != nil { @@ -384,142 +455,284 @@ func (s *Store) RedeemLoginKey(plain string) (token string, owner string, err er return token, o, nil } -// CompleteSetPasswordChecked 设密事务(恢复/强制路径):在最终事务内复验会话 -// (存在、未过期、scope 受限或账户仍处强制态、auth_version 未变),然后统一 -// 写 hash、清 must_change、bump auth_version、作废未用登录 Key、删除全部会话、 -// 写成功审计。会话或版本失效时返回 ErrConflict,不做任何写入。 +// CompleteSetPasswordChecked accepts only a restricted session derived from a +// trusted owner initialization or owner-confirmed recovery grant. Historical +// key sessions and password-authenticated forced sessions cannot use it. func (s *Store) CompleteSetPasswordChecked(token, passwordHash string) (owner string, err error) { - tx, err := s.db.Begin() - if err != nil { - return "", err - } - defer tx.Rollback() - var scope, sessExp string - var sessVer, userVer int - var mustChange bool - var hash sql.NullString - err = tx.QueryRow(s.q(`SELECT s.owner,s.scope,s.expires_at,s.auth_version,u.auth_version,u.must_change_password,u.password_hash - FROM sessions s JOIN users u ON u.owner=s.owner WHERE s.token_hash=?`), hashKey(token)). - Scan(&owner, &scope, &sessExp, &sessVer, &userVer, &mustChange, &hash) + var routeGrant string + err = s.db.QueryRow(s.q(`SELECT owner,grant_id FROM sessions WHERE token_hash=?`), hashKey(token)).Scan(&owner, &routeGrant) if errors.Is(err, sql.ErrNoRows) { return "", ErrNotFound } if err != nil { return "", err } - if expT, perr := time.Parse(time.RFC3339Nano, sessExp); perr != nil || time.Now().After(expT) { - return "", ErrNotFound + if routeGrant == "" { + return "", ErrAccountForbidden } - // 受限会话、待改密、未设密三者之一才允许免旧密码设密;其余按主动改密路径走。 - if scope != SessionScopeSetPasswordOnly && !mustChange && (hash.Valid && hash.String != "") { - return "", ErrConflict - } - // 会话创建后账户凭据已变(并发改密/重置):本次授权过期,拒绝继续。 - if sessVer != userVer { - return "", ErrConflict - } - res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=?, auth_version=auth_version+1 WHERE owner=?`), - passwordHash, nowISO(), false, owner) - if err != nil { + err = s.accountTx(AccountWrite{Owner: owner, Actor: owner, Action: "account.password.grant_setup"}, func(tx *sql.Tx, _ string) error { + accounts, err := s.lockAccountUsers(tx, owner) + if err != nil { + return err + } + account := accounts[owner] + if !account.Enabled { + return ErrAccountForbidden + } + grantQuery := s.q(`SELECT owner,purpose,status,expires_at,grant_origin,generation,issued_auth_version FROM login_keys WHERE id=?`) + if s.Dialect() == "postgres" { + grantQuery += ` FOR UPDATE` + } + var grantOwner, purpose, status, grantExpiry, origin string + var generation int64 + var issuedVersion int + if err := tx.QueryRow(grantQuery, routeGrant).Scan(&grantOwner, &purpose, &status, &grantExpiry, &origin, &generation, &issuedVersion); err != nil { + return ErrAccountForbidden + } + sessionQuery := s.q(`SELECT owner,scope,expires_at,auth_version,auth_method,credential_origin,grant_id FROM sessions WHERE token_hash=?`) + if s.Dialect() == "postgres" { + sessionQuery += ` FOR UPDATE` + } + var sessionOwner, scope, sessionExpiry, method, sessionOrigin, grantID string + var sessionVersion int + if err := tx.QueryRow(sessionQuery, hashKey(token)).Scan(&sessionOwner, &scope, &sessionExpiry, &sessionVersion, &method, &sessionOrigin, &grantID); err != nil { + return ErrNotFound + } + grantExpires, err := time.Parse(time.RFC3339Nano, grantExpiry) + if err != nil || time.Now().After(grantExpires) { + return ErrAccountForbidden + } + sessionExpires, err := time.Parse(time.RFC3339Nano, sessionExpiry) + if err != nil || time.Now().After(sessionExpires) || sessionOwner != owner || scope != SessionScopeSetPasswordOnly || method != AuthMethodGrant || + grantID != routeGrant || sessionVersion != account.AuthVersion || issuedVersion != account.AuthVersion || + grantOwner != owner || status != LoginKeyStatusConsumed || sessionOrigin != origin { + return ErrAccountForbidden + } + switch { + case purpose == LoginKeyPurposeActivate && origin == string(CredentialOriginOwnerInitialization): + if account.InitializationState != InitializationPending || account.InitializationGeneration != generation || account.HasPassword { + return ErrAccountForbidden + } + default: + return ErrAccountForbidden + } + res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?,password_set_at=?,must_change_password=?,auth_version=auth_version+1, + credential_origin=?,initialization_state=?,state_version=state_version+1 WHERE owner=? AND auth_version=? AND enabled=?`), + passwordHash, nowISO(), false, origin, InitializationInitialized, owner, account.AuthVersion, true) + if err != nil { + return err + } + if n, err := res.RowsAffected(); err != nil || n != 1 { + return ErrConflict + } + if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`), + LoginKeyStatusRevoked, owner, LoginKeyStatusUnused); err != nil { + return err + } + if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil { + return err + } + if err := s.insertAccountEvent(tx, owner, "trusted-grant", owner, "password_set", origin); err != nil { + return err + } + return nil + }) + return owner, err +} + +// CompleteForcedPasswordChecked preserves the provenance of a historical +// password-authenticated account. It requires the current password even when +// the session is restricted by must_change_password. +func (s *Store) CompleteForcedPasswordChecked(token, current, passwordHash string) (string, error) { + return s.CompleteForcedPasswordCheckedWithFallback(token, current, passwordHash, nil) +} + +// CompleteForcedPasswordCheckedWithFallback additionally accepts the +// super administrator's in-memory fallback as the current password (R6). +func (s *Store) CompleteForcedPasswordCheckedWithFallback(token, current, passwordHash string, fallback FallbackCredential) (string, error) { + var owner string + if err := s.db.QueryRow(s.q(`SELECT owner FROM sessions WHERE token_hash=?`), hashKey(token)).Scan(&owner); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return "", ErrNotFound + } return "", err } - if n, _ := res.RowsAffected(); n == 0 { - return "", ErrNotFound - } - if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`), - LoginKeyStatusRevoked, owner, LoginKeyStatusUnused); err != nil { - return "", err - } - if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil { - return "", err - } - entrance := "must-change" - if scope == SessionScopeSetPasswordOnly { - entrance = "recovery" - } - if err := s.insertAccountEvent(tx, owner, entrance, owner, "password_set", entrance); err != nil { - return "", err - } - return owner, tx.Commit() + err := s.accountTx(AccountWrite{Owner: owner, Actor: owner, Action: "account.password.forced"}, func(tx *sql.Tx, _ string) error { + accounts, err := s.lockAccountUsers(tx, owner) + if err != nil { + return err + } + account := accounts[owner] + if !account.Enabled || !account.MustChangePassword || !account.HasPassword { + return ErrAccountForbidden + } + var hash string + if err := tx.QueryRow(s.q(`SELECT password_hash FROM users WHERE owner=?`), owner).Scan(&hash); err != nil { + return err + } + query := s.q(`SELECT scope,auth_method,expires_at,auth_version FROM sessions WHERE token_hash=? AND owner=?`) + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + var scope, method, expiresAt string + var version int + if err := tx.QueryRow(query, hashKey(token), owner).Scan(&scope, &method, &expiresAt, &version); err != nil { + return ErrNotFound + } + expiry, err := time.Parse(time.RFC3339Nano, expiresAt) + if err != nil || time.Now().After(expiry) || version != account.AuthVersion || scope != SessionScopeSetPasswordOnly || method != AuthMethodPassword { + return ErrAccountForbidden + } + if !verifyAccountCredential(hash, account.IsSuperAdmin, owner, current, fallback) { + return ErrBadCredentials + } + res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?,password_set_at=?,must_change_password=?,auth_version=auth_version+1 + WHERE owner=? AND auth_version=? AND enabled=? AND must_change_password=?`), + passwordHash, nowISO(), false, owner, account.AuthVersion, true, true) + if err != nil { + return err + } + if n, err := res.RowsAffected(); err != nil || n != 1 { + return ErrConflict + } + if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`), LoginKeyStatusRevoked, owner, LoginKeyStatusUnused); err != nil { + return err + } + if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil { + return err + } + if err := s.insertAccountEvent(tx, owner, "password-session", owner, "password_change_forced", ""); err != nil { + return err + } + return nil + }) + return owner, err } // ChangeUserPasswordActive 主动改密单事务:校验当前密码 → 更新(带认证版本乐观 // 检查)→ bump auth_version → 吊销其他会话(保留当前)→ 审计。中途失败无部分成功。 func (s *Store) ChangeUserPasswordActive(owner, current, passwordHash, keepToken string) error { - tx, err := s.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - var hash string - var ver int - var mustChange bool - err = tx.QueryRow(s.q(`SELECT password_hash,auth_version,must_change_password FROM users WHERE owner=?`), owner). - Scan(&hash, &ver, &mustChange) - if errors.Is(err, sql.ErrNoRows) { - return ErrNotFound - } - if err != nil { - return err - } - if mustChange || hash == "" { - // 强制态必须走受限设密路径,不在此处用旧密码换新密码。 - return ErrConflict - } - if !VerifyPassword(hash, current) { - return ErrBadCredentials - } - res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=?, auth_version=auth_version+1 WHERE owner=? AND auth_version=?`), - passwordHash, nowISO(), false, owner, ver) - if err != nil { - return err - } - if n, _ := res.RowsAffected(); n == 0 { - return ErrConflict // 并发改密:版本已前进 - } - if keepToken != "" { - if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=? AND token_hash<>?`), owner, hashKey(keepToken)); err != nil { - return err - } - } else if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil { - return err - } - if err := s.insertAccountEvent(tx, owner, "settings", owner, "password_change", ""); err != nil { - return err - } - return tx.Commit() + return s.ChangeUserPasswordActiveWithFallback(owner, current, passwordHash, keepToken, nil) } -// AdminResetUserPassword 管理员/CLI 重置密码单事务:写 hash、must_change=true、 -// bump auth_version、作废未用登录 Key、吊销全部会话、审计(actor 为操作者)。 +// ChangeUserPasswordActiveWithFallback 同 ChangeUserPasswordActive,另接受超级管理员 +// 的进程内 fallback 作为当前密码(R6);只更新数据库哈希,fallback 不受影响(KTD2)。 +func (s *Store) ChangeUserPasswordActiveWithFallback(owner, current, passwordHash, keepToken string, fallback FallbackCredential) error { + return s.accountTx(AccountWrite{Owner: owner, Actor: owner, Action: "account.password.change"}, func(tx *sql.Tx, _ string) error { + var hash string + var ver int + var mustChange, enabled, isSuperAdmin bool + accountQuery := s.q(`SELECT password_hash,auth_version,must_change_password,enabled,is_super_admin FROM users WHERE owner=?`) + if s.Dialect() == "postgres" { + accountQuery += ` FOR UPDATE` + } + err := tx.QueryRow(accountQuery, owner). + Scan(&hash, &ver, &mustChange, &enabled, &isSuperAdmin) + if errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return err + } + if !enabled { + return ErrAccountForbidden + } + if mustChange || hash == "" { + // 强制态必须走受限设密路径,不在此处用旧密码换新密码。 + return ErrConflict + } + if !verifyAccountCredential(hash, isSuperAdmin, owner, current, fallback) { + return ErrBadCredentials + } + res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=?, auth_version=auth_version+1 WHERE owner=? AND auth_version=?`), + passwordHash, nowISO(), false, owner, ver) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrConflict // 并发改密:版本已前进 + } + if keepToken != "" { + kept, err := tx.Exec(s.q(`UPDATE sessions SET auth_version=? WHERE token_hash=? AND owner=? AND auth_version=? AND scope=?`), ver+1, hashKey(keepToken), owner, ver, SessionScopeFull) + if err != nil { + return err + } + if n, err := kept.RowsAffected(); err != nil { + return err + } else if n != 1 { + return ErrConflict + } + if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=? AND token_hash<>?`), owner, hashKey(keepToken)); err != nil { + return err + } + } else if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil { + return err + } + if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`), LoginKeyStatusRevoked, owner, LoginKeyStatusUnused); err != nil { + return err + } + if err := s.insertAccountEvent(tx, owner, "settings", owner, "password_change", ""); err != nil { + return err + } + return nil + }) +} + +// AdminResetUserPassword 保留给历史内部调用路径;HTTP 管理面已改为一次性口令。 +// The super-administrator account is never reset through this path (R5). func (s *Store) AdminResetUserPassword(owner, passwordHash, actor, entrance string) error { - tx, err := s.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=?, auth_version=auth_version+1 WHERE owner=?`), - passwordHash, nowISO(), true, owner) - if err != nil { - return err - } - if n, _ := res.RowsAffected(); n == 0 { - return ErrNotFound - } - if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`), - LoginKeyStatusRevoked, owner, LoginKeyStatusUnused); err != nil { - return err - } - if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil { - return err - } - if entrance == "" { - entrance = entranceOf(actor) - } - if err := s.insertAccountEvent(tx, actor, entrance, owner, "admin_password_reset", ""); err != nil { - return err - } - return tx.Commit() + return s.adminResetUserPassword(owner, passwordHash, actor, entrance, false) +} + +// AdminResetUserPasswordChecked 保留给历史内部调用路径。 +func (s *Store) AdminResetUserPasswordChecked(actorOwner, owner, passwordHash string) error { + return s.adminResetUserPassword(owner, passwordHash, actorOwner, "admin-http", true) +} + +func (s *Store) adminResetUserPassword(owner, passwordHash, actor, entrance string, actorAccount bool) error { + w := AccountWrite{Owner: owner, Actor: actor, Action: "account.password.admin_reset", ActorAccount: actorAccount} + return s.accountTx(w, func(tx *sql.Tx, _ string) error { + owners := []string{owner} + if actorAccount { + owners = append(owners, actor) + } + locked, err := s.lockAccountUsers(tx, owners...) + if errors.Is(err, ErrNotFound) && actorAccount { + return ErrAccountForbidden + } + if err != nil { + return err + } + if actorAccount && (!locked[actor].Enabled || !locked[actor].IsAdmin) { + return ErrAccountForbidden + } + if !locked[owner].Enabled || locked[owner].IsSuperAdmin { + return ErrAccountForbidden + } + res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=?, auth_version=auth_version+1, + credential_origin=?,initialization_state=? WHERE owner=? AND auth_version=? AND enabled=?`), + passwordHash, nowISO(), true, CredentialOriginAdminPassword, InitializationInitialized, owner, locked[owner].AuthVersion, true) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrConflict + } + if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`), + LoginKeyStatusRevoked, owner, LoginKeyStatusUnused); err != nil { + return err + } + if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil { + return err + } + if entrance == "" { + entrance = entranceOf(actor) + } + if err := s.insertAccountEvent(tx, actor, entrance, owner, "admin_password_reset", ""); err != nil { + return err + } + return nil + }) } // CountUnusedLoginKeys 未使用登录 Key 计数(list 展示)。 diff --git a/internal/store/login_keys_test.go b/internal/store/login_keys_test.go index 94f0974..406db21 100644 --- a/internal/store/login_keys_test.go +++ b/internal/store/login_keys_test.go @@ -15,18 +15,32 @@ func TestOpenExistingSQLiteMissing(t *testing.T) { } } +func pendingActivationFixture(t *testing.T, st *Store, owner string) (Account, string) { + t.Helper() + if err := st.CreateAccount("admin", "", "admin-hash", true); err != nil { + t.Fatal(err) + } + account, err := st.CreatePendingAccount("admin", owner, AccountProfile{}) + if err != nil { + t.Fatal(err) + } + plain, err := st.InitializePendingAccount(account.ID, account.StateVersion, account.InitializationGeneration) + if err != nil { + t.Fatal(err) + } + return account, plain +} + func TestLoginKeyRedeemOnceAndSetPassword(t *testing.T) { st, err := OpenSQLite(filepath.Join(t.TempDir(), "t.db")) if err != nil { t.Fatal(err) } defer st.Close() - if err := st.CreateAccount("alice", "Alice", "", false); err != nil { - t.Fatal(err) - } - plain, meta, err := st.IssueLoginKey("alice", LoginKeyPurposeActivate, "test", 0) - if err != nil || plain == "" || meta.ID == "" { - t.Fatalf("issue: %v meta=%+v", err, meta) + _, plain := pendingActivationFixture(t, st, "alice") + keys, err := st.ListLoginKeys("alice") + if err != nil || plain == "" || len(keys) != 1 || keys[0].ID == "" { + t.Fatalf("issue: %v keys=%+v", err, keys) } tok, owner, err := st.RedeemLoginKey(plain) if err != nil || owner != "alice" || tok == "" { @@ -72,13 +86,7 @@ func TestLoginKeyConcurrentRedeem(t *testing.T) { t.Fatal(err) } defer st.Close() - if err := st.CreateAccount("bob", "", "", false); err != nil { - t.Fatal(err) - } - plain, _, err := st.IssueLoginKey("bob", LoginKeyPurposeRecover, "test", 0) - if err != nil { - t.Fatal(err) - } + _, plain := pendingActivationFixture(t, st, "bob") var okCount atomic.Int32 var wg sync.WaitGroup for i := 0; i < 100; i++ { @@ -103,18 +111,16 @@ func TestIssueLoginKeyRevokesDerivedSessions(t *testing.T) { t.Fatal(err) } defer st.Close() - if err := st.CreateAccount("carol", "", "", false); err != nil { - t.Fatal(err) - } - old1, _, err := st.IssueLoginKey("carol", LoginKeyPurposeRecover, "test", 0) - if err != nil { - t.Fatal(err) - } + account, old1 := pendingActivationFixture(t, st, "carol") oldTok, _, err := st.RedeemLoginKey(old1) if err != nil { t.Fatal(err) } - old2, _, err := st.IssueLoginKey("carol", LoginKeyPurposeRecover, "test", 0) + account, err = st.GetAccount(account.ID) + if err != nil { + t.Fatal(err) + } + old2, err := st.InitializePendingAccount(account.ID, account.StateVersion, account.InitializationGeneration) if err != nil { t.Fatal(err) } @@ -123,7 +129,11 @@ func TestIssueLoginKeyRevokesDerivedSessions(t *testing.T) { t.Fatal("restricted session from superseded key should be revoked") } // 旧 Key 未用场景同样撤销(old1 已消费,此处验证 old2 补发路径)。 - new1, _, err := st.IssueLoginKey("carol", LoginKeyPurposeRecover, "test", 0) + account, err = st.GetAccount(account.ID) + if err != nil { + t.Fatal(err) + } + new1, err := st.InitializePendingAccount(account.ID, account.StateVersion, account.InitializationGeneration) if err != nil { t.Fatal(err) } @@ -150,19 +160,17 @@ func TestCompleteSetPasswordRevalidatesSession(t *testing.T) { t.Fatal(err) } defer st.Close() - if err := st.CreateAccount("dave", "", "", false); err != nil { - t.Fatal(err) - } - plain, _, err := st.IssueLoginKey("dave", LoginKeyPurposeRecover, "test", 0) - if err != nil { - t.Fatal(err) - } + account, plain := pendingActivationFixture(t, st, "dave") tok, _, err := st.RedeemLoginKey(plain) if err != nil { t.Fatal(err) } // 会话被签发补发作废后,设密必须失败。 - if _, _, err := st.IssueLoginKey("dave", LoginKeyPurposeRecover, "test", 0); err != nil { + account, err = st.GetAccount(account.ID) + if err != nil { + t.Fatal(err) + } + if _, err := st.InitializePendingAccount(account.ID, account.StateVersion, account.InitializationGeneration); err != nil { t.Fatal(err) } hash, err := HashPassword("dave-final-99") @@ -177,7 +185,11 @@ func TestCompleteSetPasswordRevalidatesSession(t *testing.T) { t.Fatal("password must be unchanged after rejected set") } // 新 Key 兑换出的会话可以设密,且版本前进、旧密码不适用(未设密前无旧密码)。 - plain2, _, err := st.IssueLoginKey("dave", LoginKeyPurposeRecover, "test", 0) + account, err = st.GetAccount(account.ID) + if err != nil { + t.Fatal(err) + } + plain2, err := st.InitializePendingAccount(account.ID, account.StateVersion, account.InitializationGeneration) if err != nil { t.Fatal(err) } @@ -338,18 +350,20 @@ func TestRevokeLoginKeyCascade(t *testing.T) { t.Fatal(err) } defer st.Close() - if err := st.CreateAccount("hana", "", "", false); err != nil { - t.Fatal(err) - } - plain, meta, err := st.IssueLoginKey("hana", LoginKeyPurposeRecover, "test", 0) - if err != nil { + _, plain := pendingActivationFixture(t, st, "hana") + issued, err := st.ListLoginKeys("hana") + if err != nil || len(issued) != 1 { t.Fatal(err) } + meta := issued[0] tok, _, err := st.RedeemLoginKey(plain) if err != nil { t.Fatal(err) } - if _, err := st.RevokeLoginKeyCascade(meta.ID, "admin"); err != nil { + if _, err := st.RevokeLoginKeyCascade(meta.ID, "admin", "other-owner"); err == nil { + t.Fatal("path owner mismatch revoked another user's grant") + } + if _, err := st.RevokeLoginKeyCascade(meta.ID, "admin", "hana"); err != nil { t.Fatal(err) } if _, _, ok := st.SessionInfo(tok); ok { diff --git a/internal/store/message_choice_account_write.go b/internal/store/message_choice_account_write.go new file mode 100644 index 0000000..0526252 --- /dev/null +++ b/internal/store/message_choice_account_write.go @@ -0,0 +1,64 @@ +package store + +import ( + "database/sql" + "errors" +) + +// ExcludeRunGroupAccountChecked and SelectVariantAccountChecked recheck the +// message's persisted conversation owner after the actor lock. The entire +// group update commits behind the same disable fence. +func (s *Store) ExcludeRunGroupAccountChecked(actor, id string, excluded, allowImplicitDefault bool) error { + return s.mutateMessageChoiceAccountChecked(actor, id, excluded, false, allowImplicitDefault) +} + +func (s *Store) SelectVariantAccountChecked(actor, id string, allowImplicitDefault bool) error { + return s.mutateMessageChoiceAccountChecked(actor, id, false, true, allowImplicitDefault) +} + +func (s *Store) mutateMessageChoiceAccountChecked(actor, id string, excluded, selectVariant, allowImplicitDefault bool) error { + var routeConversation string + if err := s.db.QueryRow(s.q(`SELECT conversation_id FROM messages WHERE id=?`), id).Scan(&routeConversation); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + if err := s.dialect.lockConversation(tx, routeConversation); err != nil { + return err + } + query := s.q(`SELECT m.run_id,m.variant_group,m.conversation_id,c.owner FROM messages m + JOIN conversations c ON c.id=m.conversation_id WHERE m.id=?`) + if s.Dialect() == "postgres" { + query += ` FOR UPDATE OF m` + } + var runID, group, conversationID, owner string + if err := tx.QueryRow(query, id).Scan(&runID, &group, &conversationID, &owner); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + if owner != actor || conversationID != routeConversation { + return ErrNotFound + } + if selectVariant && group != "" { + if _, err := tx.Exec(s.q(`UPDATE messages SET excluded=1 WHERE conversation_id=? AND variant_group=?`), conversationID, group); err != nil { + return err + } + _, err := tx.Exec(s.q(`UPDATE messages SET excluded=0 WHERE id=? AND conversation_id=?`), id, conversationID) + return err + } + flag := 0 + if excluded { + flag = 1 + } + if _, err := tx.Exec(s.q(`UPDATE messages SET excluded=? WHERE id=? AND conversation_id=?`), flag, id, conversationID); err != nil { + return err + } + if runID != "" { + _, err := tx.Exec(s.q(`UPDATE messages SET excluded=? WHERE run_id=? AND conversation_id=?`), flag, runID, conversationID) + return err + } + return nil + }) +} diff --git a/internal/store/messages.go b/internal/store/messages.go index 58724f2..1800203 100644 --- a/internal/store/messages.go +++ b/internal/store/messages.go @@ -35,9 +35,22 @@ func (s *Store) AppendMessage(m *core.Message) error { // AppendMessageWithRequest 同 AppendMessage,并记录可选的 request_id。 // 同一会话里非空 request_id 已存在时,把 *m 换成已有行并返回 DuplicateRequestError。 func (s *Store) AppendMessageWithRequest(m *core.Message, requestID string) error { + return s.appendMessageWithRequest(m, requestID, "", false, false) +} + +// AppendMessageWithRequestAccountChecked fences the final user-message write +// against account disable. The actor lock precedes the conversation lock. +func (s *Store) AppendMessageWithRequestAccountChecked(actor string, allowImplicitDefault bool, m *core.Message, requestID string) error { + return s.appendMessageWithRequest(m, requestID, actor, allowImplicitDefault, true) +} + +func (s *Store) appendMessageWithRequest(m *core.Message, requestID, actor string, allowImplicitDefault, guarded bool) error { if m.ID == "" { m.ID = newID("msg") } + if m.CreatedAt.IsZero() { + m.CreatedAt = time.Now().UTC() + } m.CreatedAt = m.CreatedAt.UTC() insert := `INSERT INTO messages(id,conversation_id,run_id,role,parts,seq,created_at,request_id) SELECT ?,?,?,?,?,COALESCE(MAX(seq),0)+1,?,? FROM messages WHERE conversation_id=?` @@ -46,9 +59,8 @@ func (s *Store) AppendMessageWithRequest(m *core.Message, requestID string) erro // 竞态在锁下消失。唯一约束仍留作兜底——冲突只剩事务边界外的极端 // 竞态(如手工写入),小退避重试即可收敛。 for attempt := 0; attempt < 8; attempt++ { - err := s.appendMessageTx(m, requestID, insert) + err := s.appendMessageTx(m, requestID, insert, actor, allowImplicitDefault, guarded) if err == nil { - s.TouchConversation(m.ConversationID) return nil } if !isUniqueViolation(err) { @@ -72,21 +84,48 @@ func (s *Store) AppendMessageWithRequest(m *core.Message, requestID string) erro // appendMessageTx 单事务完成同会话消息追加:会话锁 → 序号分配 → INSERT。 // 请求幂等依赖 uq_messages_conv_request 唯一索引在事务内暴露冲突。 -func (s *Store) appendMessageTx(m *core.Message, requestID, insert string) error { +func (s *Store) appendMessageTx(m *core.Message, requestID, insert, actor string, allowImplicitDefault, guarded bool) error { + if guarded { + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + return s.appendMessageRows(tx, m, requestID, insert, actor) + }) + } tx, err := s.db.Begin() if err != nil { return err } defer tx.Rollback() - if err := s.dialect.lockConversation(tx, string(m.ConversationID)); err != nil { - return err - } - if _, err := tx.Exec(s.q(insert), m.ID, m.ConversationID, m.RunID, string(m.Role), marshalJSON(m.Parts), nowISO(), requestID, m.ConversationID); err != nil { + if err := s.appendMessageRows(tx, m, requestID, insert, ""); err != nil { return err } return tx.Commit() } +func (s *Store) appendMessageRows(tx *sql.Tx, m *core.Message, requestID, insert, actor string) error { + if actor != "" { + var owner string + if err := tx.QueryRow(s.q(`SELECT owner FROM conversations WHERE id=?`), m.ConversationID).Scan(&owner); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + if owner != actor { + return ErrNotFound + } + } + if err := s.dialect.lockConversation(tx, string(m.ConversationID)); err != nil { + return err + } + if _, err := tx.Exec(s.q(insert), m.ID, m.ConversationID, m.RunID, string(m.Role), marshalJSON(m.Parts), m.CreatedAt.Format(time.RFC3339Nano), requestID, m.ConversationID); err != nil { + return err + } + if err := s.appendProjectMessageObservationsTx(tx, m); err != nil { + return err + } + _, err := tx.Exec(s.q(`UPDATE conversations SET updated_at=? WHERE id=?`), nowISO(), m.ConversationID) + return err +} + func (s *Store) ListMessages(conversationID string) ([]core.Message, error) { rows, err := s.db.Query(s.q(`SELECT id,conversation_id,run_id,role,parts,created_at,excluded,variant_group,variant_index FROM messages WHERE conversation_id=? ORDER BY seq`), conversationID) if err != nil { @@ -111,6 +150,31 @@ func (s *Store) ListMessages(conversationID string) ([]core.Message, error) { return out, rows.Err() } +// RunAssistantParts reads only one owner's selected run, without decoding the +// rest of a long conversation when presenting a saved work result. +func (s *Store) RunAssistantParts(owner, conversationID, runID string) ([]core.Part, error) { + rows, err := s.db.Query(s.q(`SELECT m.parts FROM messages m JOIN runs r ON r.id=m.run_id + WHERE r.owner=? AND r.conversation_id=? AND m.conversation_id=? AND r.id=? AND m.role=? ORDER BY m.seq`), + owner, conversationID, conversationID, runID, core.RoleAssistant) + if err != nil { + return nil, err + } + defer rows.Close() + var result []core.Part + for rows.Next() { + var raw string + if err := rows.Scan(&raw); err != nil { + return nil, err + } + parts, err := unmarshalJSON[[]core.Part](raw) + if err != nil { + return nil, err + } + result = append(result, parts...) + } + return result, rows.Err() +} + func (s *Store) LastMessages(conversationID string, n int) ([]core.Message, error) { all, err := s.ListMessages(conversationID) if err != nil { @@ -146,9 +210,14 @@ func (s *Store) CreateRun(r *core.Run) error { if r.ID == "" { r.ID = newID("run") } - r.CreatedAt = r.CreatedAt.UTC() + if r.CreatedAt.IsZero() { + r.CreatedAt = time.Now().UTC() + } else { + r.CreatedAt = r.CreatedAt.UTC() + } + ca := r.CreatedAt.Format(time.RFC3339Nano) _, err := s.db.Exec(s.q(`INSERT INTO runs(id,parent_run_id,conversation_id,project_id,owner,kind,profile_id,status,phase,agent_name,created_at,instance_id) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`), - r.ID, r.ParentRunID, r.ConversationID, r.ProjectID, r.Owner, string(r.Kind), r.ProfileID, string(r.Status), string(r.Phase), r.AgentName, nowISO(), s.instanceID) + r.ID, r.ParentRunID, r.ConversationID, r.ProjectID, r.Owner, string(r.Kind), r.ProfileID, string(r.Status), string(r.Phase), r.AgentName, ca, s.instanceID) return err } @@ -278,6 +347,88 @@ func (s *Store) appendEvents(conversationID string, evs []core.StreamEvent, owne return 0, err } defer tx.Rollback() + base, err := s.appendEventsInTx(tx, conversationID, evs, owner, producer) + if err != nil { + return 0, err + } + if err := tx.Commit(); err != nil { + return 0, err + } + assignEventSequences(evs, base) + return base, nil +} + +// AppendEventsAccountChecked is the final persistence boundary for an +// actor-attributed card/surface event. The actor account lock precedes the +// channel sequence lock and event insert. +func (s *Store) AppendEventsAccountChecked(actor, conversationID string, evs []core.StreamEvent, allowImplicitDefault bool) (int64, error) { + if len(evs) == 0 { + return 0, nil + } + var base int64 + err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + if projectID, projectChannel := core.ProjectFromChannel(core.ID(conversationID)); projectChannel { + var one int + if err := tx.QueryRow(s.q(`SELECT 1 FROM projects WHERE id=?`), projectID).Scan(&one); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + } else { + var owner string + if err := tx.QueryRow(s.q(`SELECT owner FROM conversations WHERE id=?`), conversationID).Scan(&owner); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + if owner != actor { + return ErrNotFound + } + } + var err error + base, err = s.appendEventsInTx(tx, conversationID, evs, "", "") + return err + }) + if err != nil { + return 0, err + } + assignEventSequences(evs, base) + return base, nil +} + +// AppendEventsForEffectiveChannel resolves an internal run event's account +// from the persisted channel before entering the final guarded append. Card +// and Surface APIs use their explicit actor-aware emitter instead. +func (s *Store) AppendEventsForEffectiveChannel(conversationID string, evs []core.StreamEvent, allowImplicitDefault bool) (int64, error) { + var owner string + if projectID, projectChannel := core.ProjectFromChannel(core.ID(conversationID)); projectChannel { + if err := s.db.QueryRow(s.q(`SELECT owner FROM projects WHERE id=?`), projectID).Scan(&owner); errors.Is(err, sql.ErrNoRows) { + return 0, ErrNotFound + } else if err != nil { + return 0, err + } + } else { + if err := s.db.QueryRow(s.q(`SELECT owner FROM conversations WHERE id=?`), conversationID).Scan(&owner); errors.Is(err, sql.ErrNoRows) { + return 0, ErrNotFound + } else if err != nil { + return 0, err + } + } + return s.AppendEventsAccountChecked(owner, conversationID, evs, allowImplicitDefault) +} + +func assignEventSequences(evs []core.StreamEvent, base int64) { + for i := range evs { + evs[i].Seq = base + int64(i) + 1 + } +} + +// appendEventsInTx shares sequence allocation and transactional NOTIFY with +// projections. Callers assign evs[i].Seq only after their transaction commits. +func (s *Store) appendEventsInTx(tx *sql.Tx, conversationID string, evs []core.StreamEvent, owner, producer string) (int64, error) { + if len(evs) == 0 { + return 0, nil + } if err := s.dialect.lockConversation(tx, conversationID); err != nil { return 0, err } @@ -317,12 +468,6 @@ func (s *Store) appendEvents(conversationID string, evs []core.StreamEvent, owne return 0, err } } - if err := tx.Commit(); err != nil { - return 0, err - } - for i := range evs { - evs[i].Seq = base.Int64 + int64(i) + 1 - } return base.Int64, nil } @@ -355,6 +500,38 @@ func (s *Store) EventsAfter(conversationID string, since int64, limit int) ([]co return out, rows.Err() } +// RunEventsAfter reads a run's persisted stream plus its direct children's +// collaboration status events in conversation seq order. Raw child output is +// intentionally outside the parent's previously visible process stream. +// The API checks conversation and run ownership before calling this method. +func (s *Store) RunEventsAfter(conversationID, runID string, since int64, limit int) ([]core.StreamEvent, error) { + rows, err := s.db.Query(s.q(`SELECT seq,run_id,ts,v,type,payload FROM events + WHERE conversation_id=? AND seq>? AND ( + run_id=? OR (run_id IN (SELECT id FROM runs WHERE parent_run_id=? AND conversation_id=?) + AND type IN ('agent.progress','agent.result')) + ) ORDER BY seq LIMIT ?`), conversationID, since, runID, runID, conversationID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + out := []core.StreamEvent{} + for rows.Next() { + var e core.StreamEvent + var typ string + var payload []byte + if err := rows.Scan(&e.Seq, &e.RunID, &e.TS, &e.V, &typ, &payload); err != nil { + return nil, err + } + e.ConversationID = conversationID + e.Type = core.StreamEventType(typ) + if len(payload) > 0 { + e.Payload = payload + } + out = append(out, e) + } + return out, rows.Err() +} + // EventAt 读取单个事件(PG 档 NOTIFY 扇出的按序号取回路径)。 func (s *Store) EventAt(conversationID string, seq int64) (core.StreamEvent, error) { var e core.StreamEvent diff --git a/internal/store/migrate.go b/internal/store/migrate.go index bf6fab9..dd6aecb 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -3,6 +3,7 @@ package store import ( "context" "database/sql" + "errors" "fmt" "strings" "time" @@ -848,40 +849,557 @@ func init() { }, down: map[string][]string{"sqlite": {`DROP TABLE IF EXISTS project_event_shares`}, "postgres": {`DROP TABLE IF EXISTS project_event_shares`}}, }, - migration{version: 31, name: "work_task_bindings", + migration{version: 31, name: "account_audit", + up: map[string][]string{"sqlite": { + `ALTER TABLE audit_events ADD COLUMN entry TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE audit_events ADD COLUMN instance_id TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE audit_events ADD COLUMN operation_id TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE audit_events ADD COLUMN outcome TEXT NOT NULL DEFAULT ''`, + }, "postgres": { + `ALTER TABLE audit_events ADD COLUMN entry TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE audit_events ADD COLUMN instance_id TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE audit_events ADD COLUMN operation_id TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE audit_events ADD COLUMN outcome TEXT NOT NULL DEFAULT ''`, + }}, + down: map[string][]string{"sqlite": { + `ALTER TABLE audit_events DROP COLUMN outcome`, + `ALTER TABLE audit_events DROP COLUMN operation_id`, + `ALTER TABLE audit_events DROP COLUMN instance_id`, + `ALTER TABLE audit_events DROP COLUMN entry`, + }, "postgres": { + `ALTER TABLE audit_events DROP COLUMN outcome`, + `ALTER TABLE audit_events DROP COLUMN operation_id`, + `ALTER TABLE audit_events DROP COLUMN instance_id`, + `ALTER TABLE audit_events DROP COLUMN entry`, + }}}, + migration{version: 32, name: "account_profile_state_and_provenance", + up: map[string][]string{ + "sqlite": accountFoundationUp("INTEGER NOT NULL DEFAULT 1", "lower(hex(randomblob(16)))"), + "postgres": accountFoundationUp("BOOLEAN NOT NULL DEFAULT TRUE", "md5(random()::text || clock_timestamp()::text)"), + }, + down: map[string][]string{"sqlite": accountFoundationDown, "postgres": accountFoundationDown}, + }, + migration{version: 33, name: "owner_proof_recovery", + up: map[string][]string{"sqlite": recoveryFoundationUp, "postgres": recoveryFoundationUp}, + down: map[string][]string{"sqlite": recoveryFoundationDown, "postgres": recoveryFoundationDown}, + }, + migration{version: 34, name: "shared_account_cursor_key", + up: map[string][]string{"sqlite": { + `ALTER TABLE account_directory_revision ADD COLUMN cursor_key_version INTEGER NOT NULL DEFAULT 1`, + `ALTER TABLE account_directory_revision ADD COLUMN cursor_secret TEXT NOT NULL DEFAULT ''`, + }, "postgres": { + `ALTER TABLE account_directory_revision ADD COLUMN cursor_key_version INTEGER NOT NULL DEFAULT 1`, + `ALTER TABLE account_directory_revision ADD COLUMN cursor_secret TEXT NOT NULL DEFAULT ''`, + }}, + down: map[string][]string{"sqlite": { + `ALTER TABLE account_directory_revision DROP COLUMN cursor_secret`, + `ALTER TABLE account_directory_revision DROP COLUMN cursor_key_version`, + }, "postgres": { + `ALTER TABLE account_directory_revision DROP COLUMN cursor_secret`, + `ALTER TABLE account_directory_revision DROP COLUMN cursor_key_version`, + }}}, + migration{version: 35, name: "exec_env_layers", + up: map[string][]string{"sqlite": execEnvUp, "postgres": execEnvUp}, + down: map[string][]string{"sqlite": execEnvDown, "postgres": execEnvDown}, + }, + // U8:活动服务锁的库内标记。锁文件 inode 与本行不一致表示锁文件被重建。 + migration{version: 36, name: "instance_guard", + up: map[string][]string{"sqlite": { + `CREATE TABLE IF NOT EXISTS instance_guard ( + id INTEGER PRIMARY KEY CHECK (id = 1), + db_dev INTEGER NOT NULL, + db_ino INTEGER NOT NULL, + lock_dev INTEGER NOT NULL, + lock_ino INTEGER NOT NULL, + instance_id TEXT NOT NULL DEFAULT '', + lock_gen TEXT NOT NULL DEFAULT '' + )`, + }, "postgres": { + `CREATE TABLE IF NOT EXISTS instance_guard ( + id INTEGER PRIMARY KEY CHECK (id = 1), + db_dev BIGINT NOT NULL, + db_ino BIGINT NOT NULL, + lock_dev BIGINT NOT NULL, + lock_ino BIGINT NOT NULL, + instance_id TEXT NOT NULL DEFAULT '', + lock_gen TEXT NOT NULL DEFAULT '' + )`, + }}, + down: map[string][]string{"sqlite": { + `DROP TABLE IF EXISTS instance_guard`, + }, "postgres": { + `DROP TABLE IF EXISTS instance_guard`, + }}}, + migration{version: 37, name: "work_task_bindings", up: map[string][]string{ "sqlite": {`CREATE TABLE work_task_bindings(task_id TEXT PRIMARY KEY,owner TEXT NOT NULL,plugin_id TEXT NOT NULL,conversation_id TEXT NOT NULL,project_id TEXT NOT NULL,created_at TEXT NOT NULL,FOREIGN KEY(task_id) REFERENCES background_tasks(id) ON DELETE CASCADE)`}, "postgres": {`CREATE TABLE work_task_bindings(task_id TEXT PRIMARY KEY,owner TEXT NOT NULL,plugin_id TEXT NOT NULL,conversation_id TEXT NOT NULL,project_id TEXT NOT NULL,created_at TEXT NOT NULL,FOREIGN KEY(task_id) REFERENCES background_tasks(id) ON DELETE CASCADE)`}, }, down: map[string][]string{"sqlite": {`DROP TABLE IF EXISTS work_task_bindings`}, "postgres": {`DROP TABLE IF EXISTS work_task_bindings`}}, }, - migration{version: 32, name: "trusted_context_manifests", + migration{version: 38, name: "trusted_context_manifests", up: map[string][]string{ "sqlite": {`CREATE TABLE context_manifests(id TEXT PRIMARY KEY,attempt_id TEXT NOT NULL,owner TEXT NOT NULL,target_id TEXT NOT NULL,project_id TEXT NOT NULL,input_sha256 TEXT NOT NULL,delivery TEXT NOT NULL CHECK(delivery IN ('attempted','responded','unknown')),manifest_json TEXT NOT NULL,created_at TEXT NOT NULL,UNIQUE(attempt_id,delivery))`, `CREATE INDEX idx_context_manifests_owner_target ON context_manifests(owner,target_id,created_at)`, `CREATE UNIQUE INDEX uniq_context_manifest_terminal ON context_manifests(attempt_id) WHERE delivery IN ('responded','unknown')`}, "postgres": {`CREATE TABLE context_manifests(id TEXT PRIMARY KEY,attempt_id TEXT NOT NULL,owner TEXT NOT NULL,target_id TEXT NOT NULL,project_id TEXT NOT NULL,input_sha256 TEXT NOT NULL,delivery TEXT NOT NULL CHECK(delivery IN ('attempted','responded','unknown')),manifest_json TEXT NOT NULL,created_at TEXT NOT NULL,UNIQUE(attempt_id,delivery))`, `CREATE INDEX idx_context_manifests_owner_target ON context_manifests(owner,target_id,created_at)`, `CREATE UNIQUE INDEX uniq_context_manifest_terminal ON context_manifests(attempt_id) WHERE delivery IN ('responded','unknown')`}, }, down: map[string][]string{"sqlite": {`DROP INDEX IF EXISTS uniq_context_manifest_terminal`, `DROP INDEX IF EXISTS idx_context_manifests_owner_target`, `DROP TABLE IF EXISTS context_manifests`}, "postgres": {`DROP INDEX IF EXISTS uniq_context_manifest_terminal`, `DROP INDEX IF EXISTS idx_context_manifests_owner_target`, `DROP TABLE IF EXISTS context_manifests`}}, }, - migration{version: 33, name: "workflow_step_sources", + migration{version: 39, name: "workflow_step_sources", up: map[string][]string{ "sqlite": {`CREATE TABLE workflow_step_sources(run_id TEXT NOT NULL,step_id TEXT NOT NULL,owner TEXT NOT NULL,project_id TEXT NOT NULL,step_attempt_id TEXT NOT NULL,output_sha256 TEXT NOT NULL,attempt_id TEXT NOT NULL,authorization_epoch TEXT NOT NULL,publication_epoch TEXT NOT NULL,watermark TEXT NOT NULL,version TEXT NOT NULL,created_at TEXT NOT NULL,PRIMARY KEY(run_id,step_id),FOREIGN KEY(run_id) REFERENCES runs(id) ON DELETE CASCADE)`}, "postgres": {`CREATE TABLE workflow_step_sources(run_id TEXT NOT NULL,step_id TEXT NOT NULL,owner TEXT NOT NULL,project_id TEXT NOT NULL,step_attempt_id TEXT NOT NULL,output_sha256 TEXT NOT NULL,attempt_id TEXT NOT NULL,authorization_epoch TEXT NOT NULL,publication_epoch TEXT NOT NULL,watermark TEXT NOT NULL,version TEXT NOT NULL,created_at TEXT NOT NULL,PRIMARY KEY(run_id,step_id),FOREIGN KEY(run_id) REFERENCES runs(id) ON DELETE CASCADE)`}, }, down: map[string][]string{"sqlite": {`DROP TABLE IF EXISTS workflow_step_sources`}, "postgres": {`DROP TABLE IF EXISTS workflow_step_sources`}}, }, - migration{version: 34, name: "context_manifest_resolve_scope", + migration{version: 40, name: "context_manifest_resolve_scope", up: map[string][]string{ - "sqlite": {`ALTER TABLE context_manifests ADD COLUMN resolve_goal TEXT NOT NULL DEFAULT ''`, `ALTER TABLE context_manifests ADD COLUMN resolve_target_id TEXT NOT NULL DEFAULT ''`, `ALTER TABLE context_manifests ADD COLUMN scope_bound INTEGER NOT NULL DEFAULT 0`}, - "postgres": {`ALTER TABLE context_manifests ADD COLUMN resolve_goal TEXT NOT NULL DEFAULT ''`, `ALTER TABLE context_manifests ADD COLUMN resolve_target_id TEXT NOT NULL DEFAULT ''`, `ALTER TABLE context_manifests ADD COLUMN scope_bound INTEGER NOT NULL DEFAULT 0`}, + "sqlite": {`ALTER TABLE context_manifests ADD COLUMN scope_bound INTEGER NOT NULL DEFAULT 0`}, + "postgres": {`ALTER TABLE context_manifests ADD COLUMN scope_bound INTEGER NOT NULL DEFAULT 0`}, }, down: map[string][]string{ - "sqlite": {`ALTER TABLE context_manifests DROP COLUMN scope_bound`, `ALTER TABLE context_manifests DROP COLUMN resolve_target_id`, `ALTER TABLE context_manifests DROP COLUMN resolve_goal`}, - "postgres": {`ALTER TABLE context_manifests DROP COLUMN scope_bound`, `ALTER TABLE context_manifests DROP COLUMN resolve_target_id`, `ALTER TABLE context_manifests DROP COLUMN resolve_goal`}, + "sqlite": {`ALTER TABLE context_manifests DROP COLUMN scope_bound`}, + "postgres": {`ALTER TABLE context_manifests DROP COLUMN scope_bound`}, + }, + }, + migration{version: 41, name: "scrub_candidate_context_scope_text", + up: map[string][]string{ + "sqlite": {`CREATE TABLE IF NOT EXISTS context_scope_scrub_state(id INTEGER PRIMARY KEY CHECK(id=1),pending INTEGER NOT NULL CHECK(pending IN (0,1)))`}, + "postgres": {`SELECT 1`}, + }, + down: map[string][]string{"sqlite": {`DROP TABLE IF EXISTS context_scope_scrub_state`}, "postgres": {`SELECT 1`}}, + }, + migration{version: 42, name: "command_audit_attribution_snapshot", + up: map[string][]string{ + "sqlite": {`ALTER TABLE command_audit ADD COLUMN actor_name TEXT NOT NULL DEFAULT ''`, `ALTER TABLE command_audit ADD COLUMN project_id TEXT NOT NULL DEFAULT ''`, `CREATE INDEX idx_command_audit_project_owner_offset ON command_audit(project_id,owner,id)`}, + "postgres": {`ALTER TABLE command_audit ADD COLUMN actor_name TEXT NOT NULL DEFAULT ''`, `ALTER TABLE command_audit ADD COLUMN project_id TEXT NOT NULL DEFAULT ''`, `CREATE INDEX idx_command_audit_project_owner_offset ON command_audit(project_id,owner,id)`}, + }, + down: map[string][]string{ + "sqlite": {`DROP INDEX IF EXISTS idx_command_audit_project_owner_offset`, `ALTER TABLE command_audit DROP COLUMN project_id`, `ALTER TABLE command_audit DROP COLUMN actor_name`}, + "postgres": {`DROP INDEX IF EXISTS idx_command_audit_project_owner_offset`, `ALTER TABLE command_audit DROP COLUMN project_id`, `ALTER TABLE command_audit DROP COLUMN actor_name`}, + }}, + migration{version: 43, name: "project_audit_committed_links", + up: map[string][]string{ + "sqlite": {`CREATE TABLE project_audit_links (id INTEGER PRIMARY KEY AUTOINCREMENT,host_audit_offset INTEGER NOT NULL REFERENCES command_audit(id),project_id TEXT NOT NULL,owner TEXT NOT NULL,source_kind TEXT NOT NULL,source_id TEXT NOT NULL,operation_id TEXT NOT NULL,resource_kind TEXT NOT NULL,resource_id TEXT NOT NULL,run_id TEXT NOT NULL DEFAULT '',disclosure_class TEXT NOT NULL,idempotency_key TEXT NOT NULL,created_at TEXT NOT NULL,UNIQUE(owner,idempotency_key),UNIQUE(host_audit_offset,source_kind,source_id))`, `CREATE INDEX idx_project_audit_links_source ON project_audit_links(project_id,source_kind,source_id,host_audit_offset)`}, + "postgres": {`CREATE TABLE project_audit_links (id BIGSERIAL PRIMARY KEY,host_audit_offset BIGINT NOT NULL REFERENCES command_audit(id),project_id TEXT NOT NULL,owner TEXT NOT NULL,source_kind TEXT NOT NULL,source_id TEXT NOT NULL,operation_id TEXT NOT NULL,resource_kind TEXT NOT NULL,resource_id TEXT NOT NULL,run_id TEXT NOT NULL DEFAULT '',disclosure_class TEXT NOT NULL,idempotency_key TEXT NOT NULL,created_at TEXT NOT NULL,UNIQUE(owner,idempotency_key),UNIQUE(host_audit_offset,source_kind,source_id))`, `CREATE INDEX idx_project_audit_links_source ON project_audit_links(project_id,source_kind,source_id,host_audit_offset)`}, + }, + down: map[string][]string{ + "sqlite": {`DROP TABLE IF EXISTS project_audit_links`}, + "postgres": {`DROP TABLE IF EXISTS project_audit_links`}, + }}, + migration{version: 44, name: "project_audit_committed_prefix", + up: map[string][]string{ + // SQLite has one writer and one connection per Store; this migration + // only changes PostgreSQL's implicit command_audit ID allocation. + "sqlite": {`SELECT 1`}, + "postgres": { + `LOCK TABLE command_audit IN ACCESS EXCLUSIVE MODE`, + `CREATE FUNCTION project_audit_cutoff_safe(target regclass) RETURNS boolean LANGUAGE plpgsql VOLATILE AS $$ + DECLARE seq regclass; default_expr text; increment_by bigint; min_value bigint; start_value bigint; cache_size bigint; cycles boolean; + last_number numeric; was_called boolean; next_number numeric; min_id bigint; max_id bigint; + BEGIN + seq := pg_get_serial_sequence(target::text,'id')::regclass; + IF seq IS NULL THEN RETURN false; END IF; + SELECT pg_get_expr(d.adbin,d.adrelid) INTO default_expr FROM pg_attrdef d + JOIN pg_attribute a ON a.attrelid=d.adrelid AND a.attnum=d.adnum + WHERE d.adrelid=target AND a.attname='id'; + IF default_expr IS DISTINCT FROM format('next_command_audit_id(%L::regclass)',target::text) THEN RETURN false; END IF; + SELECT seqincrement,seqmin,seqstart,seqcache,seqcycle + INTO increment_by,min_value,start_value,cache_size,cycles FROM pg_sequence WHERE seqrelid=seq; + IF increment_by IS NULL OR increment_by<=0 OR min_value<=0 OR start_value<=0 OR cache_size<>1 OR cycles THEN RETURN false; END IF; + -- Supported writers allocate under the lock before they commit. Read + -- committed rows first, then the non-MVCC sequence state: a writer + -- committing between these reads can only advance the latter. + EXECUTE format('SELECT COALESCE(MIN(id),1),COALESCE(MAX(id),0) FROM %s',target) INTO min_id,max_id; + EXECUTE format('SELECT last_value,is_called FROM %s',seq) INTO last_number,was_called; + next_number := last_number; + IF was_called THEN next_number := last_number+increment_by; END IF; + RETURN min_id>0 AND next_number>0 AND next_number>max_id; + END $$`, + `CREATE FUNCTION next_command_audit_id(target regclass) RETURNS bigint LANGUAGE plpgsql VOLATILE AS $$ + DECLARE seq regclass; allocated bigint; max_id bigint; + BEGIN + PERFORM pg_advisory_xact_lock(715328491,target::oid::int4); + IF NOT project_audit_cutoff_safe(target) THEN RAISE EXCEPTION 'project audit cutoff sequence is unsafe'; END IF; + seq := pg_get_serial_sequence(target::text,'id')::regclass; + allocated := nextval(seq); + EXECUTE format('SELECT COALESCE(MAX(id),0) FROM %s',target) INTO max_id; + IF allocated<=0 OR allocated<=max_id THEN RAISE EXCEPTION 'project audit ID did not advance beyond committed rows'; END IF; + RETURN allocated; + END $$`, + `ALTER TABLE command_audit ALTER COLUMN id SET DEFAULT next_command_audit_id('command_audit'::regclass)`, + `DO $$ BEGIN IF NOT project_audit_cutoff_safe('command_audit'::regclass) THEN RAISE EXCEPTION 'project audit cutoff migration precondition failed'; END IF; END $$`, + }, + }, + down: map[string][]string{ + "sqlite": {`SELECT 1`}, + "postgres": { + `LOCK TABLE command_audit IN ACCESS EXCLUSIVE MODE`, + `ALTER TABLE command_audit ALTER COLUMN id SET DEFAULT nextval(pg_get_serial_sequence('command_audit','id')::regclass)`, + `DROP FUNCTION next_command_audit_id(regclass)`, + `DROP FUNCTION project_audit_cutoff_safe(regclass)`, + }, + }}, + migration{version: 45, name: "project_audit_all_writers_guard", + up: map[string][]string{ + "sqlite": {`SELECT 1`}, + "postgres": { + `LOCK TABLE command_audit IN ACCESS EXCLUSIVE MODE`, + `CREATE FUNCTION project_audit_insert_guard_v1() RETURNS trigger LANGUAGE plpgsql VOLATILE AS $$ + DECLARE seq regclass; last_number numeric; was_called boolean; max_id bigint; + BEGIN + IF current_setting('transaction_isolation') <> 'read committed' THEN + RAISE EXCEPTION 'command audit insert requires read committed isolation'; + END IF; + PERFORM pg_advisory_xact_lock(715328491,TG_RELID::int4); + IF NOT project_audit_cutoff_safe(TG_RELID) OR NOT project_audit_writers_guarded(TG_RELID) THEN + RAISE EXCEPTION 'command audit write guard is unsafe'; + END IF; + seq := pg_get_serial_sequence(TG_RELID::regclass::text,'id')::regclass; + EXECUTE format('SELECT last_value,is_called FROM %s',seq) INTO last_number,was_called; + EXECUTE format('SELECT COALESCE(MAX(id),0) FROM %s',TG_RELID::regclass) INTO max_id; + IF NEW.id IS NULL OR NEW.id<=0 OR NEW.id<=max_id OR NOT was_called OR NEW.id>last_number THEN + RAISE EXCEPTION 'command audit ID is not commit-monotone'; + END IF; + RETURN NEW; + END $$`, + `CREATE FUNCTION project_audit_mutation_guard_v1() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN RAISE EXCEPTION 'command audit is append-only'; END $$`, + `CREATE TRIGGER project_audit_insert_guard_v1 BEFORE INSERT ON command_audit FOR EACH ROW EXECUTE FUNCTION project_audit_insert_guard_v1()`, + `CREATE TRIGGER project_audit_mutation_guard_v1 BEFORE UPDATE OR DELETE ON command_audit FOR EACH ROW EXECUTE FUNCTION project_audit_mutation_guard_v1()`, + `ALTER TABLE command_audit ENABLE ALWAYS TRIGGER project_audit_insert_guard_v1`, + `ALTER TABLE command_audit ENABLE ALWAYS TRIGGER project_audit_mutation_guard_v1`, + `CREATE FUNCTION project_audit_writers_guarded(target regclass) RETURNS boolean LANGUAGE plpgsql VOLATILE AS $$ + DECLARE insert_ok boolean; mutation_ok boolean; + BEGIN + SELECT EXISTS (SELECT 1 FROM pg_trigger WHERE tgrelid=target AND tgname='project_audit_insert_guard_v1' + AND NOT tgisinternal AND tgenabled='A' AND tgtype=7 + AND tgfoid=to_regprocedure('project_audit_insert_guard_v1()')) INTO insert_ok; + SELECT EXISTS (SELECT 1 FROM pg_trigger WHERE tgrelid=target AND tgname='project_audit_mutation_guard_v1' + AND NOT tgisinternal AND tgenabled='A' AND tgtype=27 + AND tgfoid=to_regprocedure('project_audit_mutation_guard_v1()')) INTO mutation_ok; + RETURN insert_ok AND mutation_ok; + END $$`, + `CREATE OR REPLACE FUNCTION next_command_audit_id(target regclass) RETURNS bigint LANGUAGE plpgsql VOLATILE AS $$ + DECLARE seq regclass; allocated bigint; max_id bigint; + BEGIN + PERFORM pg_advisory_xact_lock(715328491,target::oid::int4); + IF NOT project_audit_cutoff_safe(target) OR NOT project_audit_writers_guarded(target) THEN + RAISE EXCEPTION 'project audit cutoff guard is unsafe'; + END IF; + seq := pg_get_serial_sequence(target::text,'id')::regclass; + allocated := nextval(seq); + EXECUTE format('SELECT COALESCE(MAX(id),0) FROM %s',target) INTO max_id; + IF allocated<=0 OR allocated<=max_id THEN RAISE EXCEPTION 'project audit ID did not advance beyond committed rows'; END IF; + RETURN allocated; + END $$`, + `DO $$ BEGIN IF NOT project_audit_cutoff_safe('command_audit'::regclass) OR NOT project_audit_writers_guarded('command_audit'::regclass) THEN RAISE EXCEPTION 'project audit writer guard migration precondition failed'; END IF; END $$`, + }, + }, + down: map[string][]string{ + "sqlite": {`SELECT 1`}, + "postgres": { + `LOCK TABLE command_audit IN ACCESS EXCLUSIVE MODE`, + `CREATE OR REPLACE FUNCTION next_command_audit_id(target regclass) RETURNS bigint LANGUAGE plpgsql VOLATILE AS $$ + DECLARE seq regclass; allocated bigint; max_id bigint; + BEGIN + PERFORM pg_advisory_xact_lock(715328491,target::oid::int4); + IF NOT project_audit_cutoff_safe(target) THEN RAISE EXCEPTION 'project audit cutoff sequence is unsafe'; END IF; + seq := pg_get_serial_sequence(target::text,'id')::regclass; + allocated := nextval(seq); + EXECUTE format('SELECT COALESCE(MAX(id),0) FROM %s',target) INTO max_id; + IF allocated<=0 OR allocated<=max_id THEN RAISE EXCEPTION 'project audit ID did not advance beyond committed rows'; END IF; + RETURN allocated; + END $$`, + `DROP TRIGGER project_audit_insert_guard_v1 ON command_audit`, + `DROP TRIGGER project_audit_mutation_guard_v1 ON command_audit`, + `DROP FUNCTION project_audit_insert_guard_v1()`, + `DROP FUNCTION project_audit_mutation_guard_v1()`, + `DROP FUNCTION project_audit_writers_guarded(regclass)`, + }, + }}, + migration{version: 46, name: "project_work_observation_outbox", + up: map[string][]string{ + "sqlite": {`CREATE TABLE project_work_outbox (event_id TEXT PRIMARY KEY,owner TEXT NOT NULL,project_id TEXT NOT NULL,conversation_id TEXT NOT NULL,run_id TEXT NOT NULL DEFAULT '',source_kind TEXT NOT NULL,source_id TEXT NOT NULL,source_version TEXT NOT NULL,source_sha256 TEXT NOT NULL,part_index INTEGER NOT NULL,span_start INTEGER NOT NULL,span_end INTEGER NOT NULL,delivery TEXT NOT NULL,scope TEXT NOT NULL,observed_at TEXT NOT NULL,state TEXT NOT NULL DEFAULT 'pending',receipt_id TEXT NOT NULL DEFAULT '',payload_text TEXT NOT NULL DEFAULT '',metadata_json TEXT NOT NULL DEFAULT '{}')`, `CREATE INDEX idx_project_work_outbox_pending ON project_work_outbox(state,observed_at,event_id)`}, + "postgres": {`CREATE TABLE project_work_outbox (event_id TEXT PRIMARY KEY,owner TEXT NOT NULL,project_id TEXT NOT NULL,conversation_id TEXT NOT NULL,run_id TEXT NOT NULL DEFAULT '',source_kind TEXT NOT NULL,source_id TEXT NOT NULL,source_version TEXT NOT NULL,source_sha256 TEXT NOT NULL,part_index INTEGER NOT NULL,span_start INTEGER NOT NULL,span_end INTEGER NOT NULL,delivery TEXT NOT NULL,scope TEXT NOT NULL,observed_at TEXT NOT NULL,state TEXT NOT NULL DEFAULT 'pending',receipt_id TEXT NOT NULL DEFAULT '',payload_text TEXT NOT NULL DEFAULT '',metadata_json TEXT NOT NULL DEFAULT '{}')`, `CREATE INDEX idx_project_work_outbox_pending ON project_work_outbox(state,observed_at,event_id)`}, + }, + down: map[string][]string{ + "sqlite": {`DROP INDEX IF EXISTS idx_project_work_outbox_pending`, `DROP TABLE IF EXISTS project_work_outbox`}, + "postgres": {`DROP INDEX IF EXISTS idx_project_work_outbox_pending`, `DROP TABLE IF EXISTS project_work_outbox`}, + }}, + migration{version: 47, name: "human_decision_proofs", + up: map[string][]string{ + "sqlite": {`CREATE TABLE human_decisions ( + id TEXT PRIMARY KEY, + plugin_id TEXT NOT NULL, + audience TEXT NOT NULL, + command_id TEXT NOT NULL, + human_actor TEXT NOT NULL, + owner TEXT NOT NULL, + project_id TEXT NOT NULL, + proposal_id TEXT NOT NULL, + proposal_revision INTEGER NOT NULL, + payload_hash TEXT NOT NULL, + question TEXT NOT NULL, + status TEXT NOT NULL, + decision TEXT NOT NULL DEFAULT '', + proof_id TEXT NOT NULL DEFAULT '', + conversation_id TEXT NOT NULL DEFAULT '', + issued_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + responded_at TEXT NOT NULL DEFAULT '' + )`, `CREATE INDEX idx_human_decisions_pending ON human_decisions(human_actor, status, proposal_id)`}, + "postgres": {`CREATE TABLE human_decisions ( + id TEXT PRIMARY KEY, + plugin_id TEXT NOT NULL, + audience TEXT NOT NULL, + command_id TEXT NOT NULL, + human_actor TEXT NOT NULL, + owner TEXT NOT NULL, + project_id TEXT NOT NULL, + proposal_id TEXT NOT NULL, + proposal_revision INTEGER NOT NULL, + payload_hash TEXT NOT NULL, + question TEXT NOT NULL, + status TEXT NOT NULL, + decision TEXT NOT NULL DEFAULT '', + proof_id TEXT NOT NULL DEFAULT '', + conversation_id TEXT NOT NULL DEFAULT '', + issued_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + responded_at TEXT NOT NULL DEFAULT '' + )`, `CREATE INDEX idx_human_decisions_pending ON human_decisions(human_actor, status, proposal_id)`}, + }, + down: map[string][]string{ + "sqlite": {`DROP INDEX IF EXISTS idx_human_decisions_pending`, `DROP TABLE IF EXISTS human_decisions`}, + "postgres": {`DROP INDEX IF EXISTS idx_human_decisions_pending`, `DROP TABLE IF EXISTS human_decisions`}, + }}, + migration{version: 48, name: "document_render_deliveries", + up: map[string][]string{ + "sqlite": {`CREATE TABLE document_render_deliveries ( + operation_id TEXT NOT NULL,owner TEXT NOT NULL,project_id TEXT NOT NULL,conversation_id TEXT NOT NULL,source_run TEXT NOT NULL DEFAULT '', + request_sha256 TEXT NOT NULL,state TEXT NOT NULL,frozen_at TEXT NOT NULL,renderer_identity TEXT NOT NULL,mode TEXT NOT NULL,document_id TEXT NOT NULL, + canonical_hash TEXT NOT NULL DEFAULT '',profile_hash TEXT NOT NULL DEFAULT '',review_hash TEXT NOT NULL DEFAULT '',delivery_key TEXT NOT NULL DEFAULT '', + markdown_artifact_id TEXT NOT NULL DEFAULT '',markdown_sha256 TEXT NOT NULL DEFAULT '',docx_artifact_id TEXT NOT NULL DEFAULT '',docx_sha256 TEXT NOT NULL DEFAULT '', + manifest_artifact_id TEXT NOT NULL DEFAULT '',manifest_sha256 TEXT NOT NULL DEFAULT '',archive_artifact_id TEXT NOT NULL DEFAULT '',archive_sha256 TEXT NOT NULL DEFAULT '', + error_text TEXT NOT NULL DEFAULT '',created_at TEXT NOT NULL,updated_at TEXT NOT NULL,PRIMARY KEY(owner,operation_id))`, + `CREATE INDEX idx_document_render_delivery_key ON document_render_deliveries(owner,project_id,delivery_key)`}, + "postgres": {`CREATE TABLE document_render_deliveries ( + operation_id TEXT NOT NULL,owner TEXT NOT NULL,project_id TEXT NOT NULL,conversation_id TEXT NOT NULL,source_run TEXT NOT NULL DEFAULT '', + request_sha256 TEXT NOT NULL,state TEXT NOT NULL,frozen_at TEXT NOT NULL,renderer_identity TEXT NOT NULL,mode TEXT NOT NULL,document_id TEXT NOT NULL, + canonical_hash TEXT NOT NULL DEFAULT '',profile_hash TEXT NOT NULL DEFAULT '',review_hash TEXT NOT NULL DEFAULT '',delivery_key TEXT NOT NULL DEFAULT '', + markdown_artifact_id TEXT NOT NULL DEFAULT '',markdown_sha256 TEXT NOT NULL DEFAULT '',docx_artifact_id TEXT NOT NULL DEFAULT '',docx_sha256 TEXT NOT NULL DEFAULT '', + manifest_artifact_id TEXT NOT NULL DEFAULT '',manifest_sha256 TEXT NOT NULL DEFAULT '',archive_artifact_id TEXT NOT NULL DEFAULT '',archive_sha256 TEXT NOT NULL DEFAULT '', + error_text TEXT NOT NULL DEFAULT '',created_at TEXT NOT NULL,updated_at TEXT NOT NULL,PRIMARY KEY(owner,operation_id))`, + `CREATE INDEX idx_document_render_delivery_key ON document_render_deliveries(owner,project_id,delivery_key)`}, + }, + down: map[string][]string{ + "sqlite": {`DROP INDEX IF EXISTS idx_document_render_delivery_key`, `DROP TABLE IF EXISTS document_render_deliveries`}, + "postgres": {`DROP INDEX IF EXISTS idx_document_render_delivery_key`, `DROP TABLE IF EXISTS document_render_deliveries`}, + }}, + migration{version: 49, name: "role_policy_authority", + up: map[string][]string{ + "sqlite": rolePolicyFoundationUp("INTEGER"), + "postgres": rolePolicyFoundationUp("BOOLEAN"), + }, + down: map[string][]string{"sqlite": rolePolicyFoundationDown, "postgres": rolePolicyFoundationDown}, + }, + migration{version: 50, name: "role_authority_import_stage", + up: map[string][]string{"sqlite": roleAuthorityImportStageUp, "postgres": roleAuthorityImportStageUp}, + down: map[string][]string{"sqlite": {`DROP TABLE role_authority_import_state`}, "postgres": {`DROP TABLE role_authority_import_state`}}, + }, + migration{version: 51, name: "super_admin_identity", + // 超级管理员是独立的持久标记(KTD1):不从 is_admin/角色/成员资格推断; + // 部分唯一索引在库层保证至多一个标记行。 + up: map[string][]string{ + "sqlite": { + `ALTER TABLE users ADD COLUMN is_super_admin INTEGER NOT NULL DEFAULT 0`, + `CREATE UNIQUE INDEX idx_users_single_super_admin ON users(is_super_admin) WHERE is_super_admin=1`, + }, + "postgres": { + `ALTER TABLE users ADD COLUMN is_super_admin BOOLEAN NOT NULL DEFAULT FALSE`, + `CREATE UNIQUE INDEX idx_users_single_super_admin ON users(is_super_admin) WHERE is_super_admin`, + }, + }, + down: map[string][]string{ + "sqlite": {`DROP INDEX IF EXISTS idx_users_single_super_admin`, `ALTER TABLE users DROP COLUMN is_super_admin`}, + "postgres": {`DROP INDEX IF EXISTS idx_users_single_super_admin`, `ALTER TABLE users DROP COLUMN is_super_admin`}, }, }, ) } +// v35 分层 Python 环境:扩展包申请与会话绑定。层目录本身在环境根,不入库。 +var execEnvUp = []string{ + `CREATE TABLE IF NOT EXISTS exec_env_requests ( + id TEXT PRIMARY KEY, + conversation_id TEXT NOT NULL, + owner TEXT NOT NULL DEFAULT '', + spec TEXT NOT NULL, + reason TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL, + layer_id TEXT NOT NULL DEFAULT '', + detail TEXT NOT NULL DEFAULT '', + approver TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS idx_exec_env_requests_conv ON exec_env_requests(conversation_id, created_at)`, + `CREATE TABLE IF NOT EXISTS exec_env_bindings ( + conversation_id TEXT PRIMARY KEY, + base_id TEXT NOT NULL DEFAULT '', + layers TEXT NOT NULL DEFAULT '[]', + isolated TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL + )`, +} + +var execEnvDown = []string{ + `DROP TABLE IF EXISTS exec_env_bindings`, + `DROP INDEX IF EXISTS idx_exec_env_requests_conv`, + `DROP TABLE IF EXISTS exec_env_requests`, +} + +var roleAuthorityImportStageUp = []string{ + `CREATE TABLE role_authority_import_state ( + id INTEGER PRIMARY KEY,source_sha256 TEXT NOT NULL DEFAULT '',state TEXT NOT NULL DEFAULT 'idle', + payload_json TEXT NOT NULL DEFAULT '',staged_by TEXT NOT NULL DEFAULT '',updated_at TEXT NOT NULL DEFAULT '')`, + `INSERT INTO role_authority_import_state(id,source_sha256,state,payload_json,staged_by,updated_at) + VALUES(1,'','idle','','','')`, +} + +func rolePolicyFoundationUp(boolType string) []string { + statements := []string{ + `CREATE TABLE roles ( + role_id TEXT PRIMARY KEY,name TEXT NOT NULL,normalized_name TEXT NOT NULL UNIQUE, + description TEXT NOT NULL DEFAULT '',enabled ` + boolType + ` NOT NULL, + role_version BIGINT NOT NULL DEFAULT 1,created_by TEXT NOT NULL,updated_by TEXT NOT NULL, + created_at TEXT NOT NULL,updated_at TEXT NOT NULL, + FOREIGN KEY(created_by) REFERENCES users(id),FOREIGN KEY(updated_by) REFERENCES users(id))`, + `CREATE TABLE role_permissions ( + role_id TEXT NOT NULL,action_id TEXT NOT NULL,allowed ` + boolType + ` NOT NULL, + cap_allowed ` + boolType + ` NOT NULL,permission_version BIGINT NOT NULL DEFAULT 1, + updated_by TEXT NOT NULL,updated_at TEXT NOT NULL,PRIMARY KEY(role_id,action_id), + FOREIGN KEY(role_id) REFERENCES roles(role_id) ON DELETE CASCADE, + FOREIGN KEY(updated_by) REFERENCES users(id))`, + `CREATE TABLE user_role_bindings ( + binding_id TEXT PRIMARY KEY,user_id TEXT NOT NULL,role_id TEXT NOT NULL, + active ` + boolType + ` NOT NULL,binding_version BIGINT NOT NULL DEFAULT 1, + assigned_by TEXT NOT NULL,assigned_at TEXT NOT NULL,ended_at TEXT NOT NULL DEFAULT '', + FOREIGN KEY(user_id) REFERENCES users(id),FOREIGN KEY(role_id) REFERENCES roles(role_id), + FOREIGN KEY(assigned_by) REFERENCES users(id))`, + `CREATE UNIQUE INDEX uq_user_role_bindings_active ON user_role_bindings(user_id) WHERE active=TRUE`, + `CREATE INDEX idx_user_role_bindings_role ON user_role_bindings(role_id,active)`, + `CREATE TABLE system_grants ( + user_id TEXT NOT NULL,action_id TEXT NOT NULL,allowed ` + boolType + ` NOT NULL, + grant_version BIGINT NOT NULL DEFAULT 1,updated_by TEXT NOT NULL,updated_at TEXT NOT NULL, + PRIMARY KEY(user_id,action_id),FOREIGN KEY(user_id) REFERENCES users(id), + FOREIGN KEY(updated_by) REFERENCES users(id))`, + `CREATE TABLE authority_state ( + id INTEGER PRIMARY KEY,authority_epoch BIGINT NOT NULL,catalog_version BIGINT NOT NULL, + updated_at TEXT NOT NULL)`, + `INSERT INTO authority_state(id,authority_epoch,catalog_version,updated_at) VALUES(1,1,1,'')`, + `CREATE TABLE policy_receipts ( + receipt_id TEXT PRIMARY KEY,actor_user_id TEXT NOT NULL,idempotency_key TEXT NOT NULL, + operation TEXT NOT NULL,request_hash TEXT NOT NULL,result_json TEXT NOT NULL,created_at TEXT NOT NULL, + UNIQUE(actor_user_id,idempotency_key),FOREIGN KEY(actor_user_id) REFERENCES users(id))`, + } + for _, actionID := range rolePolicyAdminActionsV49 { + statements = append(statements, `INSERT INTO system_grants(user_id,action_id,allowed,grant_version,updated_by,updated_at) + SELECT id,'`+actionID+`',TRUE,1,id,created_at FROM users WHERE is_admin=TRUE`) + } + return statements +} + +var rolePolicyAdminActionsV49 = []string{ + "system.settings.read", "system.settings.write", + "system.users.view", "system.users.create", "system.users.edit", "system.users.disable", + "system.roles.view", "system.roles.manage", "system.roles.assign", + "system.permissions.view", "system.permissions.manage", "project.create", +} + +var rolePolicyFoundationDown = []string{ + `DROP TABLE policy_receipts`, + `DROP TABLE authority_state`, + `DROP TABLE system_grants`, + `DROP INDEX idx_user_role_bindings_role`, + `DROP INDEX uq_user_role_bindings_active`, + `DROP TABLE user_role_bindings`, + `DROP TABLE role_permissions`, + `DROP TABLE roles`, +} + +var recoveryFoundationUp = []string{ + `ALTER TABLE login_keys ADD COLUMN grant_origin TEXT NOT NULL DEFAULT 'legacy_unknown'`, + `ALTER TABLE login_keys ADD COLUMN generation BIGINT NOT NULL DEFAULT 0`, + `ALTER TABLE login_keys ADD COLUMN issued_auth_version INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE login_keys ADD COLUMN proposal_id TEXT NOT NULL DEFAULT ''`, + `CREATE TABLE recovery_proposals ( + id TEXT PRIMARY KEY,user_id TEXT NOT NULL,owner TEXT NOT NULL,status TEXT NOT NULL, + version BIGINT NOT NULL DEFAULT 1,generation BIGINT NOT NULL DEFAULT 0, + created_by TEXT NOT NULL,created_at TEXT NOT NULL,expires_at TEXT NOT NULL, + issued_auth_version INTEGER NOT NULL,idempotency_key TEXT NOT NULL DEFAULT '' + )`, + `CREATE INDEX idx_recovery_proposals_owner_status ON recovery_proposals(owner,status)`, + `CREATE UNIQUE INDEX uq_recovery_proposals_actor_idempotency ON recovery_proposals(created_by,idempotency_key) WHERE idempotency_key<>''`, + `CREATE TABLE recovery_proposal_proofs ( + proposal_id TEXT NOT NULL,session_hash TEXT NOT NULL,PRIMARY KEY(proposal_id,session_hash) + )`, +} + +var recoveryFoundationDown = []string{ + `DROP TABLE recovery_proposal_proofs`, + `DROP INDEX uq_recovery_proposals_actor_idempotency`, + `DROP INDEX idx_recovery_proposals_owner_status`, + `DROP TABLE recovery_proposals`, + `ALTER TABLE login_keys DROP COLUMN proposal_id`, + `ALTER TABLE login_keys DROP COLUMN issued_auth_version`, + `ALTER TABLE login_keys DROP COLUMN generation`, + `ALTER TABLE login_keys DROP COLUMN grant_origin`, +} + +// v32 preserves legacy identities and credentials. Only the explicit pending +// account primitive sets pending initialization; migration cannot infer proof. +func accountFoundationUp(enabledType, revisionExpression string) []string { + return []string{ + `ALTER TABLE users ADD COLUMN phone TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE users ADD COLUMN email TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE users ADD COLUMN staff_no TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE users ADD COLUMN department TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE users ADD COLUMN notes TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE users ADD COLUMN profile_version BIGINT NOT NULL DEFAULT 1 CHECK(profile_version>=1)`, + `ALTER TABLE users ADD COLUMN enabled ` + enabledType, + `ALTER TABLE users ADD COLUMN state_version BIGINT NOT NULL DEFAULT 1 CHECK(state_version>=1)`, + `ALTER TABLE users ADD COLUMN credential_origin TEXT NOT NULL DEFAULT 'legacy_unknown'`, + `ALTER TABLE users ADD COLUMN initialization_state TEXT NOT NULL DEFAULT 'legacy'`, + `ALTER TABLE users ADD COLUMN initialization_generation BIGINT NOT NULL DEFAULT 0 CHECK(initialization_generation>=0)`, + `ALTER TABLE sessions ADD COLUMN auth_method TEXT NOT NULL DEFAULT 'legacy_unknown'`, + `ALTER TABLE sessions ADD COLUMN credential_origin TEXT NOT NULL DEFAULT 'legacy_unknown'`, + `ALTER TABLE sessions ADD COLUMN grant_id TEXT NOT NULL DEFAULT ''`, + `CREATE TABLE account_directory_revision(id INTEGER PRIMARY KEY CHECK(id=1),public_revision TEXT NOT NULL,staff_no_revision TEXT NOT NULL,private_revision TEXT NOT NULL)`, + `INSERT INTO account_directory_revision(id,public_revision,staff_no_revision,private_revision) VALUES(1,` + revisionExpression + `,` + revisionExpression + `,` + revisionExpression + `)`, + } +} + +// Down is for isolated migration/recovery rehearsal. Operational rollback must +// respect the security compatibility floor and cannot re-enable old writers. +var accountFoundationDown = []string{ + `DROP TABLE account_directory_revision`, + `ALTER TABLE sessions DROP COLUMN grant_id`, + `ALTER TABLE sessions DROP COLUMN credential_origin`, + `ALTER TABLE sessions DROP COLUMN auth_method`, + `ALTER TABLE users DROP COLUMN initialization_generation`, + `ALTER TABLE users DROP COLUMN initialization_state`, + `ALTER TABLE users DROP COLUMN credential_origin`, + `ALTER TABLE users DROP COLUMN state_version`, + `ALTER TABLE users DROP COLUMN enabled`, + `ALTER TABLE users DROP COLUMN profile_version`, + `ALTER TABLE users DROP COLUMN notes`, + `ALTER TABLE users DROP COLUMN department`, + `ALTER TABLE users DROP COLUMN staff_no`, + `ALTER TABLE users DROP COLUMN email`, + `ALTER TABLE users DROP COLUMN phone`, +} + // v17 后台子代理完成通知的持久化队列(N2 WP5:重启后重投未投递结果)。 var v17Up = []string{ `CREATE TABLE IF NOT EXISTS pending_notices ( @@ -974,7 +1492,60 @@ func (s *Store) migrate() error { // SQLite:_txlock=immediate(OpenSQLite DSN)保证迁移事务在 BEGIN 即取写锁, // 跨进程迁移者按 busy_timeout 排队;后续版本读取发生在同一事务内, // 前一个迁移者提交后本进程看到的已是完整账本(无部分版本可见)。 - return s.migratePass() + if err := s.migratePass(); err != nil { + return err + } + pending, err := s.ensureContextScrubState() + if err != nil { + return err + } + if pending { + // The prior candidate schema may have stored full prompts. VACUUM is + // outside the DDL transaction; it compacts the isolated SQLite file + // after v41 drops those columns. Refuse to serve if compaction fails. + if _, err := s.db.Exec(`PRAGMA secure_delete=ON`); err != nil { + return fmt.Errorf("candidate context scrub: %w", err) + } + if _, err := s.db.Exec(`VACUUM`); err != nil { + return fmt.Errorf("candidate context scrub: %w", err) + } + var busy, logFrames, checkpointed int + if err := s.db.QueryRow(`PRAGMA wal_checkpoint(TRUNCATE)`).Scan(&busy, &logFrames, &checkpointed); err != nil { + return fmt.Errorf("candidate context scrub checkpoint: %w", err) + } + if busy != 0 { + return errors.New("candidate context scrub checkpoint remains busy") + } + if _, err := s.db.Exec(`UPDATE context_scope_scrub_state SET pending=0 WHERE id=1 AND pending=1`); err != nil { + return fmt.Errorf("candidate context scrub completion: %w", err) + } + } + return nil +} + +// ensureContextScrubState handles databases that ran the earlier candidate +// an earlier candidate before a durable marker existed. They are re-scrubbed. +func (s *Store) ensureContextScrubState() (bool, error) { + var rawColumns int + if err := s.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('context_manifests') WHERE name IN ('resolve_goal','resolve_target_id')`).Scan(&rawColumns); err != nil { + return false, err + } + if rawColumns != 0 { + return false, errors.New("candidate context raw scope columns remain after migration") + } + if _, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS context_scope_scrub_state(id INTEGER PRIMARY KEY CHECK(id=1),pending INTEGER NOT NULL CHECK(pending IN (0,1)))`); err != nil { + return false, err + } + // An absent row may be a partially initialized earlier candidate; mark it + // pending before any physical cleanup. A fresh v41 inserted pending=0. + if _, err := s.db.Exec(`INSERT OR IGNORE INTO context_scope_scrub_state(id,pending) VALUES(1,1)`); err != nil { + return false, err + } + var pending int + if err := s.db.QueryRow(`SELECT pending FROM context_scope_scrub_state WHERE id=1`).Scan(&pending); err != nil { + return false, err + } + return pending != 0, nil } // migratePG 以会话级咨询锁串行化并发迁移者(KTD8):锁在专用连接上获取, @@ -1021,26 +1592,32 @@ func (s *Store) migratePass() error { return fmt.Errorf("migrate: %w", err) } applied := map[int]bool{} - known := map[int]bool{} + known := map[int]string{} for _, m := range migrations { - known[m.version] = true + known[m.version] = m.name } - rows, err := tx.Query(`SELECT version FROM schema_migrations`) + rows, err := tx.Query(`SELECT version,name FROM schema_migrations`) if err != nil { return fmt.Errorf("migrate: %w", err) } for rows.Next() { var v int - if err := rows.Scan(&v); err != nil { + var name string + if err := rows.Scan(&v, &name); err != nil { rows.Close() return fmt.Errorf("migrate: %w", err) } // 兼容门禁(KTD8 应用侧只核对兼容版本):库里有本二进制不认识的版本 // 说明库由更新的构建迁移过——拒绝启动,不冒险在未知 schema 上服务。 - if !known[v] { + expectedName, knownVersion := known[v] + if !knownVersion { rows.Close() return fmt.Errorf("migrate: 数据库 schema 版本 v%d 超出本构建(最高 v%d);拒绝启动,请升级二进制", v, latestMigrationVersion()) } + if name != expectedName { + rows.Close() + return fmt.Errorf("migrate: %w", ValidateMigrationIdentity(v, name)) + } applied[v] = true } if err := rows.Err(); err != nil { @@ -1052,6 +1629,13 @@ func (s *Store) migratePass() error { if applied[m.version] { continue } + candidateRawScope := false + if m.version == 41 { + candidateRawScope, err = scrubCandidateContextScope(tx, s.dialect.name()) + if err != nil { + return fmt.Errorf("migrate v41: %w", err) + } + } stmts := m.up[s.dialect.name()] if len(stmts) == 0 { return fmt.Errorf("migrate: v%d %s 缺少 %s 方言", m.version, m.name, s.dialect.name()) @@ -1061,6 +1645,15 @@ func (s *Store) migratePass() error { return fmt.Errorf("migrate v%d (%s): %w", m.version, m.name, err) } } + if m.version == 41 && s.dialect.name() == "sqlite" { + pending := 0 + if candidateRawScope { + pending = 1 + } + if _, err := tx.Exec(`INSERT OR REPLACE INTO context_scope_scrub_state(id,pending) VALUES(1,?)`, pending); err != nil { + return fmt.Errorf("migrate v41 scrub marker: %w", err) + } + } // 故障注入点:DDL 已执行、版本未登记——历史实现在此处崩溃会留下 // 「DDL 生效而账本缺失」的中间态;同事务设计下这里失败必须整体回滚。 if fault := s.takeMigrateFault(); fault != nil { @@ -1073,6 +1666,36 @@ func (s *Store) migratePass() error { return tx.Commit() } +// Earlier candidate databases were never released but may contain raw Resolve +// goals. Fresh v40 creates no such columns; v41 removes them conditionally +// from an earlier candidate schema before serving. +func scrubCandidateContextScope(tx *sql.Tx, dialect string) (bool, error) { + found := false + for _, column := range []string{"resolve_goal", "resolve_target_id"} { + var exists int + var err error + if dialect == "sqlite" { + err = tx.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('context_manifests') WHERE name=?`, column).Scan(&exists) + } else { + err = tx.QueryRow(`SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=current_schema() AND table_name='context_manifests' AND column_name=$1`, column).Scan(&exists) + } + if err != nil { + return false, err + } + if exists == 0 { + continue + } + found = true + if _, err := tx.Exec(`UPDATE context_manifests SET ` + column + `=''`); err != nil { + return false, err + } + if _, err := tx.Exec(`ALTER TABLE context_manifests DROP COLUMN ` + column); err != nil { + return false, err + } + } + return found, nil +} + func latestMigrationVersion() int { if len(migrations) == 0 { return 0 @@ -1096,7 +1719,7 @@ func (s *Store) execInTx(stmts []string) error { // MigrationStatus 返回已应用版本列表(诊断/验收用)。 func (s *Store) MigrationStatus() ([]int, error) { - rows, err := s.db.Query(`SELECT version FROM schema_migrations ORDER BY version`) + rows, err := s.db.Query(`SELECT version,name FROM schema_migrations ORDER BY version`) if err != nil { return nil, err } @@ -1104,9 +1727,13 @@ func (s *Store) MigrationStatus() ([]int, error) { out := []int{} for rows.Next() { var v int - if err := rows.Scan(&v); err != nil { + var name string + if err := rows.Scan(&v, &name); err != nil { return nil, err } + if err := ValidateMigrationIdentity(v, name); err != nil { + return nil, fmt.Errorf("migrate: %w; refuse to roll back another schema", err) + } out = append(out, v) } return out, rows.Err() diff --git a/internal/store/migrate_atomic_test.go b/internal/store/migrate_atomic_test.go index e2421c4..6a427b3 100644 --- a/internal/store/migrate_atomic_test.go +++ b/internal/store/migrate_atomic_test.go @@ -87,6 +87,93 @@ func assertFullLedger(t *testing.T, st *Store) { } } +// Separate feature candidates reused version 31 before current main assigned +// that number to account_audit. A version match alone must not silently treat +// the candidate's different schema as an applied release migration. +func TestMigrateRejectsLedgerNameMismatch(t *testing.T) { + path := filepath.Join(t.TempDir(), "name-mismatch.db") + st := openSQLiteAt(t, path) + if _, err := st.db.Exec(`UPDATE schema_migrations SET name='work_task_bindings' WHERE version=31`); err != nil { + t.Fatal(err) + } + st.Close() + if reopened, err := OpenSQLite(path); err == nil { + reopened.Close() + t.Fatal("reused migration version with different name was accepted") + } else if !strings.Contains(err.Error(), "migration identity mismatch") { + t.Fatalf("missing fail-closed migration identity error: %v", err) + } +} + +func TestMigrateAdoptedExecEnvHistoryUpgradesWithoutReinterpretingV35(t *testing.T) { + st := openTestStore(t) + if err := st.MigrateDown(35); err != nil { + t.Fatalf("migrate down to adopted exec env: %v", err) + } + request := ExecEnvRequest{ + ID: "exec-request-1", ConversationID: "conversation-1", Owner: "alice", + Spec: "python-docx", Status: "approved", CreatedAt: "2026-10-15T00:00:00Z", UpdatedAt: "2026-10-15T00:00:00Z", + } + if err := st.InsertExecEnvRequest(request); err != nil { + t.Fatalf("insert v35 exec env row: %v", err) + } + if _, err := st.db.Exec(`SELECT COUNT(*) FROM work_task_bindings`); err == nil { + t.Fatal("v35 exec env history was silently treated as work_task_bindings") + } + if err := st.migrate(); err != nil { + t.Fatalf("upgrade adopted exec env history: %v", err) + } + if got, err := st.GetExecEnvRequest(request.ID); err != nil || got.Spec != request.Spec { + t.Fatalf("exec env row did not survive feature migrations: got=%+v err=%v", got, err) + } + if _, err := st.db.Exec(`SELECT COUNT(*) FROM work_task_bindings`); err != nil { + t.Fatalf("feature migration did not create work_task_bindings after v35: %v", err) + } +} + +func TestMigrateRejectsLegacyFeatureNumberingBeforeApplyingCurrentTail(t *testing.T) { + st := openTestStore(t) + if err := st.MigrateDown(35); err != nil { + t.Fatalf("migrate down to v35: %v", err) + } + for _, query := range []string{ + `DROP TABLE exec_env_bindings`, + `DROP TABLE exec_env_requests`, + `CREATE TABLE work_task_bindings(task_id TEXT PRIMARY KEY,owner TEXT NOT NULL,plugin_id TEXT NOT NULL,conversation_id TEXT NOT NULL,project_id TEXT NOT NULL,created_at TEXT NOT NULL,FOREIGN KEY(task_id) REFERENCES background_tasks(id) ON DELETE CASCADE)`, + `UPDATE schema_migrations SET name='work_task_bindings' WHERE version=35`, + } { + if _, err := st.db.Exec(query); err != nil { + t.Fatalf("seed legacy feature v35: %v", err) + } + } + err := st.migrate() + if err == nil || !strings.Contains(err.Error(), "legacy feature migration numbering") || !strings.Contains(err.Error(), "v35") { + t.Fatalf("legacy feature v35 was not rejected explicitly: %v", err) + } + if _, err := st.db.Exec(`SELECT COUNT(*) FROM exec_env_requests`); err == nil { + t.Fatal("rejected legacy history still applied current exec_env identity") + } + if _, err := st.db.Exec(`SELECT COUNT(*) FROM work_task_bindings`); err != nil { + t.Fatalf("rejected legacy history changed its work_task_bindings table: %v", err) + } +} + +func TestMigrateDownRejectsLedgerNameDriftBeforeChangingSchema(t *testing.T) { + path := filepath.Join(t.TempDir(), "down-name-drift.db") + st := openSQLiteAt(t, path) + if _, err := st.db.Exec(`UPDATE schema_migrations SET name='wrong-candidate' WHERE version=45`); err != nil { + t.Fatal(err) + } + err := st.MigrateDown(44) + if err == nil || !strings.Contains(err.Error(), "migration identity mismatch") { + t.Fatalf("rollback accepted a changed migration identity: %v", err) + } + var remain int + if err := st.db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE version=45`).Scan(&remain); err != nil || remain != 1 { + t.Fatalf("rollback changed ledger before refusing: remaining=%d err=%v", remain, err) + } +} + // DDL 与账本同事务:在「DDL 已执行、版本未登记」处注入故障 → 全部回滚, // 不留下 v6 的表,也不留下 v6 的账本行(历史两步实现的中间态缺口)。 func TestMigrateAtomicDDLAndLedger(t *testing.T) { diff --git a/internal/store/migrate_compat.go b/internal/store/migrate_compat.go index 7b17ce6..02f4d13 100644 --- a/internal/store/migrate_compat.go +++ b/internal/store/migrate_compat.go @@ -1,5 +1,7 @@ package store +import "fmt" + // migrationBreaksOldReaders 登记每个迁移版本落地后,schema.target 更低的旧制品 // 还能不能读这个库。新增迁移漏登记时 TestMigrationCompatRegistered 失败。 // true 会抬高 SchemaCompatFloor:候选若要把库迁过该版本,而旧制品的 target @@ -35,15 +37,79 @@ var migrationBreaksOldReaders = map[int]bool{ 28: false, // auth_version_account_events:仅加列与新表,旧读者不受影响 29: false, // redeem_attempts:新表,旧读者不受影响 30: false, // project_event_publication_proofs:新表,旧读者不受影响 - 31: false, // work_task_bindings:新增绑定表,旧后台任务读面不受影响 - 32: false, // trusted_context_manifests:独立可信回执表,旧读者不受影响 - 33: false, // workflow_step_sources:新增逐步骤来源绑定表,旧读者不受影响 - 34: false, // context_manifest_resolve_scope:可信回执增加原Resolve范围,旧读者不受影响 + 31: false, // account_audit 给 audit_events 增加带默认值的列,旧制品按列名读取 + 32: true, // 账号有效态/凭据来源是安全协议边界;旧认证写者必须排除 + 33: true, // 本人证明与 grant 绑定协议;旧恢复写者和兑换者必须排除 + 34: false, // 游标签名密钥只新增内部列;旧读者按列名读取既有字段 + 35: false, // exec_env 新表;旧读者不读这些表 + 36: false, // instance_guard 是新表,旧制品不读取它 + 37: false, // work_task_bindings:新增绑定表,旧后台任务读面不受影响 + 38: false, // trusted_context_manifests:独立可信回执表,旧读者不受影响 + 39: false, // workflow_step_sources:新增逐步骤来源绑定表,旧读者不受影响 + 40: false, // context_manifest_resolve_scope:可信回执增加范围标记,旧读者不受影响 + 41: false, // scrub_candidate_context_scope_text:候选原文列清理,旧读者不受影响 + 42: false, // command_audit_attribution_snapshot:仅加列/索引,旧读者不受影响 + 43: false, // project_audit_committed_links:新表,旧读者不受影响 + 44: false, // project_audit_committed_prefix:PG 审计 ID 默认函数,旧读者兼容 + 45: false, // project_audit_all_writers_guard:PG 在线审计写入门禁,旧读者兼容 + 46: false, // project_work_observation_outbox:独立私域投递表,旧读者不读取 + 47: false, // human_decision_proofs:独立真人确认表,旧读者不读取 + 48: false, // document_render_deliveries:新增普通工作区文档交付回执表 + 49: false, // 角色权威仅新增独立表;消费者切换由公开提供者 readiness 单独围栏 + 50: false, // 角色导入 staging 为独立表;正式角色读取在激活前不变 + 51: true, // 超级管理员标记是账号权威边界;不认识它的旧账号写者必须排除 } // SchemaTarget 是本二进制会迁移到的 schema 版本。 func SchemaTarget() int { return latestMigrationVersion() } +// MigrationName lets the offline copier reject an unpublished branch schema +// that reused a numeric version for a different migration before copying rows. +func MigrationName(version int) (string, bool) { + for _, migration := range migrations { + if migration.version == version { + return migration.name, true + } + } + return "", false +} + +// legacyFeatureMigrationNames records the unpublished project-context/audit +// candidate numbering that collided with the adopted v35 exec_env migration. +// These identities are intentionally not aliases: accepting them would mark a +// different schema as applied. Operators must first move that candidate through +// an explicit, reviewed compatibility procedure. +var legacyFeatureMigrationNames = map[int]string{ + 35: "work_task_bindings", + 36: "trusted_context_manifests", + 37: "workflow_step_sources", + 38: "context_manifest_resolve_scope", + 39: "scrub_candidate_context_scope_text", + 40: "command_audit_attribution_snapshot", + 41: "project_audit_committed_links", + 42: "project_audit_committed_prefix", + 43: "project_audit_all_writers_guard", + 44: "project_work_observation_outbox", + 45: "human_decision_proofs", +} + +// ValidateMigrationIdentity rejects any ledger row whose numeric version names +// a different migration. Known unpublished feature identities get a specific +// fail-closed error so they cannot be mistaken for the adopted exec_env history. +func ValidateMigrationIdentity(version int, actual string) error { + expected, known := MigrationName(version) + if !known { + return fmt.Errorf("migration identity mismatch at v%d; refuse to reinterpret another schema", version) + } + if actual == expected { + return nil + } + if legacyFeatureMigrationNames[version] == actual { + return fmt.Errorf("migration identity mismatch at v%d: legacy feature migration numbering %q conflicts with adopted history %q; refuse automatic reinterpretation", version, actual, expected) + } + return fmt.Errorf("migration identity mismatch at v%d; refuse to reinterpret another schema", version) +} + // SchemaCompatFloor 是旧制品仍能读取的最高破坏性版本。0 表示当前全部迁移都不破坏旧读。 func SchemaCompatFloor() int { floor := 0 diff --git a/internal/store/migrate_compat_test.go b/internal/store/migrate_compat_test.go index 6c46cf7..f005001 100644 --- a/internal/store/migrate_compat_test.go +++ b/internal/store/migrate_compat_test.go @@ -1,6 +1,9 @@ package store -import "testing" +import ( + "strings" + "testing" +) func TestMigrationCompatRegistered(t *testing.T) { if len(migrationBreaksOldReaders) != len(migrations) { @@ -18,3 +21,53 @@ func TestMigrationCompatRegistered(t *testing.T) { t.Fatalf("compatFloor=%d target=%d", SchemaCompatFloor(), SchemaTarget()) } } + +func TestMigrationTailKeepsAdoptedExecEnvBeforeFeatureHistory(t *testing.T) { + want := []struct { + version int + name string + }{ + {35, "exec_env_layers"}, + {36, "instance_guard"}, + {37, "work_task_bindings"}, + {38, "trusted_context_manifests"}, + {39, "workflow_step_sources"}, + {40, "context_manifest_resolve_scope"}, + {41, "scrub_candidate_context_scope_text"}, + {42, "command_audit_attribution_snapshot"}, + {43, "project_audit_committed_links"}, + {44, "project_audit_committed_prefix"}, + {45, "project_audit_all_writers_guard"}, + {46, "project_work_observation_outbox"}, + {47, "human_decision_proofs"}, + {48, "document_render_deliveries"}, + {49, "role_policy_authority"}, + {50, "role_authority_import_stage"}, + } + for _, item := range want { + if got, ok := MigrationName(item.version); !ok || got != item.name { + t.Fatalf("migration v%d=%q,%v want %q,true", item.version, got, ok, item.name) + } + } +} + +func TestLegacyFeatureMigrationIdentitiesAreNeverAliases(t *testing.T) { + for version, legacyName := range legacyFeatureMigrationNames { + err := ValidateMigrationIdentity(version, legacyName) + if err == nil { + t.Fatalf("legacy v%d %q was accepted", version, legacyName) + } + if got := err.Error(); !containsAll(got, "migration identity mismatch", "legacy feature migration numbering") { + t.Fatalf("legacy v%d %q was not explicitly rejected: %v", version, legacyName, err) + } + } +} + +func containsAll(value string, parts ...string) bool { + for _, part := range parts { + if !strings.Contains(value, part) { + return false + } + } + return true +} diff --git a/internal/store/notices.go b/internal/store/notices.go index 72e5327..3867141 100644 --- a/internal/store/notices.go +++ b/internal/store/notices.go @@ -15,16 +15,47 @@ type Notice struct { Owner string Name string Payload core.AgentResultPayload + TaskID string `json:"taskId,omitempty"` + AttemptID string `json:"attemptId,omitempty"` + CardID string `json:"cardId,omitempty"` + Material bool `json:"material,omitempty"` CreatedAt time.Time } // AppendNotice 落一条待投递通知(重启后可重投)。 +type noticePayload struct { + core.AgentResultPayload + TaskID string `json:"taskId,omitempty"` + AttemptID string `json:"attemptId,omitempty"` + CardID string `json:"cardId,omitempty"` + Material bool `json:"material,omitempty"` +} + func (s *Store) AppendNotice(n Notice) error { - _, err := s.db.Exec(s.q(`INSERT INTO pending_notices (id,conversation_id,owner,name,payload,created_at) VALUES(?,?,?,?,?,?)`), - n.ID, n.ConversationID, n.Owner, n.Name, marshalJSON(&n.Payload), nowISO()) + _, err := s.db.Exec(s.q(`INSERT INTO pending_notices (id,conversation_id,owner,name,payload,created_at) VALUES(?,?,?,?,?,?) ON CONFLICT(id) DO NOTHING`), + n.ID, n.ConversationID, n.Owner, n.Name, marshalJSON(¬icePayload{n.Payload, n.TaskID, n.AttemptID, n.CardID, n.Material}), nowISO()) return err } +// AppendNoticeAccountChecked stores a new result notice only while the owner +// is effective in the same transaction that creates the notice. +func (s *Store) AppendNoticeAccountChecked(n Notice, allowImplicitDefault bool) error { + return s.withEffectiveAccountWrite(n.Owner, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + var owner string + if err := tx.QueryRow(s.q(`SELECT owner FROM conversations WHERE id=?`), n.ConversationID).Scan(&owner); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + if owner != n.Owner { + return ErrNotFound + } + _, err := tx.Exec(s.q(`INSERT INTO pending_notices (id,conversation_id,owner,name,payload,created_at) VALUES(?,?,?,?,?,?) ON CONFLICT(id) DO NOTHING`), + n.ID, n.ConversationID, n.Owner, n.Name, marshalJSON(¬icePayload{n.Payload, n.TaskID, n.AttemptID, n.CardID, n.Material}), nowISO()) + return err + }) +} + // ListNotices 取回全部待投递通知(Manager 启动时装入内存队列)。 func (s *Store) ListNotices() ([]Notice, error) { rows, err := s.db.Query(s.q(`SELECT id,conversation_id,owner,name,payload,created_at FROM pending_notices ORDER BY created_at`)) @@ -39,7 +70,15 @@ func (s *Store) ListNotices() ([]Notice, error) { if err := rows.Scan(&n.ID, &n.ConversationID, &n.Owner, &n.Name, &payload, &ca); err != nil { return nil, err } - n.Payload, _ = unmarshalJSON[core.AgentResultPayload](payload) + p, err := unmarshalJSON[noticePayload](payload) + if err != nil { + return nil, err + } + n.Payload = p.AgentResultPayload + n.TaskID = p.TaskID + n.AttemptID = p.AttemptID + n.CardID = p.CardID + n.Material = p.Material n.CreatedAt, _ = parseTime(ca) out = append(out, n) } diff --git a/internal/store/one_time_password.go b/internal/store/one_time_password.go new file mode 100644 index 0000000..204514f --- /dev/null +++ b/internal/store/one_time_password.go @@ -0,0 +1,264 @@ +package store + +import ( + "crypto/rand" + "database/sql" + "encoding/hex" + "errors" + "time" +) + +const ( + LoginKeyPurposeOneTimePassword = "one_time_password" + AuthMethodOneTimePassword = "one_time_password" + PasswordChangeModeOneTimePassword = "one_time_password" + oneTimePasswordTTL = 24 * time.Hour +) + +// ErrPasswordRequired marks endpoints that require a prepared hash. +var ErrPasswordRequired = errors.New("store: password hash required") + +// IssueOneTimePassword issues an administrator one-time login password without +// modifying the account's normal password. +func (s *Store) IssueOneTimePassword(actorOwner, owner, passwordHash string) (LoginKeyMeta, error) { + if passwordHash == "" { + return LoginKeyMeta{}, ErrPasswordRequired + } + if _, err := s.GetAccountByOwner(owner); err != nil { + return LoginKeyMeta{}, err + } + tx, err := s.db.Begin() + if err != nil { + return LoginKeyMeta{}, err + } + defer tx.Rollback() + locked, err := s.lockAccountUsers(tx, actorOwner, owner) + if errors.Is(err, ErrNotFound) { + return LoginKeyMeta{}, ErrAccountForbidden + } + if err != nil { + return LoginKeyMeta{}, err + } + actor, target := locked[actorOwner], locked[owner] + if !actor.Enabled || !actor.IsAdmin || !target.Enabled { + return LoginKeyMeta{}, ErrAccountForbidden + } + if target.IsSuperAdmin { + return LoginKeyMeta{}, ErrAccountForbidden + } + if err := requireSuperAdminTargetAccess(actor, target); err != nil { + return LoginKeyMeta{}, err + } + initialization := !target.HasPassword + event, action := "one_time_password_issued", "account.one_time_password.issue" + if initialization { + event, action = "one_time_password_initialization_issued", "account.one_time_password.initialize" + } + if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND purpose=? AND status=?`), + LoginKeyStatusRevoked, owner, LoginKeyPurposeActivate, LoginKeyStatusUnused); err != nil { + return LoginKeyMeta{}, err + } + if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND purpose=? AND status=?`), + LoginKeyStatusRevoked, owner, LoginKeyPurposeOneTimePassword, LoginKeyStatusUnused); err != nil { + return LoginKeyMeta{}, err + } + if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=? AND auth_method=?`), owner, AuthMethodOneTimePassword); err != nil { + return LoginKeyMeta{}, err + } + now := time.Now().UTC() + meta := LoginKeyMeta{ + ID: newID("lk"), + Owner: owner, + Purpose: LoginKeyPurposeOneTimePassword, + Status: LoginKeyStatusUnused, + ExpiresAt: now.Add(oneTimePasswordTTL), + CreatedAt: now, + CreatedBy: actorOwner, + } + if _, err := tx.Exec(s.q(`INSERT INTO login_keys(id,owner,purpose,key_hash,status,expires_at,created_at,created_by,grant_origin,generation,issued_auth_version) + VALUES(?,?,?,?,?,?,?,?,?,?,?)`), + meta.ID, owner, meta.Purpose, passwordHash, meta.Status, meta.ExpiresAt.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), + actorOwner, CredentialOriginAdminPassword, 0, target.AuthVersion); err != nil { + return LoginKeyMeta{}, err + } + if err := s.stampLoginKeyInstance(tx, meta.ID, ""); err != nil { + return LoginKeyMeta{}, err + } + if err := s.insertAccountEvent(tx, actorOwner, "admin-http", owner, event, meta.ID); err != nil { + return LoginKeyMeta{}, err + } + if err := s.auditAccountMutation(tx, owner, actorOwner, action); err != nil { + return LoginKeyMeta{}, err + } + if err := tx.Commit(); err != nil { + return LoginKeyMeta{}, err + } + return meta, nil +} + +// LoginWithOneTimePassword verifies and consumes an unused one-time password. +func (s *Store) LoginWithOneTimePassword(owner, password string) (string, error) { + if owner == "" || password == "" { + return "", ErrBadCredentials + } + rows, err := s.db.Query(s.q(`SELECT k.id,k.key_hash,k.expires_at FROM login_keys k JOIN users u ON u.owner=k.owner + WHERE k.owner=? AND k.purpose=? AND k.status=? AND u.enabled=?`), owner, LoginKeyPurposeOneTimePassword, LoginKeyStatusUnused, true) + if err != nil { + return "", err + } + type candidate struct{ id, hash, expires string } + var candidates []candidate + for rows.Next() { + var c candidate + if err := rows.Scan(&c.id, &c.hash, &c.expires); err != nil { + rows.Close() + return "", err + } + candidates = append(candidates, c) + } + if err := rows.Err(); err != nil { + rows.Close() + return "", err + } + rows.Close() + matched := "" + for _, c := range candidates { + expiry, err := time.Parse(time.RFC3339Nano, c.expires) + if err != nil || time.Now().After(expiry) { + continue + } + if VerifyPassword(c.hash, password) { + matched = c.id + break + } + } + if matched == "" { + return "", ErrBadCredentials + } + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", err + } + token := "sess_" + hex.EncodeToString(raw) + err = s.accountTx(AccountWrite{Owner: owner, Actor: owner, Action: "account.one_time_password.login"}, func(tx *sql.Tx, _ string) error { + locked, err := s.lockAccountUsers(tx, owner) + if err != nil { + return err + } + account := locked[owner] + if !account.Enabled { + return ErrAccountForbidden + } + query := s.q(`SELECT owner,purpose,status,expires_at,issued_auth_version FROM login_keys WHERE id=?`) + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + var keyOwner, purpose, status, expires string + var issuedVersion int + if err := tx.QueryRow(query, matched).Scan(&keyOwner, &purpose, &status, &expires, &issuedVersion); err != nil { + return ErrConflict + } + expiry, err := time.Parse(time.RFC3339Nano, expires) + if err != nil || time.Now().After(expiry) || keyOwner != owner || purpose != LoginKeyPurposeOneTimePassword || + status != LoginKeyStatusUnused || issuedVersion != account.AuthVersion { + return ErrConflict + } + res, err := tx.Exec(s.q(`UPDATE login_keys SET status=?,consumed_at=? WHERE id=? AND status=?`), + LoginKeyStatusConsumed, nowISO(), matched, LoginKeyStatusUnused) + if err != nil { + return err + } + if n, err := res.RowsAffected(); err != nil || n != 1 { + return ErrConflict + } + if _, err := tx.Exec(s.q(`INSERT INTO sessions(token_hash,owner,created_at,expires_at,last_seen_at,scope,auth_version,auth_method,credential_origin,grant_id) + VALUES(?,?,?,?,?,?,?,?,?,?)`), + hashKey(token), owner, nowISO(), time.Now().Add(restrictedSessionTTL).UTC().Format(time.RFC3339Nano), nowISO(), + SessionScopeSetPasswordOnly, account.AuthVersion, AuthMethodOneTimePassword, account.CredentialOrigin, matched); err != nil { + return err + } + return s.insertAccountEvent(tx, owner, "one-time-password", owner, "one_time_password_login", matched) + }) + if err != nil { + return "", err + } + return token, nil +} + +// CompleteOneTimePasswordChecked finalizes a one-time password restricted session. +func (s *Store) CompleteOneTimePasswordChecked(token, passwordHash string) (string, error) { + if passwordHash == "" { + return "", ErrPasswordRequired + } + var owner string + if err := s.db.QueryRow(s.q(`SELECT owner FROM sessions WHERE token_hash=?`), hashKey(token)).Scan(&owner); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return "", ErrNotFound + } + return "", err + } + err := s.accountTx(AccountWrite{Owner: owner, Actor: owner, Action: "account.password.one_time_password"}, func(tx *sql.Tx, _ string) error { + locked, err := s.lockAccountUsers(tx, owner) + if err != nil { + return err + } + account := locked[owner] + if !account.Enabled { + return ErrAccountForbidden + } + sessionQuery := s.q(`SELECT owner,scope,expires_at,auth_version,auth_method,grant_id FROM sessions WHERE token_hash=?`) + grantQuery := s.q(`SELECT owner,purpose,status,issued_auth_version FROM login_keys WHERE id=?`) + if s.Dialect() == "postgres" { + sessionQuery += ` FOR UPDATE` + grantQuery += ` FOR UPDATE` + } + var sessionOwner, scope, expires, method, grantID string + var sessionVersion int + if err := tx.QueryRow(sessionQuery, hashKey(token)).Scan(&sessionOwner, &scope, &expires, &sessionVersion, &method, &grantID); err != nil { + return ErrNotFound + } + expiry, err := time.Parse(time.RFC3339Nano, expires) + if err != nil || time.Now().After(expiry) || sessionOwner != owner || scope != SessionScopeSetPasswordOnly || + method != AuthMethodOneTimePassword || sessionVersion != account.AuthVersion || grantID == "" { + return ErrAccountForbidden + } + var grantOwner, purpose, status string + var issuedVersion int + if err := tx.QueryRow(grantQuery, grantID).Scan(&grantOwner, &purpose, &status, &issuedVersion); err != nil { + return ErrAccountForbidden + } + if grantOwner != owner || purpose != LoginKeyPurposeOneTimePassword || status != LoginKeyStatusConsumed || issuedVersion != account.AuthVersion { + return ErrAccountForbidden + } + update := `UPDATE users SET password_hash=?,password_set_at=?,must_change_password=?,auth_version=auth_version+1,credential_origin=? + WHERE owner=? AND auth_version=? AND enabled=?` + args := []any{passwordHash, nowISO(), false, CredentialOriginAdminPassword, owner, account.AuthVersion, true} + if !account.HasPassword { + update = `UPDATE users SET password_hash=?,password_set_at=?,must_change_password=?,auth_version=auth_version+1,credential_origin=?, + initialization_state=?,state_version=state_version+1 WHERE owner=? AND auth_version=? AND enabled=? AND password_hash=''` + args = []any{passwordHash, nowISO(), false, CredentialOriginAdminPassword, InitializationInitialized, owner, account.AuthVersion, true} + } + res, err := tx.Exec(s.q(update), args...) + if err != nil { + return err + } + if n, err := res.RowsAffected(); err != nil || n != 1 { + return ErrConflict + } + if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`), LoginKeyStatusRevoked, owner, LoginKeyStatusUnused); err != nil { + return err + } + if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil { + return err + } + return s.insertAccountEvent(tx, owner, "one-time-password", owner, "password_set", LoginKeyPurposeOneTimePassword+" "+grantID) + }) + return owner, err +} + +// InvalidateUnusedOneTimePasswords revokes outstanding unused one-time passwords. +func (s *Store) InvalidateUnusedOneTimePasswords(owner string) error { + _, err := s.db.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND purpose=? AND status=?`), + LoginKeyStatusRevoked, owner, LoginKeyPurposeOneTimePassword, LoginKeyStatusUnused) + return err +} diff --git a/internal/store/one_time_password_test.go b/internal/store/one_time_password_test.go new file mode 100644 index 0000000..8581f5f --- /dev/null +++ b/internal/store/one_time_password_test.go @@ -0,0 +1,139 @@ +package store + +import ( + "errors" + "path/filepath" + "testing" +) + +func TestIssueOneTimePasswordAndComplete(t *testing.T) { + st, err := OpenSQLite(filepath.Join(t.TempDir(), "otp.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if err := st.CreateAccount("admin", "", "admin-hash", true); err != nil { + t.Fatal(err) + } + hash, err := HashPassword("normal-pass-1") + if err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("alice", "", hash, false); err != nil { + t.Fatal(err) + } + if err := st.ClearMustChangePassword("alice"); err != nil { + t.Fatal(err) + } + otpHash, err := HashPassword("otp-pass-1") + if err != nil { + t.Fatal(err) + } + meta, err := st.IssueOneTimePassword("admin", "alice", otpHash) + if err != nil || meta.ID == "" || meta.Purpose != LoginKeyPurposeOneTimePassword { + t.Fatalf("issue: %+v %v", meta, err) + } + tok, err := st.LoginWithOneTimePassword("alice", "otp-pass-1") + if err != nil || tok == "" { + t.Fatalf("otp login: %q %v", tok, err) + } + if _, err := st.LoginWithOneTimePassword("alice", "otp-pass-1"); err == nil { + t.Fatal("consumed otp reused") + } + newHash, err := HashPassword("new-pass-2") + if err != nil { + t.Fatal(err) + } + if _, err := st.CompleteOneTimePasswordChecked(tok, newHash); err != nil { + t.Fatalf("complete otp: %v", err) + } + if _, _, ok := st.SessionInfo(tok); ok { + t.Fatal("otp session survived completion") + } + if _, ok := st.VerifyLogin("alice", "new-pass-2"); !ok { + t.Fatal("new password cannot login") + } + if _, ok := st.VerifyLogin("alice", "normal-pass-1"); ok { + t.Fatal("old password still valid") + } +} + +func TestOneTimePasswordNormalLoginInvalidatesUnused(t *testing.T) { + st, err := OpenSQLite(filepath.Join(t.TempDir(), "otp_invalidate.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if err := st.CreateAccount("admin", "", "admin-hash", true); err != nil { + t.Fatal(err) + } + hash, err := HashPassword("normal-pass-1") + if err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("nora", "", hash, false); err != nil { + t.Fatal(err) + } + if err := st.ClearMustChangePassword("nora"); err != nil { + t.Fatal(err) + } + otpHash, err := HashPassword("otp-pass-1") + if err != nil { + t.Fatal(err) + } + if _, err := st.IssueOneTimePassword("admin", "nora", otpHash); err != nil { + t.Fatal(err) + } + if _, _, ok := st.VerifyLoginWithVersion("nora", "normal-pass-1"); !ok { + t.Fatal("normal password no longer accepted") + } + if err := st.InvalidateUnusedOneTimePasswords("nora"); err != nil { + t.Fatal(err) + } + if _, err := st.LoginWithOneTimePassword("nora", "otp-pass-1"); !errors.Is(err, ErrBadCredentials) { + t.Fatalf("otp remained valid: %v", err) + } +} + +func TestOneTimePasswordInitializesHistoricalPasswordlessAccount(t *testing.T) { + st, err := OpenSQLite(filepath.Join(t.TempDir(), "otp_init.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if err := st.CreateAccount("admin", "", "admin-hash", true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("legacy-empty", "", "", false); err != nil { + t.Fatal(err) + } + before, err := st.GetAccountByOwner("legacy-empty") + if err != nil || before.HasPassword || before.InitializationState != InitializationLegacy { + t.Fatalf("fixture: %+v %v", before, err) + } + otpHash, err := HashPassword("otp-pass-1") + if err != nil { + t.Fatal(err) + } + if _, err := st.IssueOneTimePassword("admin", "legacy-empty", otpHash); err != nil { + t.Fatal(err) + } + tok, err := st.LoginWithOneTimePassword("legacy-empty", "otp-pass-1") + if err != nil { + t.Fatal(err) + } + finalHash, err := HashPassword("final-pass-1") + if err != nil { + t.Fatal(err) + } + if _, err := st.CompleteOneTimePasswordChecked(tok, finalHash); err != nil { + t.Fatal(err) + } + after, err := st.GetAccountByOwner("legacy-empty") + if err != nil || !after.HasPassword || after.InitializationState != InitializationInitialized { + t.Fatalf("account not initialized: %+v %v", after, err) + } + if _, ok := st.VerifyLogin("legacy-empty", "final-pass-1"); !ok { + t.Fatal("initialized account cannot login") + } +} diff --git a/internal/store/owner_initialization.go b/internal/store/owner_initialization.go new file mode 100644 index 0000000..91afb63 --- /dev/null +++ b/internal/store/owner_initialization.go @@ -0,0 +1,19 @@ +package store + +// InitializePendingAccount is reserved for the local machine/DB owner CLI. It +// accepts a stable user ID and expected versions, never an actor string from a +// request. The audit actor is the actual local OS UID. The caller must use the +// protected delivery channel; a failed delivery is retried with the same ID. +func (s *Store) InitializePendingAccount(userID string, expectedStateVersion, expectedGeneration int64) (string, error) { + out, err := s.InitializePendingAccountWithAudit(userID, expectedStateVersion, expectedGeneration, "") + return out.Plain, err +} + +// CreateLocalPendingAccount is the local owner-only new-account path. Account +// and first activation grant commit together, including a local administrator +// flag when explicitly requested by the machine/DB owner CLI. That flag is now +// rejected: administrator appointment is reserved to the super administrator. +func (s *Store) CreateLocalPendingAccount(owner, name string, isAdmin bool) (Account, string, error) { + account, out, err := s.CreateLocalPendingAccountWithAudit(owner, name, isAdmin, "") + return account, out.Plain, err +} diff --git a/internal/store/owner_initialization_audit.go b/internal/store/owner_initialization_audit.go new file mode 100644 index 0000000..3ce5881 --- /dev/null +++ b/internal/store/owner_initialization_audit.go @@ -0,0 +1,146 @@ +package store + +import ( + "crypto/rand" + "database/sql" + "encoding/hex" + "fmt" + "os" + "strconv" + "time" +) + +func localOwnerActor() string { return fmt.Sprintf("local-owner:uid:%d", os.Geteuid()) } + +func newActivationPlain() (string, error) { + var raw [32]byte + if _, err := rand.Read(raw[:]); err != nil { + return "", err + } + return "alk_" + hex.EncodeToString(raw[:]), nil +} + +// InitializePendingAccountWithAudit reissues only a trusted pending account's +// activation grant. The account mutation, grant, and success audit commit together. +func (s *Store) InitializePendingAccountWithAudit(userID string, expectedStateVersion, expectedGeneration int64, instanceID string) (AccountOutcome, error) { + var out AccountOutcome + if userID == "" || expectedStateVersion < 1 || expectedGeneration < 0 { + return out, ErrConflict + } + pre, err := s.GetAccount(userID) + if err != nil { + return out, err + } + plain, err := newActivationPlain() + if err != nil { + return out, err + } + operator := localOwnerActor() + w := AccountWrite{Owner: pre.Owner, Actor: strconv.Itoa(os.Geteuid()), InstanceID: instanceID, Action: "account.initialize"} + err = s.accountTx(w, func(tx *sql.Tx, opID string) error { + locked, err := s.lockAccountUsers(tx, pre.Owner) + if err != nil { + return err + } + account := locked[pre.Owner] + if account.ID != userID || account.StateVersion != expectedStateVersion || account.InitializationGeneration != expectedGeneration { + return ErrConflict + } + if !account.Enabled || account.IsSuperAdmin || account.InitializationState != InitializationPending || account.HasPassword || account.CredentialOrigin != CredentialOriginLegacyUnknown { + return ErrAccountForbidden + } + for _, query := range []string{ + `SELECT COUNT(1) FROM api_keys WHERE owner=? AND revoked=0`, + `SELECT COUNT(1) FROM sessions WHERE owner=? AND scope='full'`, + `SELECT COUNT(1) FROM login_keys WHERE owner=? AND grant_origin<>'owner_initialization'`, + } { + var unsafeCreds int + if err := tx.QueryRow(s.q(query), account.Owner).Scan(&unsafeCreds); err != nil { + return err + } + if unsafeCreds != 0 { + return ErrAccountForbidden + } + } + nextGeneration := account.InitializationGeneration + 1 + nextAuthVersion := account.AuthVersion + 1 + result, err := tx.Exec(s.q(`UPDATE users SET initialization_generation=?,state_version=state_version+1,auth_version=auth_version+1 + WHERE id=? AND state_version=? AND initialization_generation=? AND initialization_state=? AND password_hash='' AND enabled=?`), + nextGeneration, userID, expectedStateVersion, expectedGeneration, InitializationPending, true) + if err != nil { + return err + } + if n, err := result.RowsAffected(); err != nil || n != 1 { + return ErrConflict + } + now := time.Now().UTC() + id := newID("lk") + if _, err := s.insertLoginKeyTx(tx, account.Owner, LoginKeyPurposeActivate, operator, plain, id, now, now.Add(defaultLoginKeyTTL), + grantBinding{origin: CredentialOriginOwnerInitialization, generation: nextGeneration, version: nextAuthVersion}); err != nil { + return err + } + if err := s.stampLoginKeyInstance(tx, id, instanceID); err != nil { + return err + } + if err := s.insertAccountEvent(tx, operator, "local-owner-cli", account.Owner, "initialization_issued", fmt.Sprintf("user_id=%s generation=%d", userID, nextGeneration)); err != nil { + return err + } + out = AccountOutcome{Plain: plain, CredentialID: id, OperationID: opID} + return nil + }) + return out, err +} + +// CreateLocalPendingAccountWithAudit admits a new pending account through the +// same serialized, audited account transaction as other maintenance actions. +// No local CLI path may appoint an administrator (R4, R5): isAdmin is +// rejected before any write; only the server-side super administrator can. +func (s *Store) CreateLocalPendingAccountWithAudit(owner, name string, isAdmin bool, instanceID string) (Account, AccountOutcome, error) { + var account Account + var out AccountOutcome + if isAdmin { + return account, out, fmt.Errorf("%w: administrator appointment is reserved to the super administrator", ErrAccountForbidden) + } + plain, err := newActivationPlain() + if err != nil { + return account, out, err + } + operator := localOwnerActor() + w := AccountWrite{Owner: owner, Actor: strconv.Itoa(os.Geteuid()), InstanceID: instanceID, Action: "account.create.local_pending"} + err = s.accountTx(w, func(tx *sql.Tx, opID string) error { + if err := s.dialect.lockBootstrap(tx); err != nil { + return err + } + var users int + if err := tx.QueryRow(`SELECT COUNT(1) FROM users`).Scan(&users); err != nil { + return err + } + if users == 0 { + return ErrConflict + } + var err error + account, err = s.insertPendingAccountTx(tx, owner, AccountProfile{Name: name}) + if err != nil { + return err + } + if _, err := tx.Exec(s.q(`UPDATE users SET is_admin=?,initialization_generation=? WHERE id=?`), isAdmin, 1, account.ID); err != nil { + return err + } + now := time.Now().UTC() + id := newID("lk") + if _, err := s.insertLoginKeyTx(tx, owner, LoginKeyPurposeActivate, operator, plain, id, now, now.Add(defaultLoginKeyTTL), + grantBinding{origin: CredentialOriginOwnerInitialization, generation: 1, version: 0}); err != nil { + return err + } + if err := s.stampLoginKeyInstance(tx, id, instanceID); err != nil { + return err + } + if err := s.insertAccountEvent(tx, operator, "local-owner-cli", owner, "local_account_created", account.ID); err != nil { + return err + } + account.IsAdmin, account.InitializationGeneration = isAdmin, 1 + out = AccountOutcome{Plain: plain, CredentialID: id, OperationID: opID} + return nil + }) + return account, out, err +} diff --git a/internal/store/owner_initialization_test.go b/internal/store/owner_initialization_test.go new file mode 100644 index 0000000..8d7189d --- /dev/null +++ b/internal/store/owner_initialization_test.go @@ -0,0 +1,56 @@ +package store + +import ( + "errors" + "testing" +) + +func TestOwnerInitializationReissuesSamePendingIdentityAndClosesAfterCompletion(t *testing.T) { + st := openTestStore(t) + t.Logf("owner initialization dialect=%s", st.Dialect()) + if err := st.CreateAccount("admin", "", "admin-hash", true); err != nil { + t.Fatal(err) + } + pending, err := st.CreatePendingAccount("admin", "alice", AccountProfile{Name: "Alice"}) + if err != nil { + t.Fatal(err) + } + first, err := st.InitializePendingAccount(pending.ID, pending.StateVersion, pending.InitializationGeneration) + if err != nil || first == "" { + t.Fatalf("initialize pending: %v", err) + } + firstSession, owner, err := st.RedeemLoginKey(first) + if err != nil || owner != "alice" { + t.Fatalf("redeem first activation: %v %q", err, owner) + } + stillPending, err := st.GetAccount(pending.ID) + if err != nil { + t.Fatal(err) + } + second, err := st.InitializePendingAccount(pending.ID, stillPending.StateVersion, stillPending.InitializationGeneration) + if err != nil || second == first { + t.Fatalf("reissue: %v", err) + } + if _, _, ok := st.SessionInfo(firstSession); ok { + t.Fatal("reissue left old restricted session active") + } + secondSession, owner, err := st.RedeemLoginKey(second) + if err != nil || owner != "alice" { + t.Fatalf("redeem second activation: %v %q", err, owner) + } + hash, err := HashPassword("alice-initialized-password") + if err != nil { + t.Fatal(err) + } + if _, err := st.CompleteSetPasswordChecked(secondSession, hash); err != nil { + t.Fatalf("complete initial password: %v", err) + } + completed, err := st.GetAccount(pending.ID) + if err != nil || completed.ID != pending.ID || completed.Owner != pending.Owner || completed.InitializationState != InitializationInitialized || + completed.CredentialOrigin != CredentialOriginOwnerInitialization || !completed.HasPassword { + t.Fatalf("completion changed identity or lost trusted lineage: %+v %v", completed, err) + } + if _, err := st.InitializePendingAccount(pending.ID, completed.StateVersion, completed.InitializationGeneration); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("completed account could be initialized again: %v", err) + } +} diff --git a/internal/store/plan_todo_account_write.go b/internal/store/plan_todo_account_write.go new file mode 100644 index 0000000..4255096 --- /dev/null +++ b/internal/store/plan_todo_account_write.go @@ -0,0 +1,39 @@ +package store + +import ( + "database/sql" + "errors" + + core "agent-system-core" +) + +func (s *Store) SavePlanAccountChecked(actor, conversationID string, plan core.PlanSnapshot, allowImplicitDefault bool) error { + return s.saveConversationPayloadAccountChecked(actor, conversationID, "plans", marshalJSON(plan), allowImplicitDefault) +} + +func (s *Store) SaveTodosAccountChecked(actor, conversationID string, todos core.TodoList, allowImplicitDefault bool) error { + return s.saveConversationPayloadAccountChecked(actor, conversationID, "todos", marshalJSON(todos), allowImplicitDefault) +} + +func (s *Store) saveConversationPayloadAccountChecked(actor, conversationID, table, payload string, allowImplicitDefault bool) error { + if table != "plans" && table != "todos" { + return ErrAccountForbidden + } + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + if err := s.dialect.lockConversation(tx, conversationID); err != nil { + return err + } + var owner string + if err := tx.QueryRow(s.q(`SELECT owner FROM conversations WHERE id=?`), conversationID).Scan(&owner); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + if owner != actor { + return ErrNotFound + } + _, err := tx.Exec(s.q(`INSERT INTO `+table+`(conversation_id,payload) VALUES(?,?) + ON CONFLICT(conversation_id) DO UPDATE SET payload=excluded.payload`), conversationID, payload) + return err + }) +} diff --git a/internal/store/platform.go b/internal/store/platform.go index 0c11aa8..2b65b9d 100644 --- a/internal/store/platform.go +++ b/internal/store/platform.go @@ -2,6 +2,7 @@ package store import ( "database/sql" + "errors" "fmt" "time" @@ -235,6 +236,37 @@ func (s *Store) SavePendingConfirm(cardID, owner, payload string) error { return err } +func (s *Store) SavePendingConfirmAccountChecked(cardID, owner, payload string, allowImplicitDefault bool) error { + now := time.Now().UTC().Format(time.RFC3339) + return s.withEffectiveAccountWrite(owner, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + _, err := tx.Exec(s.q(`INSERT INTO pending_confirms(card_id,owner,payload,created_at) VALUES(?,?,?,?) + ON CONFLICT(card_id) DO UPDATE SET owner=excluded.owner, payload=excluded.payload, created_at=excluded.created_at`), + cardID, owner, payload, now) + return err + }) +} + +func (s *Store) TakePendingConfirmAccountChecked(cardID, actor string, allowImplicitDefault bool) (owner, payload string, ok bool, err error) { + err = s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + err := tx.QueryRow(s.q(`SELECT owner,payload FROM pending_confirms WHERE card_id=?`), cardID).Scan(&owner, &payload) + if errors.Is(err, sql.ErrNoRows) { + return nil + } + if err != nil { + return err + } + if owner != actor { + return ErrNotFound + } + if _, err := tx.Exec(s.q(`DELETE FROM pending_confirms WHERE card_id=? AND owner=?`), cardID, actor); err != nil { + return err + } + ok = true + return nil + }) + return owner, payload, ok, err +} + func (s *Store) TakePendingConfirm(cardID string) (owner, payload string, ok bool, err error) { tx, err := s.db.Begin() if err != nil { @@ -279,6 +311,18 @@ type BudgetStatus struct { } func (s *Store) AccountTokens(owner string, tokens, budget int) (BudgetStatus, error) { + if tokens == 0 { + day := time.Now().UTC().Format("2006-01-02") + var total int + err := s.db.QueryRow(s.q(`SELECT tokens FROM llm_usage_daily WHERE owner=? AND day=?`), owner, day).Scan(&total) + if errors.Is(err, sql.ErrNoRows) { + return BudgetStatus{Over: false}, nil + } + if err != nil { + return BudgetStatus{}, fmt.Errorf("llm usage: %w", err) + } + return BudgetStatus{Total: total, Over: budget > 0 && total > budget}, nil + } total, err := s.AddDailyTokens(owner, "", tokens) if err != nil { return BudgetStatus{}, fmt.Errorf("llm usage: %w", err) diff --git a/internal/store/platform_account_write.go b/internal/store/platform_account_write.go new file mode 100644 index 0000000..cea41a6 --- /dev/null +++ b/internal/store/platform_account_write.go @@ -0,0 +1,95 @@ +package store + +import ( + "database/sql" + "time" + + core "agent-system-core" +) + +func (s *Store) CreateBackgroundTaskAccountChecked(actor, kind, title, conversationID string, allowImplicitDefault bool) (BackgroundTask, error) { + now := time.Now().UTC().Format(time.RFC3339) + task := BackgroundTask{ID: NewID("task"), Owner: actor, Kind: kind, Title: title, Status: "queued", + ConversationID: conversationID, CreatedAt: now, UpdatedAt: now} + err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + _, err := tx.Exec(s.q(`INSERT INTO background_tasks(id,owner,kind,title,status,progress,error,conversation_id,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?)`), task.ID, actor, task.Kind, task.Title, task.Status, task.Progress, + task.Error, task.ConversationID, task.CreatedAt, task.UpdatedAt) + return err + }) + return task, err +} + +func (s *Store) UpdateBackgroundTaskAccountChecked(actor, id, status string, progress int, errText string, allowImplicitDefault bool) error { + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + res, err := tx.Exec(s.q(`UPDATE background_tasks SET status=?,progress=?,error=?,updated_at=? WHERE id=? AND owner=?`), + status, progress, errText, time.Now().UTC().Format(time.RFC3339), id, actor) + if err != nil { + return err + } + if n, err := res.RowsAffected(); err != nil { + return err + } else if n != 1 { + return ErrNotFound + } + return nil + }) +} + +func (s *Store) PutMemoryAccountChecked(actor string, entry core.MemoryEntry, allowImplicitDefault bool) (core.MemoryEntry, error) { + if entry.ID == "" { + entry.ID = NewID("mem") + } + now := time.Now().UTC() + if entry.CreatedAt.IsZero() { + entry.CreatedAt = now + } + entry.LastAccessed = now + expiry := "" + if entry.ExpiresAt != nil { + expiry = entry.ExpiresAt.UTC().Format(time.RFC3339) + } + err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + _, err := tx.Exec(s.q(`INSERT INTO memory_entries(id,owner,scope,project_id,typ,content,source,confidence,created_at,last_accessed,expires_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?)`), entry.ID, actor, entry.Scope, string(entry.ProjectID), entry.Type, + entry.Content, entry.Source, entry.Confidence, entry.CreatedAt.UTC().Format(time.RFC3339), + entry.LastAccessed.UTC().Format(time.RFC3339), expiry) + return err + }) + return entry, err +} + +func (s *Store) AppendAuditEventAccountChecked(actor string, event AuditEvent, allowImplicitDefault bool) error { + event.Owner = actor + if event.Actor == "" { + event.Actor = actor + } + if event.Time == "" { + event.Time = time.Now().UTC().Format(time.RFC3339) + } + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + _, err := tx.Exec(s.q(`INSERT INTO audit_events(time,owner,action,actor,target,evidence,trace_id) VALUES(?,?,?,?,?,?,?)`), + event.Time, actor, event.Action, event.Actor, event.Target, event.Evidence, event.TraceID) + return err + }) +} + +func (s *Store) SetPluginEnabledAccountChecked(actor, pluginID string, enabled, allowImplicitDefault bool) error { + flag := 0 + if enabled { + flag = 1 + } + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + _, err := tx.Exec(s.q(`INSERT INTO plugin_grants(owner,plugin_id,enabled) VALUES(?,?,?) + ON CONFLICT(owner,plugin_id) DO UPDATE SET enabled=excluded.enabled`), actor, pluginID, flag) + return err + }) +} + +func (s *Store) PutPluginKVAccountChecked(actor, pluginID, key, value string, allowImplicitDefault bool) error { + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + _, err := tx.Exec(s.q(`INSERT INTO plugin_kv(plugin_id,owner,k,v) VALUES(?,?,?,?) + ON CONFLICT(plugin_id,owner,k) DO UPDATE SET v=excluded.v`), pluginID, actor, key, value) + return err + }) +} diff --git a/internal/store/plugin_account_write.go b/internal/store/plugin_account_write.go new file mode 100644 index 0000000..dad4ee6 --- /dev/null +++ b/internal/store/plugin_account_write.go @@ -0,0 +1,41 @@ +package store + +import ( + "database/sql" + "fmt" + "time" + + core "agent-system-core" +) + +// UpsertPluginAccountChecked fences the node-level plugin registry write with +// the request actor's effective account state. Admin authorization belongs to +// the caller; an enabled account alone does not authorize plugin management. +func (s *Store) UpsertPluginAccountChecked(actor, id, dir string, m core.PluginManifest, allowImplicitDefault bool) error { + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + _, err := tx.Exec(s.q(`INSERT INTO plugins(id,dir,manifest,enabled,loaded_at) VALUES(?,?,?,?,?) + ON CONFLICT(id) DO UPDATE SET dir=excluded.dir, manifest=excluded.manifest, enabled=excluded.enabled, loaded_at=excluded.loaded_at`), + id, dir, marshalJSON(m), 1, nowISO()) + return err + }) +} + +// SetPluginSchemaVersionAccountChecked checks the stored version and writes it +// in the final actor-fenced transaction. It preserves the legacy loader's +// refusal to downgrade a plugin schema. +func (s *Store) SetPluginSchemaVersionAccountChecked(actor, pluginID string, version int, allowImplicitDefault bool) error { + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + var current int + err := tx.QueryRow(s.q(`SELECT version FROM plugin_schema WHERE plugin_id=?`), pluginID).Scan(¤t) + if err != nil && err != sql.ErrNoRows { + return err + } + if version < current { + return fmt.Errorf("plugins: %s: schema %d older than stored %d", pluginID, version, current) + } + _, err = tx.Exec(s.q(`INSERT INTO plugin_schema(plugin_id,version,updated_at) VALUES(?,?,?) + ON CONFLICT(plugin_id) DO UPDATE SET version=excluded.version, updated_at=excluded.updated_at`), + pluginID, version, time.Now().UTC().Format(time.RFC3339)) + return err + }) +} diff --git a/internal/store/plugin_account_write_test.go b/internal/store/plugin_account_write_test.go new file mode 100644 index 0000000..b1b3332 --- /dev/null +++ b/internal/store/plugin_account_write_test.go @@ -0,0 +1,102 @@ +package store + +import ( + "errors" + "path/filepath" + "testing" + "time" + + core "agent-system-core" +) + +func TestPluginAccountCheckedWritesStopAfterDisable(t *testing.T) { + st, err := OpenSQLite(filepath.Join(t.TempDir(), "plugins.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + for _, a := range []struct { + owner string + admin bool + }{{"admin", true}, {"alice", false}} { + if err := st.CreateAccount(a.owner, "", "hash", a.admin); err != nil { + t.Fatal(err) + } + } + manifest := core.PluginManifest{ID: "p", Name: "P", Version: "1.0.0"} + if err := st.UpsertPluginAccountChecked("alice", "p", "/tmp/p", manifest, false); err != nil { + t.Fatal(err) + } + if err := st.SetPluginSchemaVersionAccountChecked("alice", "p", 2, false); err != nil { + t.Fatal(err) + } + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + manifest.Version = "2.0.0" + if err := st.UpsertPluginAccountChecked("alice", "p", "/tmp/p2", manifest, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("upsert after disable: %v", err) + } + if err := st.SetPluginSchemaVersionAccountChecked("alice", "p", 3, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("schema update after disable: %v", err) + } + plugins, err := st.ListPlugins() + if err != nil || len(plugins) != 1 || plugins[0].Version != "1.0.0" { + t.Fatalf("plugin row changed: %+v, %v", plugins, err) + } + if version, err := st.PluginSchemaVersion("p"); err != nil || version != 2 { + t.Fatalf("schema version changed: %d, %v", version, err) + } +} + +func TestPGPluginAccountCheckedWriteSerializesWithDisable(t *testing.T) { + st := openTestStore(t) + if st.Dialect() != "postgres" { + t.Skip("real PostgreSQL required") + } + other := reopenAccountModelStore(t, st) + if err := st.CreateAccount("admin", "", "hash", true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("alice", "", "hash", false); err != nil { + t.Fatal(err) + } + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + release := holdPGAccountWrite(t, st, "plugins", "INSERT", "plugin_upsert") + writeDone := make(chan error, 1) + go func() { + writeDone <- st.UpsertPluginAccountChecked("alice", "p", "/tmp/p", core.PluginManifest{ID: "p", Name: "P", Version: "1"}, false) + }() + waitForPGAccountWrite(t, st, "plugin_upsert") + disableDone := make(chan error, 1) + go func() { + _, err := other.SetAccountEnabled("admin", account.ID, account.StateVersion, false) + disableDone <- err + }() + select { + case err := <-disableDone: + t.Fatalf("disable committed ahead of paused plugin upsert: %v", err) + case <-time.After(100 * time.Millisecond): + } + release() + if err := awaitPGAccountResult(t, writeDone); err != nil { + t.Fatal(err) + } + if err := awaitPGAccountResult(t, disableDone); err != nil { + t.Fatal(err) + } + if err := st.UpsertPluginAccountChecked("alice", "late", "/tmp/late", core.PluginManifest{ID: "late", Name: "Late", Version: "1"}, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("late plugin write passed after disable: %v", err) + } + plugins, err := st.ListPlugins() + if err != nil || len(plugins) != 1 || plugins[0].ID != "p" { + t.Fatalf("plugin writes crossed disable boundary: %+v, %v", plugins, err) + } +} diff --git a/internal/store/profile_prompt_fence_test.go b/internal/store/profile_prompt_fence_test.go new file mode 100644 index 0000000..3163e1d --- /dev/null +++ b/internal/store/profile_prompt_fence_test.go @@ -0,0 +1,157 @@ +package store + +import ( + "errors" + "testing" + "time" + + core "agent-system-core" +) + +func TestProfileAndPromptAccountCheckedWrites(t *testing.T) { + st := openTestStore(t) + t.Logf("profile and prompt fence dialect=%s", st.Dialect()) + for _, account := range []struct { + owner string + admin bool + }{{"admin", true}, {"alice", false}, {"bob", false}} { + if err := st.CreateAccount(account.owner, "", "", account.admin); err != nil { + t.Fatal(err) + } + } + profile := core.AgentProfile{ID: "owned-profile", Name: "Alice"} + if err := st.SaveProfileAccountChecked("missing", profile, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("missing actor saved profile: %v", err) + } + if err := st.SaveProfileAccountChecked("alice", profile, false); err != nil { + t.Fatal(err) + } + if err := st.SaveProfileAccountChecked("alice", core.AgentProfile{ID: "delete-me"}, false); err != nil { + t.Fatal(err) + } + if err := st.DeleteProfileAccountChecked("alice", "delete-me", false); err != nil { + t.Fatal(err) + } + if _, err := st.GetProfile("alice", "delete-me"); !errors.Is(err, ErrNotFound) { + t.Fatalf("successful profile delete left its row behind: %v", err) + } + if err := st.SaveProfileAccountChecked("bob", core.AgentProfile{ID: profile.ID, Name: "Stolen"}, false); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign actor took over profile ID: %v", err) + } + if err := st.SaveProfileAccountChecked("alice", core.AgentProfile{ID: "spoofed-owner", Owner: "bob"}, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("actor saved a profile for another owner: %v", err) + } + if err := st.DeleteProfileAccountChecked("bob", profile.ID, false); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign actor deleted profile: %v", err) + } + if err := st.SaveProfileAccountChecked("alice", core.AgentProfile{ID: "forged-builtin", Builtin: true}, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("user saved globally visible builtin profile: %v", err) + } + if err := st.SaveProfile(core.AgentProfile{ID: "default", Builtin: true, Name: "builtin"}); err != nil { + t.Fatal(err) + } + if err := st.SaveProfileAccountChecked("alice", core.AgentProfile{ID: "default", Name: "replacement"}, false); !errors.Is(err, ErrNotFound) { + t.Fatalf("user replaced a stored builtin profile: %v", err) + } + stored, err := st.GetProfile("alice", profile.ID) + if err != nil || stored.Owner != "alice" || stored.Name != "Alice" || stored.Builtin { + t.Fatalf("profile owner/content changed: %+v %v", stored, err) + } + module := core.PromptModule{ID: "task.example", Layer: core.LayerTask, Body: "Alice"} + if err := st.SavePromptModuleAccountChecked("alice", module, false); err != nil { + t.Fatal(err) + } + if err := st.SavePromptModuleAccountChecked("alice", core.PromptModule{ID: "spoofed-owner", Owner: "bob"}, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("actor saved a prompt for another owner: %v", err) + } + if err := st.SavePromptModuleAccountChecked("bob", core.PromptModule{ID: module.ID, Layer: core.LayerTask, Body: "Bob"}, false); err != nil { + t.Fatalf("different owner could not use the same module ID: %v", err) + } + if err := st.DeletePromptModuleAccountChecked("bob", module.ID, false); err != nil { + t.Fatal(err) + } + if err := st.DeletePromptModuleAccountChecked("bob", module.ID, false); err != nil { + t.Fatalf("missing owner-scoped module delete changed legacy no-op semantics: %v", err) + } + storedModule, err := st.GetPromptModule("alice", module.ID) + if err != nil || storedModule.Owner != "alice" || storedModule.Body != "Alice" { + t.Fatalf("foreign module delete affected alice: %+v %v", storedModule, err) + } + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + for _, write := range []func() error{ + func() error { + return st.SaveProfileAccountChecked("alice", core.AgentProfile{ID: profile.ID, Name: "late"}, false) + }, + func() error { return st.DeleteProfileAccountChecked("alice", profile.ID, false) }, + func() error { + return st.SavePromptModuleAccountChecked("alice", core.PromptModule{ID: module.ID, Body: "late"}, false) + }, + func() error { return st.DeletePromptModuleAccountChecked("alice", module.ID, false) }, + } { + if err := write(); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled actor changed profile or module: %v", err) + } + } + stored, err = st.GetProfile("alice", profile.ID) + if err != nil || stored.Name != "Alice" { + t.Fatalf("disabled profile write changed data: %+v %v", stored, err) + } + storedModule, err = st.GetPromptModule("alice", module.ID) + if err != nil || storedModule.Body != "Alice" { + t.Fatalf("disabled module write changed data: %+v %v", storedModule, err) + } +} + +func TestPGProfileWriteAccountFenceSerializesWithDisable(t *testing.T) { + st := openTestStore(t) + if st.Dialect() != "postgres" { + t.Skip("real PostgreSQL required") + } + other := reopenAccountModelStore(t, st) + if err := st.CreateAccount("admin", "", "", true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("alice", "", "", false); err != nil { + t.Fatal(err) + } + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + release := holdPGAccountWrite(t, st, "profiles", "INSERT", "profile") + writeDone := make(chan error, 1) + go func() { + writeDone <- st.SaveProfileAccountChecked("alice", core.AgentProfile{ID: "race", Name: "first"}, false) + }() + waitForPGAccountWrite(t, st, "profile") + disableDone := make(chan error, 1) + go func() { + _, err := other.SetAccountEnabled("admin", account.ID, account.StateVersion, false) + disableDone <- err + }() + select { + case err := <-disableDone: + t.Fatalf("disable committed ahead of paused profile insert: %v", err) + case <-time.After(100 * time.Millisecond): + } + release() + if err := awaitPGAccountResult(t, writeDone); err != nil { + t.Fatal(err) + } + if err := awaitPGAccountResult(t, disableDone); err != nil { + t.Fatal(err) + } + if err := st.DeleteProfileAccountChecked("alice", "race", false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("profile delete passed after disable: %v", err) + } + stored, err := st.GetProfile("alice", "race") + if err != nil || stored.Name != "first" { + t.Fatalf("profile write crossed disable boundary: %+v %v", stored, err) + } +} diff --git a/internal/store/project_audit_links.go b/internal/store/project_audit_links.go new file mode 100644 index 0000000..6d5c96f --- /dev/null +++ b/internal/store/project_audit_links.go @@ -0,0 +1,175 @@ +package store + +import ( + "database/sql" + "encoding/json" + "errors" +) + +// ProjectAuditLink is an immutable association written only after a trusted +// domain verifier has checked the committed source event. +type ProjectAuditLink struct { + ID int64 + HostAuditOffset int64 + ProjectID string + Owner string + SourceKind string + SourceID string + OperationID string + ResourceKind string + ResourceID string + RunID string + DisclosureClass string + IdempotencyKey string +} + +// CommittedAuditOffset resolves exactly one persisted host receipt. A domain +// outbox can have the operation ID before the bus appends its audit row; no row +// yet is a retryable pending state, while duplicates are ambiguous and refused. +func (s *Store) CommittedAuditOffset(owner, projectID, operationID string) (int64, error) { + if owner == "" || projectID == "" || operationID == "" { + return 0, ErrNotFound + } + rows, err := s.db.Query(s.q(`SELECT id FROM command_audit WHERE owner=? AND project_id=? AND operation_id=? AND state='committed' ORDER BY id LIMIT 2`), owner, projectID, operationID) + if err != nil { + return 0, err + } + defer rows.Close() + var ids []int64 + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return 0, err + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + return 0, err + } + if len(ids) == 0 { + return 0, ErrNotFound + } + if len(ids) > 1 { + return 0, ErrConflict + } + return ids[0], nil +} + +func (s *Store) CommandAuditForLink(owner string, offset int64) (ProjectCommandAuditRow, string, error) { + var row ProjectCommandAuditRow + var projectID, raw string + if owner == "" || offset <= 0 { + return row, "", ErrNotFound + } + err := s.db.QueryRow(s.q(`SELECT id,actor_name,project_id,payload FROM command_audit WHERE id=? AND owner=? AND project_id<>''`), offset, owner). + Scan(&row.Offset, &row.ActorName, &projectID, &raw) + if errors.Is(err, sql.ErrNoRows) { + return row, "", ErrNotFound + } + if err != nil { + return row, "", err + } + if err := json.Unmarshal([]byte(raw), &row.Envelope); err != nil { + return row, "", err + } + return row, projectID, nil +} + +func (s *Store) RunBoundToAudit(owner, projectID, conversationID, runID string) (bool, error) { + if owner == "" || projectID == "" || conversationID == "" || runID == "" { + return false, nil + } + var one int + err := s.db.QueryRow(s.q(`SELECT 1 FROM runs WHERE id=? AND owner=? AND project_id=? AND conversation_id=?`), runID, owner, projectID, conversationID).Scan(&one) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + return err == nil, err +} + +const projectAuditLinkColumns = `id,host_audit_offset,project_id,owner,source_kind,source_id,operation_id,resource_kind,resource_id,run_id,disclosure_class,idempotency_key` +const projectAuditLinkAliasedColumns = `l.id,l.host_audit_offset,l.project_id,l.owner,l.source_kind,l.source_id,l.operation_id,l.resource_kind,l.resource_id,l.run_id,l.disclosure_class,l.idempotency_key` + +func scanProjectAuditLink(scan func(...any) error) (ProjectAuditLink, error) { + var link ProjectAuditLink + err := scan(&link.ID, &link.HostAuditOffset, &link.ProjectID, &link.Owner, &link.SourceKind, &link.SourceID, + &link.OperationID, &link.ResourceKind, &link.ResourceID, &link.RunID, &link.DisclosureClass, &link.IdempotencyKey) + if errors.Is(err, sql.ErrNoRows) { + return link, ErrNotFound + } + return link, err +} + +func (s *Store) ProjectAuditLinkByKey(owner, key string) (ProjectAuditLink, error) { + if owner == "" || key == "" { + return ProjectAuditLink{}, ErrNotFound + } + return scanProjectAuditLink(func(dst ...any) error { + return s.db.QueryRow(s.q(`SELECT `+projectAuditLinkColumns+` FROM project_audit_links WHERE owner=? AND idempotency_key=?`), owner, key).Scan(dst...) + }) +} + +func sameProjectAuditLink(a, b ProjectAuditLink) bool { + return a.HostAuditOffset == b.HostAuditOffset && a.ProjectID == b.ProjectID && a.Owner == b.Owner && + a.SourceKind == b.SourceKind && a.SourceID == b.SourceID && a.OperationID == b.OperationID && + a.ResourceKind == b.ResourceKind && a.ResourceID == b.ResourceID && a.RunID == b.RunID && + a.DisclosureClass == b.DisclosureClass && a.IdempotencyKey == b.IdempotencyKey +} + +// SaveProjectAuditLink is idempotent for an identical key and binding. Both the +// key and the host/source triple are unique; racing or conflicting retries +// resolve by reading the committed winner, never by overwriting it. +func (s *Store) SaveProjectAuditLink(link ProjectAuditLink) (ProjectAuditLink, error) { + var id int64 + err := s.db.QueryRow(s.q(`INSERT INTO project_audit_links(host_audit_offset,project_id,owner,source_kind,source_id,operation_id,resource_kind,resource_id,run_id,disclosure_class,idempotency_key,created_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT DO NOTHING RETURNING id`), link.HostAuditOffset, link.ProjectID, link.Owner, + link.SourceKind, link.SourceID, link.OperationID, link.ResourceKind, link.ResourceID, link.RunID, link.DisclosureClass, + link.IdempotencyKey, nowISO()).Scan(&id) + if err == nil { + link.ID = id + return link, nil + } + if !errors.Is(err, sql.ErrNoRows) { + return ProjectAuditLink{}, err + } + previous, err := scanProjectAuditLink(func(dst ...any) error { + return s.db.QueryRow(s.q(`SELECT `+projectAuditLinkColumns+` FROM project_audit_links + WHERE (owner=? AND idempotency_key=?) OR (host_audit_offset=? AND source_kind=? AND source_id=?) LIMIT 1`), + link.Owner, link.IdempotencyKey, link.HostAuditOffset, link.SourceKind, link.SourceID).Scan(dst...) + }) + if err != nil { + return ProjectAuditLink{}, err + } + if !sameProjectAuditLink(previous, link) { + return ProjectAuditLink{}, ErrConflict + } + return previous, nil +} + +// ProjectAuditLinkedReceipt reads one exact association and the underlying +// host row. It does not enumerate private links or decide field disclosure. +func (s *Store) ProjectAuditLinkedReceipt(projectID, sourceKind, sourceID string, hostOffset int64) (ProjectAuditLink, ProjectCommandAuditRow, error) { + var row ProjectCommandAuditRow + var link ProjectAuditLink + var raw string + if projectID == "" || sourceKind == "" || sourceID == "" || hostOffset <= 0 { + return link, row, ErrNotFound + } + err := s.db.QueryRow(s.q(`SELECT `+projectAuditLinkAliasedColumns+`,a.actor_name,a.payload FROM project_audit_links l + JOIN command_audit a ON a.id=l.host_audit_offset AND a.owner=l.owner AND a.project_id=l.project_id + WHERE l.project_id=? AND l.source_kind=? AND l.source_id=? AND l.host_audit_offset=?`), + projectID, sourceKind, sourceID, hostOffset).Scan(&link.ID, &link.HostAuditOffset, &link.ProjectID, &link.Owner, + &link.SourceKind, &link.SourceID, &link.OperationID, &link.ResourceKind, &link.ResourceID, &link.RunID, + &link.DisclosureClass, &link.IdempotencyKey, &row.ActorName, &raw) + if errors.Is(err, sql.ErrNoRows) { + return link, row, ErrNotFound + } + if err != nil { + return link, row, err + } + row.Offset = link.HostAuditOffset + if err := json.Unmarshal([]byte(raw), &row.Envelope); err != nil { + return link, row, err + } + return link, row, nil +} diff --git a/internal/store/project_audit_links_test.go b/internal/store/project_audit_links_test.go new file mode 100644 index 0000000..64b5b07 --- /dev/null +++ b/internal/store/project_audit_links_test.go @@ -0,0 +1,97 @@ +package store + +import ( + "errors" + "testing" + "time" + + agentcommand "agent-command" +) + +func TestProjectAuditLinkStoreIdempotenceAndExactScope(t *testing.T) { + st := openTestStore(t) // SQLite locally; isolated PostgreSQL schema when configured. + if err := st.EnsureUser("alice", "Alice"); err != nil { + t.Fatal(err) + } + a, err := st.CreateProject("alice", "A") + if err != nil { + t.Fatal(err) + } + b, err := st.CreateProject("alice", "B") + if err != nil { + t.Fatal(err) + } + c, err := st.CreateConversation("alice", string(a.ID), "work", "") + if err != nil { + t.Fatal(err) + } + if err := st.AppendAudit("alice", agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: "rdm.confirm", OperationID: "op-1", ConversationID: string(c.ID), State: agentcommand.StateCommitted}); err != nil { + t.Fatal(err) + } + rows, _, _, err := st.ListProjectCommandAudits("alice", string(a.ID), 0, 0, 10) + if err != nil { + t.Fatal(err) + } + resolved, err := st.CommittedAuditOffset("alice", string(a.ID), "op-1") + if err != nil || resolved != rows[0].Offset { + t.Fatalf("trusted operation lookup: %d %v", resolved, err) + } + if _, err := st.CommittedAuditOffset("alice", string(a.ID), "op-pending"); !errors.Is(err, ErrNotFound) { + t.Fatalf("unrecorded operation should wait: %v", err) + } + link := ProjectAuditLink{HostAuditOffset: rows[0].Offset, ProjectID: string(a.ID), Owner: "alice", SourceKind: "domain-commit", SourceID: "commit-1", OperationID: "op-1", ResourceKind: "document", ResourceID: "doc-1", DisclosureClass: "metadata", IdempotencyKey: "outbox-1"} + first, err := st.SaveProjectAuditLink(link) + if err != nil { + t.Fatal(err) + } + retry, err := st.SaveProjectAuditLink(link) + if err != nil || retry.ID != first.ID { + t.Fatalf("retry: first=%+v retry=%+v err=%v", first, retry, err) + } + if found, err := st.ProjectAuditLinkByKey("alice", "outbox-1"); err != nil || found.ID != first.ID { + t.Fatalf("key lookup: %+v %v", found, err) + } + if _, _, err := st.ProjectAuditLinkedReceipt(string(b.ID), "domain-commit", "commit-1", rows[0].Offset); !errors.Is(err, ErrNotFound) { + t.Fatalf("wrong project: %v", err) + } + if _, _, err := st.ProjectAuditLinkedReceipt(string(a.ID), "domain-commit", "other", rows[0].Offset); !errors.Is(err, ErrNotFound) { + t.Fatalf("wrong source: %v", err) + } + got, audit, err := st.ProjectAuditLinkedReceipt(string(a.ID), "domain-commit", "commit-1", rows[0].Offset) + if err != nil || got.ID != first.ID || audit.Envelope.CommandID != "rdm.confirm" { + t.Fatalf("linked read: %+v %+v %v", got, audit, err) + } + link.ResourceID = "doc-2" + if _, err := st.SaveProjectAuditLink(link); !errors.Is(err, ErrConflict) { + t.Fatalf("same key different binding: %v", err) + } + link.ResourceID = "doc-1" + link.IdempotencyKey = "outbox-2" + if _, err := st.SaveProjectAuditLink(link); !errors.Is(err, ErrConflict) { + t.Fatalf("same source different key: %v", err) + } + if err := st.EnsureUser("bob", "Bob"); err != nil { + t.Fatal(err) + } + bobConversation, err := st.CreateConversationInProject("bob", string(a.ID), "shared work", "") + if err != nil { + t.Fatal(err) + } + if err := st.AppendAudit("bob", agentcommand.AuditEnvelope{Time: time.Now(), Actor: "bob", CommandID: "rdm.confirm", OperationID: "op-bob", ConversationID: string(bobConversation.ID), State: agentcommand.StateCommitted}); err != nil { + t.Fatal(err) + } + bobRows, _, _, err := st.ListProjectCommandAudits("bob", string(a.ID), 0, 0, 10) + if err != nil { + t.Fatal(err) + } + bobLink := ProjectAuditLink{HostAuditOffset: bobRows[0].Offset, ProjectID: string(a.ID), Owner: "bob", SourceKind: "domain-commit", SourceID: "commit-bob", OperationID: "op-bob", ResourceKind: "document", ResourceID: "doc-bob", DisclosureClass: "metadata", IdempotencyKey: "outbox-1"} + if _, err := st.SaveProjectAuditLink(bobLink); err != nil { + t.Fatalf("another owner may use the same outbox key: %v", err) + } + if err := st.AppendAudit("alice", agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: "rdm.confirm", OperationID: "op-1", ConversationID: string(c.ID), State: agentcommand.StateCommitted}); err != nil { + t.Fatal(err) + } + if _, err := st.CommittedAuditOffset("alice", string(a.ID), "op-1"); !errors.Is(err, ErrConflict) { + t.Fatalf("ambiguous host operation must not choose one: %v", err) + } +} diff --git a/internal/store/project_audit_sequence_test.go b/internal/store/project_audit_sequence_test.go new file mode 100644 index 0000000..780f19b --- /dev/null +++ b/internal/store/project_audit_sequence_test.go @@ -0,0 +1,610 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "os" + "sort" + "strings" + "sync" + "testing" + "time" + + agentcommand "agent-command" +) + +func openProjectAuditPG(t *testing.T) *Store { + t.Helper() + if os.Getenv("AGENT_PG_TEST_DSN") == "" { + t.Skip("AGENT_PG_TEST_DSN unset: isolated PostgreSQL check NOT_RUN") + } + st := openTestStore(t) + if st.Dialect() != "postgres" { + t.Fatal("expected PostgreSQL") + } + return st +} + +func TestProjectAuditAllWritersRejectUnsafeOnlineChanges(t *testing.T) { + st := openProjectAuditPG(t) + if err := st.AppendAudit("alice", agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: "first", State: agentcommand.StateCommitted}); err != nil { + t.Fatal(err) + } + row := `INSERT INTO command_audit(id,time,command_id,source,entrance,state,operation_id,conversation_id,owner,payload) + VALUES ($1,'2026-10-14T00:00:00Z','explicit','','','committed','','','alice','{}')` + if _, err := st.db.Exec(row, 100); err == nil { + t.Fatal("online explicit ID above reserved sequence position was accepted") + } + if _, err := st.db.Exec(`UPDATE command_audit SET command_id='changed' WHERE id=1`); err == nil { + t.Fatal("online audit UPDATE was accepted") + } + if _, err := st.db.Exec(`DELETE FROM command_audit WHERE id=1`); err == nil { + t.Fatal("online audit DELETE was accepted") + } + for _, level := range []sql.IsolationLevel{sql.LevelRepeatableRead, sql.LevelSerializable} { + tx, err := st.db.BeginTx(context.Background(), &sql.TxOptions{Isolation: level}) + if err != nil { + t.Fatal(err) + } + _, err = tx.Exec(`INSERT INTO command_audit(time,command_id,source,entrance,state,operation_id,conversation_id,owner,payload) + VALUES ('2026-10-14T00:00:00Z','stale-snapshot','','','committed','','','alice','{}')`) + tx.Rollback() + if err == nil || !strings.Contains(err.Error(), "read committed") { + t.Fatalf("isolation %v did not fail closed: %v", level, err) + } + } + seq := commandAuditSequenceName(t, st) + if _, err := st.db.Exec(`SELECT nextval($1::regclass)`, seq); err != nil { + t.Fatal(err) + } + if _, err := st.db.Exec(`INSERT INTO command_audit(id,time,command_id,source,entrance,state,operation_id,conversation_id,owner,payload) + VALUES (4,'2026-10-14T00:00:00Z','first-of-batch','','','committed','','','alice','{}'), + (1,'2026-10-14T00:00:00Z','decreasing','','','committed','','','alice','{}')`); err == nil { + t.Fatal("unordered multirow audit INSERT was accepted") + } + if _, err := st.db.Exec(row+` ON CONFLICT DO NOTHING`, 1); err == nil { + t.Fatal("online replay bypassed the INSERT guard through ON CONFLICT") + } + var count int + if err := st.db.QueryRow(`SELECT COUNT(*) FROM command_audit`).Scan(&count); err != nil || count != 1 { + t.Fatalf("unsafe writes persisted: count=%d err=%v", count, err) + } +} + +func TestProjectAuditAllWritersGuardDriftFailsClosed(t *testing.T) { + st := openProjectAuditPG(t) + for _, trigger := range []string{"project_audit_insert_guard_v1", "project_audit_mutation_guard_v1"} { + if _, err := st.db.Exec(`ALTER TABLE command_audit DISABLE TRIGGER ` + trigger); err != nil { + t.Fatal(err) + } + if _, _, _, err := st.ListProjectCommandAudits("alice", "project", 0, 0, 10); err == nil { + t.Fatalf("read accepted disabled trigger %s", trigger) + } + if err := st.AppendAudit("alice", agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: "drift"}); err == nil { + t.Fatalf("append accepted disabled trigger %s", trigger) + } + if _, err := st.db.Exec(`ALTER TABLE command_audit ENABLE ALWAYS TRIGGER ` + trigger); err != nil { + t.Fatal(err) + } + } + if _, err := st.db.Exec(`CREATE OR REPLACE TRIGGER project_audit_insert_guard_v1 BEFORE INSERT ON command_audit FOR EACH ROW EXECUTE FUNCTION project_audit_mutation_guard_v1()`); err != nil { + t.Fatal(err) + } + if _, err := st.db.Exec(`ALTER TABLE command_audit ENABLE ALWAYS TRIGGER project_audit_insert_guard_v1`); err != nil { + t.Fatal(err) + } + if _, _, _, err := st.ListProjectCommandAudits("alice", "project", 0, 0, 10); err == nil { + t.Fatal("read accepted rebound insert trigger") + } + if _, err := st.db.Exec(`CREATE OR REPLACE TRIGGER project_audit_insert_guard_v1 BEFORE INSERT ON command_audit FOR EACH ROW EXECUTE FUNCTION project_audit_insert_guard_v1()`); err != nil { + t.Fatal(err) + } + if _, err := st.db.Exec(`ALTER TABLE command_audit ENABLE ALWAYS TRIGGER project_audit_insert_guard_v1`); err != nil { + t.Fatal(err) + } + if err := st.AppendAudit("alice", agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: "restored"}); err != nil { + t.Fatalf("restored guard: %v", err) + } +} + +func commandAuditSequenceName(t *testing.T, st *Store) string { + t.Helper() + var name string + if err := st.db.QueryRow(`SELECT pg_get_serial_sequence('command_audit','id')`).Scan(&name); err != nil { + t.Fatal(err) + } + if name == "" { + t.Fatal("command_audit has no owned sequence") + } + return name +} + +func TestProjectAuditSequenceMigrationRejectsCacheTen(t *testing.T) { + st := openProjectAuditPG(t) + if err := st.MigrateDown(43); err != nil { + t.Fatal(err) + } + seq := commandAuditSequenceName(t, st) + if _, err := st.db.Exec("ALTER SEQUENCE " + seq + " CACHE 10"); err != nil { + t.Fatal(err) + } + if err := st.migrate(); err == nil { + t.Fatal("migration accepted CACHE 10, which permits out-of-order IDs") + } + if _, err := st.db.Exec("ALTER SEQUENCE " + seq + " CACHE 1"); err != nil { + t.Fatal(err) + } + if err := st.migrate(); err != nil { + t.Fatalf("safe CACHE 1 migration: %v", err) + } +} + +func TestProjectAuditSequenceRuntimeRejectsConfigurationDrift(t *testing.T) { + st := openProjectAuditPG(t) + seq := commandAuditSequenceName(t, st) + if _, err := st.db.Exec("ALTER SEQUENCE " + seq + " CACHE 10"); err != nil { + t.Fatal(err) + } + if _, _, _, err := st.ListProjectCommandAudits("alice", "project", 0, 0, 10); err == nil { + t.Fatal("project audit reported a cutoff with CACHE 10") + } + if err := st.AppendAudit("alice", agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: "unsafe-cache"}); err == nil { + t.Fatal("append allocated an ID with CACHE 10") + } + if _, err := st.db.Exec("ALTER SEQUENCE " + seq + " CACHE 1"); err != nil { + t.Fatal(err) + } + if err := st.AppendAudit("alice", agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: "safe-cache"}); err != nil { + t.Fatalf("safe append after reset: %v", err) + } + if _, err := st.db.Exec("ALTER SEQUENCE " + seq + " CYCLE"); err != nil { + t.Fatal(err) + } + if _, _, _, err := st.ListProjectCommandAudits("alice", "project", 0, 0, 10); err == nil { + t.Fatal("read accepted cyclic audit IDs") + } + if err := st.AppendAudit("alice", agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: "cyclic"}); err == nil { + t.Fatal("append accepted cyclic audit IDs") + } + if _, err := st.db.Exec("ALTER SEQUENCE " + seq + " NO CYCLE"); err != nil { + t.Fatal(err) + } + if _, err := st.db.Exec("ALTER SEQUENCE " + seq + " MINVALUE -10"); err != nil { + t.Fatal(err) + } + if _, _, _, err := st.ListProjectCommandAudits("alice", "project", 0, 0, 10); err == nil { + t.Fatal("read accepted a nonpositive audit ID range") + } + if _, err := st.db.Exec("ALTER SEQUENCE " + seq + " MINVALUE 1"); err != nil { + t.Fatal(err) + } + if _, err := st.db.Exec(`ALTER TABLE command_audit ALTER COLUMN id SET DEFAULT nextval(pg_get_serial_sequence('command_audit','id')::regclass)`); err != nil { + t.Fatal(err) + } + if _, _, _, err := st.ListProjectCommandAudits("alice", "project", 0, 0, 10); err == nil { + t.Fatal("read accepted an unguarded sequence default") + } + if err := st.AppendAudit("alice", agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: "unguarded-default"}); err == nil { + t.Fatal("append accepted an unguarded sequence default") + } + if _, err := st.db.Exec(`ALTER TABLE command_audit ALTER COLUMN id SET DEFAULT CASE WHEN false THEN next_command_audit_id('command_audit'::regclass) ELSE nextval(pg_get_serial_sequence('command_audit','id')::regclass) END`); err != nil { + t.Fatal(err) + } + if _, _, _, err := st.ListProjectCommandAudits("alice", "project", 0, 0, 10); err == nil { + t.Fatal("read accepted a replaced default that merely mentions the guarded function") + } + if _, err := st.db.Exec(`ALTER TABLE command_audit ALTER COLUMN id SET DEFAULT next_command_audit_id('command_audit'::regclass)`); err != nil { + t.Fatal(err) + } +} + +func TestProjectAuditSequenceRejectsRewindIntoUnusedGap(t *testing.T) { + st := openProjectAuditPG(t) + p, err := st.CreateProject("alice", "A") + if err != nil { + t.Fatal(err) + } + c, err := st.CreateConversation("alice", string(p.ID), "work", "") + if err != nil { + t.Fatal(err) + } + appendAudit := func(id string) error { + return st.AppendAudit("alice", agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: id, ConversationID: string(c.ID), State: agentcommand.StateCommitted}) + } + if err := appendAudit("first"); err != nil { + t.Fatal(err) + } + seq := commandAuditSequenceName(t, st) + if _, err := st.db.Exec("SELECT nextval('" + seq + "'::regclass)"); err != nil { + t.Fatal(err) + } // consume one ID without a row + if err := appendAudit("third"); err != nil { + t.Fatal(err) + } + if _, err := st.db.Exec("SELECT setval('" + seq + "'::regclass,1)"); err != nil { + t.Fatal(err) + } + if err := appendAudit("rewound-into-gap"); err == nil { + t.Fatal("rewound sequence inserted a low ID into a committed-prefix gap") + } + if _, _, _, err := st.ListProjectCommandAudits("alice", string(p.ID), 0, 0, 10); err == nil { + t.Fatal("read claimed a safe cutoff after sequence rewind") + } + if _, err := st.db.Exec("SELECT setval('" + seq + "'::regclass,3)"); err != nil { + t.Fatal(err) + } + if err := appendAudit("recovered"); err != nil { + t.Fatalf("append after restoring sequence: %v", err) + } +} + +// Advisory locking alone does not fix cached sequence allocation: session A +// can hold 2..10 while session B commits 11, then A later commits 2. +func TestProjectAuditSequenceCacheTenDefeatsAdvisoryLock(t *testing.T) { + st := openProjectAuditPG(t) + for _, query := range []string{ + `CREATE TABLE cache_probe(id BIGSERIAL PRIMARY KEY)`, + `ALTER SEQUENCE cache_probe_id_seq CACHE 10`, + `CREATE FUNCTION cache_probe_next_id(target regclass) RETURNS bigint LANGUAGE plpgsql AS $$ + BEGIN PERFORM pg_advisory_xact_lock(715328491,target::oid::int4); + RETURN nextval(pg_get_serial_sequence(target::text,'id')::regclass); END $$`, + `ALTER TABLE cache_probe ALTER COLUMN id SET DEFAULT cache_probe_next_id('cache_probe'::regclass)`, + } { + if _, err := st.db.Exec(query); err != nil { + t.Fatal(err) + } + } + ctx := context.Background() + a, err := st.db.Conn(ctx) + if err != nil { + t.Fatal(err) + } + defer a.Close() + b, err := st.db.Conn(ctx) + if err != nil { + t.Fatal(err) + } + defer b.Close() + var first, other, late int64 + if err := a.QueryRowContext(ctx, `INSERT INTO cache_probe DEFAULT VALUES RETURNING id`).Scan(&first); err != nil { + t.Fatal(err) + } + if err := b.QueryRowContext(ctx, `INSERT INTO cache_probe DEFAULT VALUES RETURNING id`).Scan(&other); err != nil { + t.Fatal(err) + } + if err := a.QueryRowContext(ctx, `INSERT INTO cache_probe DEFAULT VALUES RETURNING id`).Scan(&late); err != nil { + t.Fatal(err) + } + if first != 1 || other != 11 || late != 2 { + t.Fatalf("CACHE 10 counterexample changed: A=%d B=%d A-late=%d", first, other, late) + } +} + +func TestProjectAuditSequenceNeverCommitsPreUpgradeCachedLowID(t *testing.T) { + st := openProjectAuditPG(t) + if err := st.MigrateDown(43); err != nil { + t.Fatal(err) + } + seq := commandAuditSequenceName(t, st) + if _, err := st.db.Exec("ALTER SEQUENCE " + seq + " CACHE 10"); err != nil { + t.Fatal(err) + } + ctx := context.Background() + a, err := st.db.Conn(ctx) + if err != nil { + t.Fatal(err) + } + defer a.Close() + b, err := st.db.Conn(ctx) + if err != nil { + t.Fatal(err) + } + defer b.Close() + insert := `INSERT INTO command_audit(time,command_id,source,entrance,state,operation_id,conversation_id,owner,payload) + VALUES ('2026-10-14T00:00:00Z','legacy','','','committed','','','alice','{}') RETURNING id` + var first, high, late int64 + if err := a.QueryRowContext(ctx, insert).Scan(&first); err != nil { + t.Fatal(err) + } + if err := b.QueryRowContext(ctx, insert).Scan(&high); err != nil { + t.Fatal(err) + } + if first != 1 || high != 11 { + t.Fatalf("old cached allocation: first=%d high=%d", first, high) + } + if _, err := st.db.Exec("ALTER SEQUENCE " + seq + " CACHE 1"); err != nil { + t.Fatal(err) + } + if err := st.migrate(); err != nil { + t.Fatalf("upgrade with idle cached connections: %v", err) + } + if err := a.QueryRowContext(ctx, insert).Scan(&late); err == nil && late <= high { + t.Fatalf("old connection committed late low ID %d below %d", late, high) + } + var count int + if err := st.db.QueryRow(`SELECT COUNT(*) FROM command_audit WHERE id=2`).Scan(&count); err != nil || count != 0 { + t.Fatalf("late cached row persisted: count=%d err=%v", count, err) + } +} + +func TestProjectAuditSequenceMigrationRoundTripPreservesLinks(t *testing.T) { + st := openProjectAuditPG(t) + if err := st.EnsureUser("alice", "Alice"); err != nil { + t.Fatal(err) + } + p, err := st.CreateProject("alice", "A") + if err != nil { + t.Fatal(err) + } + c, err := st.CreateConversation("alice", string(p.ID), "work", "") + if err != nil { + t.Fatal(err) + } + if err := st.AppendAudit("alice", agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: "rdm.confirm", OperationID: "op-1", ConversationID: string(c.ID), State: agentcommand.StateCommitted}); err != nil { + t.Fatal(err) + } + rows, _, _, err := st.ListProjectCommandAudits("alice", string(p.ID), 0, 0, 10) + if err != nil { + t.Fatal(err) + } + offset := rows[0].Offset + link := ProjectAuditLink{HostAuditOffset: offset, ProjectID: string(p.ID), Owner: "alice", SourceKind: "domain-commit", SourceID: "commit-1", OperationID: "op-1", ResourceKind: "document", ResourceID: "doc-1", DisclosureClass: "metadata", IdempotencyKey: "outbox-1"} + if _, err := st.SaveProjectAuditLink(link); err != nil { + t.Fatal(err) + } + if err := st.MigrateDown(43); err != nil { + t.Fatalf("down to v43: %v", err) + } + if _, _, _, err := st.ListProjectCommandAudits("alice", string(p.ID), 0, 0, 10); err == nil { + t.Fatal("old default exposed a strong cutoff after down") + } + if _, row, err := st.ProjectAuditLinkedReceipt(string(p.ID), "domain-commit", "commit-1", offset); err != nil || row.Offset != offset { + t.Fatalf("link changed during down: %+v %v", row, err) + } + if err := st.migrate(); err != nil { + t.Fatalf("up to v44: %v", err) + } + rows, _, _, err = st.ListProjectCommandAudits("alice", string(p.ID), 0, 0, 10) + if err != nil || len(rows) != 1 || rows[0].Offset != offset { + t.Fatalf("restored audit: %+v %v", rows, err) + } + if err := st.AppendAudit("alice", agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: "after-up", ConversationID: string(c.ID), State: agentcommand.StateCommitted}); err != nil { + t.Fatal(err) + } + rows, _, _, err = st.ListProjectCommandAudits("alice", string(p.ID), 0, 0, 10) + if err != nil || len(rows) != 2 || rows[0].Offset <= offset { + t.Fatalf("sequence not advanced after up: %+v %v", rows, err) + } +} + +func TestProjectAuditSequenceLockTimeoutAndRollback(t *testing.T) { + st := openProjectAuditPG(t) + p, err := st.CreateProject("alice", "A") + if err != nil { + t.Fatal(err) + } + c, err := st.CreateConversation("alice", string(p.ID), "work", "") + if err != nil { + t.Fatal(err) + } + tx, err := st.db.Begin() + if err != nil { + t.Fatal(err) + } + defer tx.Rollback() + insert := st.q(`INSERT INTO command_audit(time,command_id,source,entrance,state,operation_id,conversation_id,owner,payload,actor_name,project_id) + VALUES (?,?,'','','committed','',?,?,?,?,?) RETURNING id`) + args := []any{time.Now().UTC().Format(time.RFC3339Nano), "held", string(c.ID), "alice", `{}`, "Alice", string(p.ID)} + var heldID int64 + if err := tx.QueryRow(insert, args...).Scan(&heldID); err != nil { + t.Fatal(err) + } + ctx := context.Background() + conn, err := st.db.Conn(ctx) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + if _, err := conn.ExecContext(ctx, `SET lock_timeout='150ms'`); err != nil { + t.Fatal(err) + } + var blockedID int64 + err = conn.QueryRowContext(ctx, insert, args...).Scan(&blockedID) + if err == nil || !strings.Contains(err.Error(), "lock timeout") { + t.Fatalf("second writer did not stop on advisory lock: id=%d err=%v", blockedID, err) + } + if _, err := conn.ExecContext(ctx, `RESET lock_timeout`); err != nil { + t.Fatal(err) + } + cancelCtx, cancel := context.WithTimeout(ctx, 150*time.Millisecond) + defer cancel() + var canceledID int64 + if err := conn.QueryRowContext(cancelCtx, insert, args...).Scan(&canceledID); err == nil { + t.Fatalf("canceled writer passed advisory lock: id=%d", canceledID) + } + if err := tx.Rollback(); err != nil { + t.Fatal(err) + } + if err := st.AppendAudit("alice", agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: "after-rollback", ConversationID: string(c.ID), State: agentcommand.StateCommitted}); err != nil { + t.Fatal(err) + } + rows, cutoff, _, err := st.ListProjectCommandAudits("alice", string(p.ID), 0, 0, 10) + if err != nil || len(rows) != 1 || rows[0].Offset <= heldID || cutoff != rows[0].Offset { + t.Fatalf("rollback gap affected cutoff: rows=%+v cutoff=%d held=%d err=%v", rows, cutoff, heldID, err) + } +} + +func TestProjectAuditSequenceConcurrentLoad(t *testing.T) { + st := openProjectAuditPG(t) + var serverVersion string + if err := st.db.QueryRow(`SHOW server_version`).Scan(&serverVersion); err != nil { + t.Fatal(err) + } + if err := st.EnsureUser("alice", "Alice"); err != nil { + t.Fatal(err) + } + p, err := st.CreateProject("alice", "A") + if err != nil { + t.Fatal(err) + } + c, err := st.CreateConversation("alice", string(p.ID), "work", "") + if err != nil { + t.Fatal(err) + } + const writers, each = 8, 24 + run := func(label string, write func(agentcommand.AuditEnvelope) error) (time.Duration, time.Duration) { + t.Helper() + durations := make([]time.Duration, writers*each) + errors := make(chan error, writers*each) + start := time.Now() + var wg sync.WaitGroup + for worker := 0; worker < writers; worker++ { + wg.Add(1) + go func(worker int) { + defer wg.Done() + for i := 0; i < each; i++ { + env := agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: fmt.Sprintf("%s-%d-%d", label, worker, i), ConversationID: string(c.ID), State: agentcommand.StateCommitted} + at := time.Now() + if err := write(env); err != nil { + errors <- err + return + } + durations[worker*each+i] = time.Since(at) + } + }(worker) + } + wg.Wait() + close(errors) + for err := range errors { + t.Fatalf("%s append: %v", label, err) + } + elapsed := time.Since(start) + sort.Slice(durations, func(i, j int) bool { return durations[i] < durations[j] }) + return elapsed, durations[len(durations)*95/100] + } + if err := st.MigrateDown(43); err != nil { + t.Fatal(err) + } + oldQuery := st.q(`INSERT INTO command_audit(time,command_id,source,entrance,state,operation_id,conversation_id,owner,payload,actor_name,project_id) + VALUES (?,?,?,?,?,?,?,?,?,COALESCE((SELECT name FROM users WHERE owner=?),''),COALESCE((SELECT project_id FROM conversations WHERE id=? AND owner=?),''))`) + oldElapsed, oldP95 := run("old", func(env agentcommand.AuditEnvelope) error { + raw, err := json.Marshal(env) + if err != nil { + return err + } + _, err = st.db.Exec(oldQuery, env.Time.UTC().Format(time.RFC3339Nano), env.CommandID, "", "", string(env.State), "", env.ConversationID, "alice", string(raw), env.Actor, env.ConversationID, "alice") + return err + }) + if err := st.migrate(); err != nil { + t.Fatalf("upgrade after baseline: %v", err) + } + statusMode := "MAX-first unlocked status" + if os.Getenv("AGENT_AUDIT_BENCH_READ_LOCK") == "1" { + // A test-only wrapper reconstructs the earlier status-read lock so the + // isolated load receipt can compare its cost against the final design. + if _, err := st.db.Exec(`ALTER FUNCTION project_audit_cutoff_safe(regclass) RENAME TO project_audit_cutoff_unlocked`); err != nil { + t.Fatal(err) + } + if _, err := st.db.Exec(`CREATE FUNCTION project_audit_cutoff_safe(target regclass) RETURNS boolean LANGUAGE plpgsql VOLATILE AS $$ + BEGIN PERFORM pg_advisory_xact_lock(715328491,target::oid::int4); + RETURN project_audit_cutoff_unlocked(target); END $$`); err != nil { + t.Fatal(err) + } + statusMode = "test-only locked status wrapper" + } + newElapsed, newP95 := run("guarded", func(env agentcommand.AuditEnvelope) error { return st.AppendAudit("alice", env) }) + t.Logf("isolated PostgreSQL %s audit append: %d writes per mode / %d workers; v43 direct INSERT=%s p95=%s (%.0f/s), v45 Store.AppendAudit with %s=%s p95=%s (%.0f/s); hardware/load dependent SELF only", + serverVersion, writers*each, writers, oldElapsed, oldP95, float64(writers*each)/oldElapsed.Seconds(), statusMode, newElapsed, newP95, float64(writers*each)/newElapsed.Seconds()) + seen := map[string]bool{} + var before, cutoff int64 + for { + page, fixed, next, err := st.ListProjectCommandAudits("alice", string(p.ID), before, cutoff, 100) + if err != nil { + t.Fatal(err) + } + if cutoff == 0 { + cutoff = fixed + } + for _, row := range page { + if seen[row.Envelope.CommandID] { + t.Fatalf("duplicate paged command: %s", row.Envelope.CommandID) + } + seen[row.Envelope.CommandID] = true + } + if next == 0 { + break + } + before = next + } + if len(seen) != 2*writers*each { + t.Fatalf("paged commands=%d want=%d", len(seen), 2*writers*each) + } +} + +func TestProjectAuditConcurrentReadWriteNeverReportsFalseDrift(t *testing.T) { + st := openProjectAuditPG(t) + p, err := st.CreateProject("alice", "A") + if err != nil { + t.Fatal(err) + } + c, err := st.CreateConversation("alice", string(p.ID), "work", "") + if err != nil { + t.Fatal(err) + } + for i := 0; i < 210; i++ { + if err := st.AppendAudit("alice", agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: fmt.Sprintf("seed-%d", i), ConversationID: string(c.ID), State: agentcommand.StateCommitted}); err != nil { + t.Fatal(err) + } + } + const writers, writes, readers, reads = 6, 30, 4, 40 + errs := make(chan error, writers*writes+readers*reads) + readDurations := make(chan time.Duration, readers*reads) + var wg sync.WaitGroup + for worker := 0; worker < writers; worker++ { + wg.Add(1) + go func(worker int) { + defer wg.Done() + for i := 0; i < writes; i++ { + err := st.AppendAudit("alice", agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: fmt.Sprintf("write-%d-%d", worker, i), ConversationID: string(c.ID), State: agentcommand.StateCommitted}) + if err != nil { + errs <- fmt.Errorf("append: %w", err) + return + } + } + }(worker) + } + for reader := 0; reader < readers; reader++ { + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < reads; i++ { + started := time.Now() + page, _, _, err := st.ListProjectCommandAudits("alice", string(p.ID), 0, 0, 200) + if err != nil { + errs <- fmt.Errorf("read: %w", err) + return + } + if len(page) != 200 { + errs <- fmt.Errorf("large page rows=%d want=200", len(page)) + return + } + readDurations <- time.Since(started) + } + }() + } + wg.Wait() + close(errs) + for err := range errs { + t.Fatal(err) + } + close(readDurations) + var samples []time.Duration + for duration := range readDurations { + samples = append(samples, duration) + } + sort.Slice(samples, func(i, j int) bool { return samples[i] < samples[j] }) + t.Logf("isolated PG mixed load: %d append writes / %d writers and %d 200-row pages / %d readers; page p95=%s, SELF cost signal only", + writers*writes, writers, readers*reads, readers, samples[len(samples)*95/100]) +} diff --git a/internal/store/project_audit_test.go b/internal/store/project_audit_test.go new file mode 100644 index 0000000..7234dd7 --- /dev/null +++ b/internal/store/project_audit_test.go @@ -0,0 +1,267 @@ +package store + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + agentcommand "agent-command" +) + +func TestProjectCommandAuditsUseStoredConversationProjectAndFixedCutoff(t *testing.T) { + st, err := OpenSQLite(filepath.Join(t.TempDir(), "audit.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if err := st.EnsureUser("alice", "Alice at action time"); err != nil { + t.Fatal(err) + } + projectA, err := st.CreateProject("alice", "A") + if err != nil { + t.Fatal(err) + } + projectB, err := st.CreateProject("alice", "B") + if err != nil { + t.Fatal(err) + } + convA, err := st.CreateConversation("alice", string(projectA.ID), "A work", "") + if err != nil { + t.Fatal(err) + } + convB, err := st.CreateConversation("alice", string(projectB.ID), "B work", "") + if err != nil { + t.Fatal(err) + } + appendCommand := func(owner, conversation, command string) { + t.Helper() + if err := st.AppendAudit(owner, agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: command, ConversationID: conversation, State: agentcommand.StateCommitted, ParamsDigest: "private-digest"}); err != nil { + t.Fatal(err) + } + } + appendCommand("alice", string(convA.ID), "a.one") + appendCommand("alice", string(convB.ID), "b.one") + appendCommand("alice", "client-supplied-only", "unbound") + appendCommand("bob", string(convA.ID), "wrong-owner") + appendCommand("alice", string(convA.ID), "a.two") + var expectedCutoff int64 + if err := st.db.QueryRow(st.q(`SELECT id FROM command_audit WHERE command_id=?`), "a.two").Scan(&expectedCutoff); err != nil { + t.Fatal(err) + } + appendCommand("alice", string(convB.ID), "b.latest") + + first, cutoff, next, err := st.ListProjectCommandAudits("alice", string(projectA.ID), 0, 0, 1) + if err != nil { + t.Fatal(err) + } + if len(first) != 1 || first[0].Envelope.CommandID != "a.two" || first[0].ActorName != "Alice at action time" || next == 0 || cutoff != expectedCutoff { + t.Fatalf("first page: rows=%+v cutoff=%d next=%d", first, cutoff, next) + } + appendCommand("alice", string(convA.ID), "a.late") + second, fixed, next, err := st.ListProjectCommandAudits("alice", string(projectA.ID), next, cutoff, 1) + if err != nil { + t.Fatal(err) + } + if fixed != cutoff || len(second) != 1 || second[0].Envelope.CommandID != "a.one" || next != 0 { + t.Fatalf("second page: rows=%+v cutoff=%d next=%d", second, fixed, next) + } + other, _, _, err := st.ListProjectCommandAudits("alice", string(projectB.ID), 0, 0, 10) + if err != nil { + t.Fatal(err) + } + if len(other) != 2 || other[0].Envelope.CommandID != "b.latest" || other[1].Envelope.CommandID != "b.one" { + t.Fatalf("B rows: %+v", other) + } + unknown, _, _, err := st.ListProjectCommandAudits("alice", "untrusted-project", 0, 0, 10) + if err != nil { + t.Fatal(err) + } + if len(unknown) != 0 { + t.Fatalf("untrusted project rows: %+v", unknown) + } +} + +func TestProjectCommandAuditKeepsActorNameSnapshot(t *testing.T) { + st, err := OpenSQLite(filepath.Join(t.TempDir(), "audit.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if err := st.EnsureUser("alice", "Former name"); err != nil { + t.Fatal(err) + } + p, err := st.CreateProject("alice", "A") + if err != nil { + t.Fatal(err) + } + c, err := st.CreateConversation("alice", string(p.ID), "A work", "") + if err != nil { + t.Fatal(err) + } + if err := st.AppendAudit("alice", agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: "old", ConversationID: string(c.ID)}); err != nil { + t.Fatal(err) + } + if _, err := st.db.Exec(st.q(`UPDATE users SET name=? WHERE owner=?`), "Current name", "alice"); err != nil { + t.Fatal(err) + } + if err := st.AppendAudit("alice", agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: "new", ConversationID: string(c.ID)}); err != nil { + t.Fatal(err) + } + legacy, err := json.Marshal(agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: "legacy", ConversationID: string(c.ID)}) + if err != nil { + t.Fatal(err) + } + if _, err := st.db.Exec(st.q(`INSERT INTO command_audit (time, command_id, source, entrance, state, operation_id, conversation_id, owner, payload) VALUES (?, ?, '', '', '', '', ?, ?, ?)`), time.Now().UTC().Format(time.RFC3339Nano), "legacy", string(c.ID), "alice", string(legacy)); err != nil { + t.Fatal(err) + } + rows, _, _, err := st.ListProjectCommandAudits("alice", string(p.ID), 0, 0, 10) + if err != nil { + t.Fatal(err) + } + if len(rows) != 2 || rows[0].ActorName != "Current name" || rows[1].ActorName != "Former name" { + t.Fatalf("names: %+v", rows) + } + var legacyProject string + if err := st.db.QueryRow(st.q(`SELECT project_id FROM command_audit WHERE command_id=?`), "legacy").Scan(&legacyProject); err != nil { + t.Fatal(err) + } + if legacyProject != "" { + t.Fatalf("legacy attribution was fabricated: %q", legacyProject) + } +} + +func TestProjectCommandAuditDoesNotMoveWithConversation(t *testing.T) { + st, err := OpenSQLite(filepath.Join(t.TempDir(), "audit.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + a, err := st.CreateProject("alice", "A") + if err != nil { + t.Fatal(err) + } + b, err := st.CreateProject("alice", "B") + if err != nil { + t.Fatal(err) + } + c, err := st.CreateConversation("alice", string(a.ID), "work", "") + if err != nil { + t.Fatal(err) + } + appendCommand := func(id string) { + t.Helper() + if err := st.AppendAudit("alice", agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: id, ConversationID: string(c.ID)}); err != nil { + t.Fatal(err) + } + } + appendCommand("before-move") + if _, err := st.db.Exec(st.q(`UPDATE conversations SET project_id=? WHERE id=?`), string(b.ID), string(c.ID)); err != nil { + t.Fatal(err) + } + appendCommand("after-move") + oldProject, _, _, err := st.ListProjectCommandAudits("alice", string(a.ID), 0, 0, 10) + if err != nil { + t.Fatal(err) + } + newProject, _, _, err := st.ListProjectCommandAudits("alice", string(b.ID), 0, 0, 10) + if err != nil { + t.Fatal(err) + } + if len(oldProject) != 1 || oldProject[0].Envelope.CommandID != "before-move" || len(newProject) != 1 || newProject[0].Envelope.CommandID != "after-move" { + t.Fatalf("project attribution changed: A=%+v B=%+v", oldProject, newProject) + } +} + +// Under the old PostgreSQL SERIAL default, an uncommitted middle ID can be +// skipped after a higher committed row advances the page cursor past it. +// The committed-prefix implementation must keep the later writer waiting. +func TestProjectAuditPostgresCursorNeverSkipsLateCommit(t *testing.T) { + if os.Getenv("AGENT_PG_TEST_DSN") == "" { + t.Skip("AGENT_PG_TEST_DSN unset: PG concurrency check NOT_RUN") + } + st := openTestStore(t) + if st.Dialect() != "postgres" { + t.Fatal("expected postgres") + } + if err := st.EnsureUser("alice", "Alice"); err != nil { + t.Fatal(err) + } + p, err := st.CreateProject("alice", "A") + if err != nil { + t.Fatal(err) + } + c, err := st.CreateConversation("alice", string(p.ID), "work", "") + if err != nil { + t.Fatal(err) + } + for _, command := range []string{"lower-one", "lower-two"} { + if err := st.AppendAudit("alice", agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: command, ConversationID: string(c.ID), State: agentcommand.StateCommitted}); err != nil { + t.Fatal(err) + } + } + env := agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: "middle-pending", ConversationID: string(c.ID), State: agentcommand.StateCommitted} + raw, err := json.Marshal(env) + if err != nil { + t.Fatal(err) + } + tx, err := st.db.Begin() + if err != nil { + t.Fatal(err) + } + defer tx.Rollback() + var middleID int64 + if err := tx.QueryRow(st.q(`INSERT INTO command_audit(time,command_id,source,entrance,state,operation_id,conversation_id,owner,payload,actor_name,project_id) + VALUES (?,?,'','','committed','',?,?,?,?,?) RETURNING id`), time.Now().UTC().Format(time.RFC3339Nano), "middle-pending", string(c.ID), "alice", string(raw), "Alice", string(p.ID)).Scan(&middleID); err != nil { + t.Fatal(err) + } + highDone := make(chan error, 1) + started := make(chan struct{}) + go func() { + close(started) + highDone <- st.AppendAudit("alice", agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: "higher-committed", ConversationID: string(c.ID), State: agentcommand.StateCommitted}) + }() + <-started + highCommittedEarly := false + select { + case err := <-highDone: + if err != nil { + t.Fatal(err) + } + highCommittedEarly = true + case <-time.After(250 * time.Millisecond): + } + var first []ProjectCommandAuditRow + var cutoff, next int64 + if highCommittedEarly { + first, cutoff, next, err = st.ListProjectCommandAudits("alice", string(p.ID), 0, 0, 2) + if err != nil { + tx.Rollback() + t.Fatal(err) + } + } + if err := tx.Commit(); err != nil { + t.Fatal(err) + } + if !highCommittedEarly { + if err := <-highDone; err != nil { + t.Fatal(err) + } + first, cutoff, next, err = st.ListProjectCommandAudits("alice", string(p.ID), 0, 0, 2) + if err != nil { + t.Fatal(err) + } + } + second, fixed, _, err := st.ListProjectCommandAudits("alice", string(p.ID), next, cutoff, 2) + if err != nil { + t.Fatal(err) + } + seen := map[string]bool{} + for _, row := range append(first, second...) { + seen[row.Envelope.CommandID] = true + } + if next == 0 || fixed != cutoff || len(seen) != 4 || !seen["middle-pending"] { + t.Fatalf("cross-page audit omitted a committed row: early=%v middleID=%d first=%+v second=%+v cutoff=%d next=%d", highCommittedEarly, middleID, first, second, cutoff, next) + } +} diff --git a/internal/store/project_events.go b/internal/store/project_events.go index ea6e7c4..ee8d75c 100644 --- a/internal/store/project_events.go +++ b/internal/store/project_events.go @@ -133,6 +133,31 @@ func (s *Store) AppendProjectSharedEvents(owner, producer, channel string, event return s.appendEvents(channel, events, owner, producer) } +// AppendProjectSharedEventsAccountChecked commits the structural event and +// sharing proof behind the actual actor's account fence. +func (s *Store) AppendProjectSharedEventsAccountChecked(actor, producer, channel string, events []core.StreamEvent, allowImplicitDefault bool) (int64, error) { + if actor == "" || strings.TrimSpace(producer) == "" { + return 0, ErrAccountForbidden + } + if _, ok := core.ProjectFromChannel(channel); !ok { + return 0, ErrNotFound + } + if len(events) == 0 { + return 0, nil + } + var base int64 + err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + var inner error + base, inner = s.appendEventsInTx(tx, channel, events, actor, producer) + return inner + }) + if err != nil { + return 0, err + } + assignEventSequences(events, base) + return base, nil +} + func (s *Store) saveProjectEventProof(tx *sql.Tx, channel string, seq int64, e core.StreamEvent, owner, producer string) error { e.ConversationID = channel resource, err := s.projectEventResource(tx, e) diff --git a/internal/store/project_fence_test.go b/internal/store/project_fence_test.go new file mode 100644 index 0000000..c92917a --- /dev/null +++ b/internal/store/project_fence_test.go @@ -0,0 +1,183 @@ +package store + +import ( + "errors" + "testing" + "time" +) + +func TestProjectAccountCheckedMutationsRespectEffectiveStateAndOwner(t *testing.T) { + st := openTestStore(t) + t.Logf("project account fence dialect=%s", st.Dialect()) + for _, account := range []struct { + owner string + admin bool + }{{"admin", true}, {"alice", false}, {"bob", false}} { + if err := st.CreateAccount(account.owner, "", "", account.admin); err != nil { + t.Fatal(err) + } + } + if _, err := st.CreateProjectAccountChecked("missing", "forbidden", false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("missing actor created a project: %v", err) + } + project, err := st.CreateProjectAccountChecked("alice", "original", false) + if err != nil || project.Owner != "alice" { + t.Fatalf("active owner could not create project: %+v %v", project, err) + } + for _, mutate := range []func() error{ + func() error { return st.RenameProjectAccountChecked("bob", project.ID, "foreign", false) }, + func() error { + return st.UpdateProjectSettingsAccountChecked("bob", project.ID, map[string]any{"foreign": true}, false) + }, + func() error { return st.DeleteProjectAccountChecked("bob", project.ID, false) }, + } { + if err := mutate(); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign actor changed project: %v", err) + } + } + if err := st.RenameProjectAccountChecked("alice", project.ID, "renamed", false); err != nil { + t.Fatal(err) + } + if err := st.UpdateProjectSettingsAccountChecked("alice", project.ID, map[string]any{"value": "retained"}, false); err != nil { + t.Fatal(err) + } + deletable, err := st.CreateProjectAccountChecked("bob", "temporary", false) + if err != nil { + t.Fatal(err) + } + child, err := st.CreateConversation("bob", deletable.ID, "child", "") + if err != nil { + t.Fatal(err) + } + if err := st.DeleteProjectAccountChecked("bob", deletable.ID, false); err != nil { + t.Fatal(err) + } + if _, err := st.GetProject("bob", deletable.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("successful delete left project behind: %v", err) + } + if _, err := st.GetConversation("bob", child.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("project deletion did not cascade to its conversation: %v", err) + } + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + if _, err := st.CreateProjectAccountChecked("alice", "late", false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled actor created a project: %v", err) + } + for _, mutate := range []func() error{ + func() error { return st.RenameProjectAccountChecked("alice", project.ID, "late", false) }, + func() error { + return st.UpdateProjectSettingsAccountChecked("alice", project.ID, map[string]any{"late": true}, false) + }, + func() error { return st.DeleteProjectAccountChecked("alice", project.ID, false) }, + } { + if err := mutate(); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled actor changed project: %v", err) + } + } + got, err := st.GetProject("alice", project.ID) + if err != nil || got.Name != "renamed" || got.Settings["value"] != "retained" || got.Settings["late"] != nil { + t.Fatalf("rejected mutations changed project: %+v %v", got, err) + } + projects, err := st.ListProjects("alice") + if err != nil || len(projects) != 1 { + t.Fatalf("rejected create wrote a project: %+v %v", projects, err) + } + if err := st.DeleteProjectAccountChecked("bob", "missing-project", false); !errors.Is(err, ErrNotFound) { + t.Fatalf("missing project delete: %v", err) + } + if err := st.DeleteProjectAccountChecked("admin", project.ID, false); !errors.Is(err, ErrNotFound) { + t.Fatalf("administrator crossed project ownership boundary: %v", err) + } +} + +func TestProjectAccountCheckedImplicitDefaultRequiresExplicitAllowance(t *testing.T) { + st := openTestStore(t) + if _, err := st.CreateProjectAccountChecked(OwnerDefault, "rejected", false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("implicit default admitted without authorization: %v", err) + } + project, err := st.CreateProjectAccountChecked(OwnerDefault, "local", true) + if err != nil || project.Owner != OwnerDefault { + t.Fatalf("implicit local owner was rejected: %+v %v", project, err) + } + if err := st.RenameProjectAccountChecked(OwnerDefault, project.ID, "renamed", true); err != nil { + t.Fatal(err) + } + if err := st.UpdateProjectSettingsAccountChecked(OwnerDefault, project.ID, map[string]any{"local": true}, true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("admin", "", "", true); err != nil { + t.Fatal(err) + } + if err := st.EnsureUser(OwnerDefault, ""); err != nil { + t.Fatal(err) + } + account, err := st.GetAccountByOwner(OwnerDefault) + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + if err := st.DeleteProjectAccountChecked(OwnerDefault, project.ID, true); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("explicitly disabled local owner deleted project: %v", err) + } +} + +func TestPGProjectCreateAccountFenceSerializesWithDisable(t *testing.T) { + st := openTestStore(t) + if st.Dialect() != "postgres" { + t.Skip("real PostgreSQL required") + } + other := reopenAccountModelStore(t, st) + if err := st.CreateAccount("admin", "", "", true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("alice", "", "", false); err != nil { + t.Fatal(err) + } + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + release := holdPGAccountWrite(t, st, "projects", "INSERT", "project_create") + type result struct { + owner string + err error + } + createDone := make(chan result, 1) + go func() { + project, err := st.CreateProjectAccountChecked("alice", "first", false) + createDone <- result{project.Owner, err} + }() + waitForPGAccountWrite(t, st, "project_create") + disableDone := make(chan error, 1) + go func() { + _, err := other.SetAccountEnabled("admin", account.ID, account.StateVersion, false) + disableDone <- err + }() + select { + case err := <-disableDone: + t.Fatalf("disable committed ahead of paused final project insert: %v", err) + case <-time.After(100 * time.Millisecond): + } + release() + created := awaitPGAccountResult(t, createDone) + if created.err != nil || created.owner != "alice" { + t.Fatalf("project insert did not complete before disable: %+v", created) + } + if err := awaitPGAccountResult(t, disableDone); err != nil { + t.Fatal(err) + } + if _, err := st.CreateProjectAccountChecked("alice", "late", false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("late project write passed after disable: %v", err) + } + projects, err := st.ListProjects("alice") + if err != nil || len(projects) != 1 || projects[0].Name != "first" { + t.Fatalf("project writes crossed disable boundary: %+v %v", projects, err) + } +} diff --git a/internal/store/project_projection.go b/internal/store/project_projection.go index 13aa99d..83fc9bd 100644 --- a/internal/store/project_projection.go +++ b/internal/store/project_projection.go @@ -97,6 +97,120 @@ func (s *Store) RenameOwnedProject(owner, id, name string) (core.Project, error) return p, tx.Commit() } +// projectProjectionForWrite locks an existing projection row after the actor +// and global project-name locks. A missing row remains ErrNotFound. +func (s *Store) projectProjectionForWrite(tx *sql.Tx, id string) (core.Project, error) { + if s.Dialect() == "postgres" { + var one int + err := tx.QueryRow(s.q(`SELECT 1 FROM projects WHERE id=? FOR UPDATE`), id).Scan(&one) + if errors.Is(err, sql.ErrNoRows) { + return core.Project{}, ErrNotFound + } + if err != nil { + return core.Project{}, err + } + } + return s.projectMetadata(tx, id) +} + +// EnsureProjectAccountChecked creates a stable projection or verifies an +// identical retry after checking the actual actor in the final transaction. +// The trusted owner scope remains distinct from the actor. +func (s *Store) EnsureProjectAccountChecked(actor, owner, id, name string, allowImplicitDefault bool) (core.Project, error) { + if actor == "" { + return core.Project{}, ErrAccountForbidden + } + name = strings.TrimSpace(name) + if owner == "" || id == "" || name == "" { + return core.Project{}, errors.New("store: project owner, id and name required") + } + var project core.Project + err := s.withEffectiveProjectActorsWrite(actor, owner, allowImplicitDefault, func(tx *sql.Tx) error { + if err := s.lockProjectNames(tx); err != nil { + return err + } + var err error + project, err = s.projectProjectionForWrite(tx, id) + if err == nil { + if project.Owner != owner { + return ErrNotFound + } + if project.Name != name { + return ErrConflict + } + return nil + } + if !errors.Is(err, ErrNotFound) { + return err + } + available, err := s.projectNameAvailable(tx, name, id) + if err != nil { + return err + } + if !available { + return ErrConflict + } + now := time.Now().UTC() + project = core.Project{ID: id, Owner: owner, Name: name, CreatedAt: now, UpdatedAt: now, Settings: map[string]any{}} + _, err = tx.Exec(s.q(`INSERT INTO projects(id,owner,name,settings,created_at,updated_at) VALUES(?,?,?,?,?,?)`), + id, owner, name, "{}", now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano)) + return err + }) + if err != nil { + return core.Project{}, err + } + return project, nil +} + +// RenameOwnedProjectAccountChecked keeps the stable ID and persisted owner +// while checking actor state, owner scope and global name uniqueness in one tx. +func (s *Store) RenameOwnedProjectAccountChecked(actor, owner, id, name string, allowImplicitDefault bool) (core.Project, error) { + if actor == "" { + return core.Project{}, ErrAccountForbidden + } + name = strings.TrimSpace(name) + if name == "" { + return core.Project{}, errors.New("store: project name required") + } + var project core.Project + err := s.withEffectiveProjectActorsWrite(actor, owner, allowImplicitDefault, func(tx *sql.Tx) error { + if err := s.lockProjectNames(tx); err != nil { + return err + } + var err error + project, err = s.projectProjectionForWrite(tx, id) + if err != nil { + return err + } + if project.Owner != owner { + return ErrNotFound + } + available, err := s.projectNameAvailable(tx, name, id) + if err != nil { + return err + } + if !available { + return ErrConflict + } + project.Name, project.UpdatedAt = name, time.Now().UTC() + res, err := tx.Exec(s.q(`UPDATE projects SET name=?,updated_at=? WHERE id=? AND owner=?`), + project.Name, project.UpdatedAt.Format(time.RFC3339Nano), id, owner) + if err != nil { + return err + } + if n, err := res.RowsAffected(); err != nil { + return err + } else if n != 1 { + return ErrNotFound + } + return nil + }) + if err != nil { + return core.Project{}, err + } + return project, nil +} + type projectQuerier interface { QueryRow(string, ...any) *sql.Row Query(string, ...any) (*sql.Rows, error) diff --git a/internal/store/project_projection_fence_test.go b/internal/store/project_projection_fence_test.go new file mode 100644 index 0000000..a7a6e4e --- /dev/null +++ b/internal/store/project_projection_fence_test.go @@ -0,0 +1,324 @@ +package store + +import ( + "errors" + "fmt" + "sync" + "testing" + "time" +) + +func TestPGDelegatedProjectionOppositeActorOrderMakesProgress(t *testing.T) { + st := openTestStore(t) + if st.Dialect() != "postgres" { + t.Skip("isolated PostgreSQL required") + } + other := reopenAccountModelStore(t, st) + for _, owner := range []string{"alice", "bob"} { + if err := st.CreateAccount(owner, "", "", false); err != nil { + t.Fatal(err) + } + } + aliceProject, err := st.CreateProject("alice", "Alice original") + if err != nil { + t.Fatal(err) + } + bobProject, err := st.CreateProject("bob", "Bob original") + if err != nil { + t.Fatal(err) + } + for round := 0; round < 8; round++ { + start := make(chan struct{}) + results := make(chan error, 2) + go func(n int) { + <-start + _, err := st.RenameOwnedProjectAccountChecked("bob", "alice", string(aliceProject.ID), fmt.Sprintf("Alice %d", n), false) + results <- err + }(round) + go func(n int) { + <-start + _, err := other.RenameOwnedProjectAccountChecked("alice", "bob", string(bobProject.ID), fmt.Sprintf("Bob %d", n), false) + results <- err + }(round) + close(start) + for i := 0; i < 2; i++ { + select { + case err := <-results: + if err != nil { + t.Fatalf("opposite delegated account order, round %d: %v", round, err) + } + case <-time.After(5 * time.Second): + t.Fatalf("opposite delegated account order stalled at round %d", round) + } + } + } +} + +func TestPGDelegatedRenameCommitsBeforeCompetingDisable(t *testing.T) { + st := openTestStore(t) + if st.Dialect() != "postgres" { + t.Skip("isolated PostgreSQL required") + } + other := reopenAccountModelStore(t, st) + for _, account := range []struct { + owner string + admin bool + }{{"admin", true}, {"alice", false}, {"bob", false}} { + if err := st.CreateAccount(account.owner, "", "", account.admin); err != nil { + t.Fatal(err) + } + } + project, err := st.CreateProject("alice", "original") + if err != nil { + t.Fatal(err) + } + bob, err := st.GetAccountByOwner("bob") + if err != nil { + t.Fatal(err) + } + release := holdPGAccountWrite(t, st, "projects", "UPDATE", "delegate-rename") + writeDone := make(chan error, 1) + go func() { + _, err := st.RenameOwnedProjectAccountChecked("bob", "alice", string(project.ID), "renamed", false) + writeDone <- err + }() + waitForPGAccountWrite(t, st, "delegate-rename") + disableDone := make(chan error, 1) + go func() { + _, err := other.SetAccountEnabled("admin", bob.ID, bob.StateVersion, false) + disableDone <- err + }() + select { + case err := <-disableDone: + t.Fatalf("disable committed before paused rename: %v", err) + case <-time.After(100 * time.Millisecond): + } + release() + if err := awaitPGAccountResult(t, writeDone); err != nil { + t.Fatal(err) + } + if err := awaitPGAccountResult(t, disableDone); err != nil { + t.Fatal(err) + } + got, err := st.ProjectMetadata(string(project.ID)) + if err != nil || got.Name != "renamed" { + t.Fatalf("completed rename was lost: %+v %v", got, err) + } +} + +func TestProjectProjectionAccountCheckedStateOwnershipAndIdempotency(t *testing.T) { + st := openTestStore(t) + t.Logf("project projection fence dialect=%s", st.Dialect()) + for _, account := range []struct { + owner string + admin bool + }{{"admin", true}, {"alice", false}, {"bob", false}} { + if err := st.CreateAccount(account.owner, "", "", account.admin); err != nil { + t.Fatal(err) + } + } + if _, err := st.EnsureProjectAccountChecked("missing", "missing", "never", "Never", false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("missing actor created projection: %v", err) + } + if _, err := st.EnsureProjectAccountChecked("", "alice", "never", "Never", false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("empty actor created projection: %v", err) + } + created, err := st.EnsureProjectAccountChecked("alice", "alice", "stable", " Shared ", false) + if err != nil || created.ID != "stable" || created.Owner != "alice" || created.Name != "Shared" { + t.Fatalf("projection create changed stable identity: %+v %v", created, err) + } + retry, err := st.EnsureProjectAccountChecked("alice", "alice", created.ID, "Shared", false) + if err != nil || retry.ID != created.ID || !retry.CreatedAt.Equal(created.CreatedAt) { + t.Fatalf("identical retry was not stable: %+v %v", retry, err) + } + if _, err := st.EnsureProjectAccountChecked("bob", "bob", created.ID, "Shared", false); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign owner reused stable ID: %v", err) + } + if _, err := st.RenameOwnedProjectAccountChecked("bob", "bob", created.ID, "Foreign", false); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign owner renamed projection: %v", err) + } + if _, err := st.EnsureProjectAccountChecked("alice", "alice", created.ID, "Different", false); !errors.Is(err, ErrConflict) { + t.Fatalf("non-identical retry replaced projection: %v", err) + } + if _, err := st.EnsureProjectAccountChecked("bob", "bob", "other", "Shared", false); !errors.Is(err, ErrConflict) { + t.Fatalf("duplicate global name was admitted: %v", err) + } + start := make(chan struct{}) + results := make(chan error, 2) + var wg sync.WaitGroup + for _, owner := range []string{"alice", "bob"} { + wg.Add(1) + go func(owner string) { + defer wg.Done() + <-start + _, err := st.EnsureProjectAccountChecked(owner, owner, "race-"+owner, "Race", false) + results <- err + }(owner) + } + close(start) + wg.Wait() + close(results) + wins, conflicts := 0, 0 + for err := range results { + switch { + case err == nil: + wins++ + case errors.Is(err, ErrConflict): + conflicts++ + default: + t.Fatalf("guarded name admission: %v", err) + } + } + if wins != 1 || conflicts != 1 { + t.Fatalf("guarded name admission wins=%d conflicts=%d", wins, conflicts) + } + renamed, err := st.RenameOwnedProjectAccountChecked("alice", "alice", created.ID, " Renamed ", false) + if err != nil || renamed.ID != created.ID || renamed.Owner != "alice" || renamed.Name != "Renamed" { + t.Fatalf("projection rename changed identity: %+v %v", renamed, err) + } + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + if _, err := st.EnsureProjectAccountChecked("alice", "alice", created.ID, "Renamed", false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled actor passed idempotent projection retry: %v", err) + } + if _, err := st.EnsureProjectAccountChecked("alice", "alice", "late", "Late", false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled actor created projection: %v", err) + } + if _, err := st.RenameOwnedProjectAccountChecked("alice", "alice", created.ID, "Late", false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled actor renamed projection: %v", err) + } + got, err := st.ProjectMetadata(created.ID) + if err != nil || got.ID != created.ID || got.Owner != "alice" || got.Name != "Renamed" { + t.Fatalf("denied write changed projection: %+v %v", got, err) + } +} + +func TestDelegatedProjectionRejectsDisabledCreatorAtFinalWrite(t *testing.T) { + st := openTestStore(t) + for _, account := range []struct { + owner string + admin bool + }{{"admin", true}, {"alice", false}, {"bob", false}} { + if err := st.CreateAccount(account.owner, "", "", account.admin); err != nil { + t.Fatal(err) + } + } + project, err := st.CreateProject("alice", "original") + if err != nil { + t.Fatal(err) + } + alice, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", alice.ID, alice.StateVersion, false); err != nil { + t.Fatal(err) + } + if _, err := st.RenameOwnedProjectAccountChecked("bob", "alice", string(project.ID), "late", false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("delegated write ignored disabled creator: %v", err) + } + got, err := st.ProjectMetadata(string(project.ID)) + if err != nil || got.Name != "original" { + t.Fatalf("disabled creator's project changed: %+v %v", got, err) + } +} + +func TestDelegatedProjectionImplicitLocalAppliesOnlyToMissingLocal(t *testing.T) { + st := openTestStore(t) + if err := st.CreateAccount("admin", "", "", true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("alice", "", "", false); err != nil { + t.Fatal(err) + } + aliceProject, err := st.CreateProject("alice", "Alice original") + if err != nil { + t.Fatal(err) + } + localProject, err := st.CreateProject(OwnerDefault, "Local original") + if err != nil { + t.Fatal(err) + } + if _, err := st.RenameOwnedProjectAccountChecked(OwnerDefault, "alice", string(aliceProject.ID), "Auth-on rejection", false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("missing local operator passed auth-on fence: %v", err) + } + if _, err := st.RenameOwnedProjectAccountChecked("alice", OwnerDefault, string(localProject.ID), "Auth-on rejection", false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("missing local creator passed auth-on fence: %v", err) + } + if _, err := st.RenameOwnedProjectAccountChecked(OwnerDefault, "alice", string(aliceProject.ID), "Local delegated", true); err != nil { + t.Fatalf("missing auth-off local operator rejected: %v", err) + } + if _, err := st.RenameOwnedProjectAccountChecked("alice", OwnerDefault, string(localProject.ID), "Alice delegated", true); err != nil { + t.Fatalf("missing auth-off local creator rejected: %v", err) + } + if err := st.EnsureUser(OwnerDefault, ""); err != nil { + t.Fatal(err) + } + local, err := st.GetAccountByOwner(OwnerDefault) + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", local.ID, local.StateVersion, false); err != nil { + t.Fatal(err) + } + if _, err := st.RenameOwnedProjectAccountChecked(OwnerDefault, "alice", string(aliceProject.ID), "Late operator", true); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled local operator renamed: %v", err) + } + if _, err := st.RenameOwnedProjectAccountChecked("alice", OwnerDefault, string(localProject.ID), "Late creator", true); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled local creator renamed: %v", err) + } +} + +func TestPGProjectProjectionAccountFenceSerializesWithDisable(t *testing.T) { + st := openTestStore(t) + if st.Dialect() != "postgres" { + t.Skip("real PostgreSQL required") + } + other := reopenAccountModelStore(t, st) + if err := st.CreateAccount("admin", "", "", true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("alice", "", "", false); err != nil { + t.Fatal(err) + } + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + release := holdPGAccountWrite(t, st, "projects", "INSERT", "projection") + writeDone := make(chan error, 1) + go func() { + _, err := st.EnsureProjectAccountChecked("alice", "alice", "stable", "Name", false) + writeDone <- err + }() + waitForPGAccountWrite(t, st, "projection") + disableDone := make(chan error, 1) + go func() { + _, err := other.SetAccountEnabled("admin", account.ID, account.StateVersion, false) + disableDone <- err + }() + select { + case err := <-disableDone: + t.Fatalf("disable committed ahead of paused projection insert: %v", err) + case <-time.After(100 * time.Millisecond): + } + release() + if err := awaitPGAccountResult(t, writeDone); err != nil { + t.Fatal(err) + } + if err := awaitPGAccountResult(t, disableDone); err != nil { + t.Fatal(err) + } + if _, err := st.RenameOwnedProjectAccountChecked("alice", "alice", "stable", "Late", false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("projection rename passed after disable: %v", err) + } + got, err := st.ProjectMetadata("stable") + if err != nil || got.Name != "Name" || got.Owner != "alice" { + t.Fatalf("projection crossed disable boundary: %+v %v", got, err) + } +} diff --git a/internal/store/project_work_outbox.go b/internal/store/project_work_outbox.go new file mode 100644 index 0000000..d5f73d6 --- /dev/null +++ b/internal/store/project_work_outbox.go @@ -0,0 +1,322 @@ +package store + +import ( + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "time" + + core "agent-system-core" +) + +// ProjectWorkObservationRow is a private host delivery record. A project ID is +// a trusted locator here, not authorization to reveal the original message to +// other project members. The source body is fetched from the message row only +// for the current provider delivery and is never part of project audit DTOs. +type ProjectWorkObservationRow struct { + EventID string + Owner string + ProjectID string + ConversationID string + RunID string + SourceKind string + SourceID string + SourceVersion string + SourceSHA256 string + PartIndex int + SpanStart int + SpanEnd int + Delivery string + Scope string + ObservedAt time.Time + State string + ReceiptID string + Text string + Metadata map[string]string + Reason string +} + +// RunWorkObservationInput identifies an actual retrieval/tool/visible-analysis +// result seen by a trusted run. Owner, project, and conversation are resolved +// from the stored run; callers cannot supply or override those identities. +type RunWorkObservationInput struct { + RunID string + SourceKind string // retrieval-hit|tool-result|visible-analysis + SourceID string + SourceVersion string + Text string + Delivery string // complete|summary-only|partial + PartIndex int + Metadata map[string]string +} + +func (s *Store) AppendRunWorkObservation(input RunWorkObservationInput) (string, error) { + if input.RunID == "" || input.SourceID == "" || input.SourceVersion == "" || input.PartIndex < 0 { + return "", errors.New("project work source: missing trusted source identity") + } + switch input.SourceKind { + case "retrieval-hit", "tool-result", "visible-analysis": + default: + return "", errors.New("project work source: unsupported kind") + } + if input.Delivery != "complete" && input.Delivery != "summary-only" && input.Delivery != "partial" { + return "", errors.New("project work source: unsupported delivery state") + } + if len(input.Text) > 64*1024 { + return "", errors.New("project work source: fragment exceeds bounded intake") + } + meta, err := json.Marshal(input.Metadata) + if err != nil || len(meta) > 8*1024 { + return "", errors.New("project work source: invalid or oversized metadata") + } + tx, err := s.db.Begin() + if err != nil { + return "", err + } + defer tx.Rollback() + var owner, projectID, conversationID, convOwner, convProject string + if err := tx.QueryRow(s.q(`SELECT r.owner,r.project_id,r.conversation_id,c.owner,c.project_id FROM runs r JOIN conversations c ON c.id=r.conversation_id WHERE r.id=?`), input.RunID). + Scan(&owner, &projectID, &conversationID, &convOwner, &convProject); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return "", ErrNotFound + } + return "", err + } + if owner != convOwner || projectID == "" || projectID != convProject { + return "", errors.New("project work source: run binding does not match current conversation") + } + hash := sha256.Sum256([]byte(input.Text)) + eventID := newID("obs") + observed := time.Now().UTC().Format(time.RFC3339Nano) + if _, err := tx.Exec(s.q(`INSERT INTO project_work_outbox(event_id,owner,project_id,conversation_id,run_id,source_kind,source_id,source_version,source_sha256,part_index,span_start,span_end,delivery,scope,observed_at,payload_text,metadata_json) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`), eventID, owner, projectID, conversationID, input.RunID, + input.SourceKind, input.SourceID, input.SourceVersion, hex.EncodeToString(hash[:]), input.PartIndex, + 0, len(input.Text), input.Delivery, "private", observed, input.Text, string(meta)); err != nil { + return "", err + } + if err := tx.Commit(); err != nil { + return "", err + } + return eventID, nil +} + +func (s *Store) appendProjectMessageObservationsTx(tx *sql.Tx, m *core.Message) error { + if m == nil || (m.Role != core.RoleUser && m.Role != core.RoleAssistant) { + return nil + } + var owner, projectID string + if err := tx.QueryRow(s.q(`SELECT owner,project_id FROM conversations WHERE id=?`), m.ConversationID).Scan(&owner, &projectID); err != nil { + return err + } + if projectID == "" { + return nil + } + kind := string(m.Role) + "-message" + observed := m.CreatedAt.UTC().Format(time.RFC3339Nano) + for i, part := range m.Parts { + // A tool summary, artifact reference, card, or hidden thinking part is not + // evidence of the original resource body. The separate retrieval/tool + // adapters will supply their actual source IDs and spans later. + if part.Type == core.PartThinking { + continue + } + delivery := "partial" + var material []byte + switch part.Type { + case core.PartText: + material = []byte(part.Text) + case core.PartPlan, core.PartCheck, core.PartCard: + material = []byte(marshalJSON(part)) // saved, visible analysis artefact + default: + material = []byte(marshalJSON(part)) + } + if (part.Type == core.PartText || part.Type == core.PartPlan || part.Type == core.PartCheck || part.Type == core.PartCard) && len(material) > 0 && len(material) <= 64*1024 { + delivery = "complete" + } + hash := sha256.Sum256(material) + idDigest := sha256.Sum256([]byte(fmt.Sprintf("%s:%s:%d", kind, m.ID, i))) + eventID := "obs_" + hex.EncodeToString(idDigest[:16]) + meta, _ := json.Marshal(map[string]string{"partType": string(part.Type)}) + if _, err := tx.Exec(s.q(`INSERT INTO project_work_outbox(event_id,owner,project_id,conversation_id,run_id,source_kind,source_id,source_version,source_sha256,part_index,span_start,span_end,delivery,scope,observed_at,metadata_json) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`), eventID, owner, projectID, m.ConversationID, m.RunID, + kind, m.ID, m.ID, hex.EncodeToString(hash[:]), i, 0, len(material), delivery, "private", observed, string(meta)); err != nil { + return err + } + } + return nil +} + +// PendingProjectWorkObservations returns committed, not-yet-ACKed source parts. +// It rechecks that the immutable source bytes still match the recorded hash. +// Missing or changed source bytes become partial, never invented complete text. +func (s *Store) PendingProjectWorkObservations(limit int) ([]ProjectWorkObservationRow, error) { + return s.projectWorkObservations("", "", limit) +} + +// ProjectMessageObservations returns every committed part of one message, +// including already-ACKed parts, for an action's exact source manifest. +func (s *Store) ProjectMessageObservations(messageID string) ([]ProjectWorkObservationRow, error) { + if messageID == "" { + return nil, errors.New("project work observation: empty message ID") + } + var count int + if err := s.db.QueryRow(s.q(`SELECT COUNT(*) FROM project_work_outbox WHERE source_id=? AND source_kind IN ('user-message','assistant-message')`), messageID).Scan(&count); err != nil { + return nil, err + } + if count > 200 { + return nil, errors.New("project work observation: message parts exceed safe preflight limit") + } + return s.projectWorkObservations(messageID, "", 200) +} + +// ProjectRunObservations is the exact committed source set linked to a run. +// An over-limit set is an explicit incomplete manifest, never a truncated pass. +func (s *Store) ProjectRunObservations(runID string) ([]ProjectWorkObservationRow, error) { + if runID == "" { + return nil, errors.New("project work observation: empty run ID") + } + var count int + if err := s.db.QueryRow(s.q(`SELECT COUNT(*) FROM project_work_outbox WHERE run_id=?`), runID).Scan(&count); err != nil { + return nil, err + } + if count > 200 { + return nil, errors.New("project work observation: run sources exceed safe preflight limit") + } + return s.projectWorkObservations("", runID, 200) +} + +func (s *Store) projectWorkObservations(messageID, runID string, limit int) ([]ProjectWorkObservationRow, error) { + if limit <= 0 || limit > 200 { + limit = 50 + } + query := `SELECT event_id,owner,project_id,conversation_id,run_id,source_kind,source_id,source_version,source_sha256,part_index,span_start,span_end,delivery,scope,observed_at,state,receipt_id,payload_text,metadata_json + FROM project_work_outbox WHERE state='pending' ORDER BY observed_at,event_id LIMIT ?` + args := []any{limit} + if messageID != "" { + query = `SELECT event_id,owner,project_id,conversation_id,run_id,source_kind,source_id,source_version,source_sha256,part_index,span_start,span_end,delivery,scope,observed_at,state,receipt_id,payload_text,metadata_json + FROM project_work_outbox WHERE source_id=? AND source_kind IN ('user-message','assistant-message') ORDER BY part_index LIMIT ?` + args = []any{messageID, limit} + } else if runID != "" { + query = `SELECT event_id,owner,project_id,conversation_id,run_id,source_kind,source_id,source_version,source_sha256,part_index,span_start,span_end,delivery,scope,observed_at,state,receipt_id,payload_text,metadata_json + FROM project_work_outbox WHERE run_id=? ORDER BY observed_at,event_id LIMIT ?` + args = []any{runID, limit} + } + rows, err := s.db.Query(s.q(query), args...) + if err != nil { + return nil, err + } + out := make([]ProjectWorkObservationRow, 0, limit) + for rows.Next() { + var row ProjectWorkObservationRow + var observed, payload, metadata string + if err := rows.Scan(&row.EventID, &row.Owner, &row.ProjectID, &row.ConversationID, &row.RunID, + &row.SourceKind, &row.SourceID, &row.SourceVersion, &row.SourceSHA256, &row.PartIndex, + &row.SpanStart, &row.SpanEnd, &row.Delivery, &row.Scope, &observed, &row.State, &row.ReceiptID, &payload, &metadata); err != nil { + rows.Close() + return nil, err + } + if err := json.Unmarshal([]byte(metadata), &row.Metadata); err != nil { + rows.Close() + return nil, err + } + row.Text = payload + row.ObservedAt, err = time.Parse(time.RFC3339Nano, observed) + if err != nil { + rows.Close() + return nil, err + } + out = append(out, row) + } + err = rows.Err() + rows.Close() + if err != nil { + return nil, err + } + for i := range out { + row := &out[i] + if row.Delivery != "complete" { + continue + } + if row.SourceKind != "user-message" && row.SourceKind != "assistant-message" { + hash := sha256.Sum256([]byte(row.Text)) + if hex.EncodeToString(hash[:]) != row.SourceSHA256 || row.SpanEnd != len(row.Text) { + row.Delivery, row.Reason, row.Text = "partial", "source-changed", "" + } + continue + } + msg, err := s.MessageByID(row.SourceID) + if errors.Is(err, ErrNotFound) { + row.Delivery, row.Reason = "partial", "source-missing" + continue + } + if err != nil { + return nil, err + } + if msg.ConversationID != core.ID(row.ConversationID) || row.PartIndex < 0 || row.PartIndex >= len(msg.Parts) { + row.Delivery, row.Reason = "partial", "source-changed" + continue + } + part := msg.Parts[row.PartIndex] + var text string + switch part.Type { + case core.PartText: + text = part.Text + case core.PartPlan, core.PartCheck, core.PartCard: + text = marshalJSON(part) + default: + row.Delivery, row.Reason = "partial", "source-changed" + continue + } + hash := sha256.Sum256([]byte(text)) + if hex.EncodeToString(hash[:]) != row.SourceSHA256 || row.SpanEnd != len(text) { + row.Delivery, row.Reason = "partial", "source-changed" + continue + } + row.Text = text + } + return out, nil +} + +// AckProjectWorkObservation records only the provider's durable intake receipt. +// It does not assert that source analysis, risk checks, or project publication +// finished. A different receipt for the same event is a conflict, not a retry. +func (s *Store) AckProjectWorkObservation(eventID, receiptID string) error { + return s.FinishProjectWorkObservation(eventID, receiptID, "accepted") +} + +// FinishProjectWorkObservation records the provider's terminal intake result. +// "accepted" is only durable receipt; "partial" and "denied" are truthful +// terminal treatment of this source part, not a project fact publication. +func (s *Store) FinishProjectWorkObservation(eventID, receiptID, state string) error { + if eventID == "" || receiptID == "" { + return errors.New("project work observation: empty event or receipt") + } + if state != "accepted" && state != "partial" && state != "denied" { + return errors.New("project work observation: invalid terminal state") + } + res, err := s.db.Exec(s.q(`UPDATE project_work_outbox SET state=?,receipt_id=? WHERE event_id=? AND state='pending'`), state, receiptID, eventID) + if err != nil { + return err + } + changed, err := res.RowsAffected() + if err != nil { + return err + } + if changed == 1 { + return nil + } + var currentReceipt, currentState string + if err := s.db.QueryRow(s.q(`SELECT receipt_id,state FROM project_work_outbox WHERE event_id=?`), eventID).Scan(¤tReceipt, ¤tState); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } + return err + } + if currentReceipt != receiptID || currentState != state { + return fmt.Errorf("project work observation: receipt conflict for %s", eventID) + } + return nil +} diff --git a/internal/store/project_work_outbox_test.go b/internal/store/project_work_outbox_test.go new file mode 100644 index 0000000..d76df12 --- /dev/null +++ b/internal/store/project_work_outbox_test.go @@ -0,0 +1,142 @@ +package store + +import ( + "crypto/sha256" + "fmt" + "testing" + "time" + + core "agent-system-core" +) + +func TestProjectMessageOutboxFollowsCommittedMessage(t *testing.T) { + st := openTestStore(t) + if err := st.EnsureUser("alice", "Alice"); err != nil { + t.Fatal(err) + } + p, err := st.CreateProject("alice", "Project") + if err != nil { + t.Fatal(err) + } + projectConv, err := st.CreateConversation("alice", string(p.ID), "Work", "") + if err != nil { + t.Fatal(err) + } + privateConv, err := st.CreateConversation("alice", string(p.ID), "Private", "") + if err != nil { + t.Fatal(err) + } + msg := core.Message{ConversationID: projectConv.ID, Role: core.RoleUser, Parts: []core.Part{ + {Type: core.PartText, Text: "要求效率和成本都低于人工"}, + {Type: core.PartArtifactRef, Artifact: &core.ArtifactRef{ArtifactID: "file-1"}}, + }} + if err := st.AppendMessageWithRequestAccountChecked("alice", false, &msg, "request-1"); err != nil { + t.Fatal(err) + } + rows, err := st.PendingProjectWorkObservations(10) + if err != nil { + t.Fatal(err) + } + if len(rows) != 2 { + t.Fatalf("message and artifact parts were not each recorded: %+v", rows) + } + byPart := map[int]ProjectWorkObservationRow{} + for _, row := range rows { + byPart[row.PartIndex] = row + } + wantHash := fmt.Sprintf("%x", sha256.Sum256([]byte(msg.Parts[0].Text))) + textPart := byPart[0] + if textPart.ProjectID != string(p.ID) || textPart.Owner != "alice" || textPart.SourceID != string(msg.ID) || textPart.SourceSHA256 != wantHash || textPart.Text != msg.Parts[0].Text || textPart.Delivery != "complete" || textPart.RunID != "" { + t.Fatalf("incorrect trusted user message observation: %+v", textPart) + } + artifactPart := byPart[1] + if artifactPart.Delivery != "partial" || artifactPart.Text != "" || artifactPart.SourceID != string(msg.ID) { + t.Fatalf("unparsed attachment was presented as analyzed text: %+v", artifactPart) + } + run := core.Run{ConversationID: projectConv.ID, ProjectID: p.ID, Owner: "alice", Kind: core.RunConversation, Status: core.RunPending, Phase: core.PhaseThinking} + if err := st.CreateRun(&run); err != nil { + t.Fatal(err) + } + if err := st.SetMessageRunIDAccountChecked("alice", string(msg.ID), string(run.ID), false); err != nil { + t.Fatal(err) + } + linked, err := st.PendingProjectWorkObservations(10) + if err != nil || len(linked) != 2 || linked[0].RunID != string(run.ID) || linked[1].RunID != string(run.ID) { + t.Fatalf("later trusted run link was not saved for every source part: %+v %v", linked, err) + } + runSources, err := st.ProjectRunObservations(string(run.ID)) + if err != nil || len(runSources) != 2 { + t.Fatalf("run preflight did not find its linked user parts: %+v %v", runSources, err) + } + retry := core.Message{ConversationID: projectConv.ID, Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "changed"}}} + if err := st.AppendMessageWithRequestAccountChecked("alice", false, &retry, "request-1"); err == nil { + t.Fatal("duplicate request did not surface existing message") + } + private := core.Message{ConversationID: privateConv.ID, Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "private"}}} + if err := st.AppendMessageWithRequestAccountChecked("alice", false, &private, "request-2"); err != nil { + t.Fatal(err) + } + again, err := st.PendingProjectWorkObservations(10) + if err != nil || len(again) != 3 { + t.Fatalf("duplicate request generated an extra source or private source was lost: %+v %v", again, err) + } + for _, row := range again { + if row.Scope != "private" { + t.Fatalf("project locator alone changed a message's private scope: %+v", row) + } + } + if err := st.AckProjectWorkObservation(textPart.EventID, "receipt-1"); err != nil { + t.Fatal(err) + } + allParts, err := st.ProjectMessageObservations(string(msg.ID)) + if err != nil || len(allParts) != 2 { + t.Fatalf("ACK erased exact action source manifest: %+v %v", allParts, err) + } + if err := st.AckProjectWorkObservation(textPart.EventID, "receipt-1"); err != nil { + t.Fatalf("same committed receipt was not idempotent: %v", err) + } + if err := st.AckProjectWorkObservation(textPart.EventID, "different"); err == nil { + t.Fatal("different observer receipt replaced the first one") + } + pending, err := st.PendingProjectWorkObservations(10) + if err != nil || len(pending) != 2 { + t.Fatalf("ACK did not remove only the delivered source: %+v %v", pending, err) + } +} + +func TestAssistantObservationCommitsOnlyWithFencedRunSettlement(t *testing.T) { + st := openTestStore(t) + conv := seedConversation(t, st) + run := seedRun(t, st, conv, core.RunRunning) + now := time.Now().UTC() + run.Status, run.Phase, run.EndedAt = core.RunCompleted, core.PhaseDone, &now + answer := core.Message{ConversationID: conv.ID, RunID: run.ID, Role: core.RoleAssistant, Parts: []core.Part{ + {Type: core.PartText, Text: "需要澄清效率的含义"}, + {Type: core.PartPlan, Plan: &core.PlanSnapshot{Title: "可见分析计划", Steps: []core.PlanStep{{ID: "clarify", Text: "确认比较方向", Status: "pending"}}}}, + }} + updated, err := st.SettleRun(&run, &answer) + if err != nil || !updated { + t.Fatalf("run settlement failed: updated=%t err=%v", updated, err) + } + rows, err := st.PendingProjectWorkObservations(10) + if err != nil || len(rows) != 2 { + t.Fatalf("final assistant observation was not committed with the run: %+v %v", rows, err) + } + byPart := map[int]ProjectWorkObservationRow{} + for _, row := range rows { + byPart[row.PartIndex] = row + } + if byPart[0].SourceKind != "assistant-message" || byPart[0].SourceID != string(answer.ID) || byPart[0].RunID != string(run.ID) || byPart[0].Text != answer.Parts[0].Text || byPart[1].Delivery != "complete" || byPart[1].Metadata["partType"] != string(core.PartPlan) || byPart[1].Text == "" { + t.Fatalf("visible analysis or final answer was lost: %+v", rows) + } + secondRun := seedRun(t, st, conv, core.RunRunning) + secondRun.Status, secondRun.Phase, secondRun.EndedAt = core.RunCompleted, core.PhaseDone, &now + failed := core.Message{ID: answer.ID, ConversationID: conv.ID, RunID: secondRun.ID, Role: core.RoleAssistant, Parts: []core.Part{{Type: core.PartText, Text: "different"}}} + if _, err := st.SettleRun(&secondRun, &failed); err == nil { + t.Fatal("duplicate final message did not fail") + } + again, err := st.PendingProjectWorkObservations(10) + if err != nil || len(again) != 2 { + t.Fatalf("failed settlement added a second observation: %+v %v", again, err) + } +} diff --git a/internal/store/project_work_run_source_test.go b/internal/store/project_work_run_source_test.go new file mode 100644 index 0000000..df7dac2 --- /dev/null +++ b/internal/store/project_work_run_source_test.go @@ -0,0 +1,52 @@ +package store + +import ( + "testing" + + core "agent-system-core" +) + +func TestRunWorkSourceUsesStoredRunAndRetainsEachActualHit(t *testing.T) { + st := openTestStore(t) + if err := st.EnsureUser("alice", "Alice"); err != nil { + t.Fatal(err) + } + p, err := st.CreateProject("alice", "P") + if err != nil { + t.Fatal(err) + } + conv, err := st.CreateConversation("alice", string(p.ID), "Work", "") + if err != nil { + t.Fatal(err) + } + run := core.Run{ConversationID: conv.ID, ProjectID: p.ID, Owner: "alice", Kind: core.RunConversation, Status: core.RunRunning, Phase: core.PhaseThinking} + if err := st.CreateRun(&run); err != nil { + t.Fatal(err) + } + input := RunWorkObservationInput{RunID: string(run.ID), SourceKind: "retrieval-hit", SourceID: "knowledge-1", SourceVersion: "v1", Text: "实际命中的资料", Delivery: "complete", Metadata: map[string]string{"query": "效率", "ref": "https://example.test/a"}} + first, err := st.AppendRunWorkObservation(input) + if err != nil { + t.Fatal(err) + } + second, err := st.AppendRunWorkObservation(input) + if err != nil || first == second { + t.Fatalf("repeated actual hit lost occurrence identity: first=%q second=%q err=%v", first, second, err) + } + rows, err := st.PendingProjectWorkObservations(10) + if err != nil || len(rows) != 2 { + t.Fatalf("run observations missing: %+v %v", rows, err) + } + for _, row := range rows { + if row.Owner != "alice" || row.ProjectID != string(p.ID) || row.ConversationID != string(conv.ID) || row.RunID != string(run.ID) || row.SourceID != "knowledge-1" || row.Text != input.Text || row.Metadata["query"] != "效率" { + t.Fatalf("source was not derived from trusted run or actual hit: %+v", row) + } + } + runRows, err := st.ProjectRunObservations(string(run.ID)) + if err != nil || len(runRows) != 2 { + t.Fatalf("run manifest omitted actual retrieval occurrences: %+v %v", runRows, err) + } + input.RunID = "run-forged" + if _, err := st.AppendRunWorkObservation(input); err == nil { + t.Fatal("unbound run locator became a project source") + } +} diff --git a/internal/store/project_work_source_status.go b/internal/store/project_work_source_status.go new file mode 100644 index 0000000..5fd2f84 --- /dev/null +++ b/internal/store/project_work_source_status.go @@ -0,0 +1,28 @@ +package store + +import ( + "database/sql" + "errors" + + core "agent-system-core" +) + +// ProjectWorkKnowledgeSource returns a stored knowledge source to the trusted +// host verifier. This is not a client read API: callers must independently +// enforce current owner, project, and resource publication permissions before +// disclosing its body to a project observer or model. +func (s *Store) ProjectWorkKnowledgeSource(id string) (core.KnowledgeDoc, error) { + var doc core.KnowledgeDoc + var owner, created string + err := s.db.QueryRow(s.q(`SELECT id,owner,project_id,source,title,ref,chunk,created_at FROM knowledge WHERE id=?`), id). + Scan(&doc.ID, &owner, &doc.ProjectID, &doc.Source, &doc.Title, &doc.Ref, &doc.Chunk, &created) + if errors.Is(err, sql.ErrNoRows) { + return doc, ErrNotFound + } + if err != nil { + return doc, err + } + doc.Owner = core.ID(owner) + doc.CreatedAt, err = parseTime(created) + return doc, err +} diff --git a/internal/store/projects.go b/internal/store/projects.go index 009a701..1b22fbb 100644 --- a/internal/store/projects.go +++ b/internal/store/projects.go @@ -125,6 +125,114 @@ func (s *Store) updateProjectRow(owner, id, query string, args ...any) error { return nil } +// CreateProjectAccountChecked checks the actor's effective state in the final +// transaction and makes that actor the new project's owner. +func (s *Store) CreateProjectAccountChecked(actor, name string, allowImplicitDefault bool) (core.Project, error) { + var project core.Project + err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + now := time.Now().UTC() + project = core.Project{ + ID: newID("prj"), Owner: actor, Name: name, + CreatedAt: now, UpdatedAt: now, Settings: map[string]any{}, + } + _, err := tx.Exec(s.q(`INSERT INTO projects(id,owner,name,settings,created_at,updated_at) VALUES(?,?,?,?,?,?)`), + project.ID, project.Owner, project.Name, "{}", now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano)) + return err + }) + if err != nil { + return core.Project{}, err + } + return project, nil +} + +// projectForAccountWrite locks the stored project row after actor admission +// and returns ErrNotFound unless that actor owns it. +func (s *Store) projectForAccountWrite(tx *sql.Tx, actor, id string) (string, error) { + var owner, settings string + query := s.q(`SELECT owner,settings FROM projects WHERE id=?`) + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + err := tx.QueryRow(query, id).Scan(&owner, &settings) + if errors.Is(err, sql.ErrNoRows) { + return "", ErrNotFound + } + if err != nil { + return "", err + } + if owner != actor { + return "", ErrNotFound + } + return settings, nil +} + +// RenameProjectAccountChecked keeps the legacy owner-only mutation semantics. +func (s *Store) RenameProjectAccountChecked(actor, id, name string, allowImplicitDefault bool) error { + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + if _, err := s.projectForAccountWrite(tx, actor, id); err != nil { + return err + } + res, err := tx.Exec(s.q(`UPDATE projects SET name=?,updated_at=? WHERE id=? AND owner=?`), name, nowISO(), id, actor) + if err != nil { + return err + } + if n, err := res.RowsAffected(); err != nil { + return err + } else if n != 1 { + return ErrNotFound + } + return nil + }) +} + +// DeleteProjectAccountChecked keeps the legacy owner-only deletion semantics. +func (s *Store) DeleteProjectAccountChecked(actor, id string, allowImplicitDefault bool) error { + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + if _, err := s.projectForAccountWrite(tx, actor, id); err != nil { + return err + } + res, err := tx.Exec(s.q(`DELETE FROM projects WHERE id=? AND owner=?`), id, actor) + if err != nil { + return err + } + if n, err := res.RowsAffected(); err != nil { + return err + } else if n != 1 { + return ErrNotFound + } + return nil + }) +} + +// UpdateProjectSettingsAccountChecked merges settings under the actor lock, +// then the project scope and row locks, in the final write transaction. +func (s *Store) UpdateProjectSettingsAccountChecked(actor, id string, values map[string]any, allowImplicitDefault bool) error { + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + if err := s.dialect.lockScope(tx, actor, "project:"+id); err != nil { + return err + } + settings, err := s.projectForAccountWrite(tx, actor, id) + if err != nil { + return err + } + cur := map[string]any{} + if settings != "" && settings != "{}" { + cur, _ = unmarshalJSON[map[string]any](settings) + } + merged := core.MergeSettings(cur, values) + res, err := tx.Exec(s.q(`UPDATE projects SET settings=?,updated_at=? WHERE id=? AND owner=?`), marshalJSON(merged), nowISO(), id, actor) + if err != nil { + return err + } + if n, err := res.RowsAffected(); err != nil { + return err + } else if n != 1 { + return ErrNotFound + } + return nil + }) +} + func scanProjects(rows *sql.Rows) ([]core.Project, error) { out := []core.Project{} for rows.Next() { @@ -333,3 +441,116 @@ func (s *Store) SetPermissionMode(owner, id, mode string) error { func (s *Store) DeleteConversation(owner, id string) error { return s.updateProjectRow(owner, id, `DELETE FROM conversations WHERE id=? AND owner=?`, id, owner) } + +// withOwnedConversationWrite locks the actor before the conversation and +// resolves ownership from the stored row in the final write transaction. +func (s *Store) withOwnedConversationWrite(actor, id string, allowImplicitDefault bool, write func(*sql.Tx) error) error { + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + if err := s.dialect.lockConversation(tx, id); err != nil { + return err + } + query := s.q(`SELECT owner FROM conversations WHERE id=?`) + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + var owner string + err := tx.QueryRow(query, id).Scan(&owner) + if errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return err + } + if owner != actor { + return ErrNotFound + } + return write(tx) + }) +} + +func (s *Store) execOwnedConversationWrite(tx *sql.Tx, query string, args ...any) error { + res, err := tx.Exec(s.q(query), args...) + if err != nil { + return err + } + if n, err := res.RowsAffected(); err != nil { + return err + } else if n != 1 { + return ErrNotFound + } + return nil +} + +// UpdateConversationAccountChecked changes title/profile only for its active owner. +func (s *Store) UpdateConversationAccountChecked(actor, id, title, profileID string, allowImplicitDefault bool) error { + return s.withOwnedConversationWrite(actor, id, allowImplicitDefault, func(tx *sql.Tx) error { + if title == "" { + return s.execOwnedConversationWrite(tx, + `UPDATE conversations SET profile_id=?,updated_at=? WHERE id=? AND owner=?`, + profileID, nowISO(), id, actor) + } + return s.execOwnedConversationWrite(tx, + `UPDATE conversations SET title=?,profile_id=?,updated_at=? WHERE id=? AND owner=?`, + title, profileID, nowISO(), id, actor) + }) +} + +// DeleteConversationAccountChecked retains the legacy owner-only delete rule. +func (s *Store) DeleteConversationAccountChecked(actor, id string, allowImplicitDefault bool) error { + return s.withOwnedConversationWrite(actor, id, allowImplicitDefault, func(tx *sql.Tx) error { + return s.execOwnedConversationWrite(tx, `DELETE FROM conversations WHERE id=? AND owner=?`, id, actor) + }) +} + +// SetConversationFlagsAccountChecked applies the legacy partial flag update +// while checking the actor and owner even when all flags are nil. +func (s *Store) SetConversationFlagsAccountChecked(actor, id string, pinned, archived, allowCrossRead, crossPrivate *bool, allowImplicitDefault bool) error { + var sets []string + var args []any + for _, field := range []struct { + name string + value *bool + }{ + {"pinned", pinned}, {"archived", archived}, + {"allow_cross_read", allowCrossRead}, {"cross_private", crossPrivate}, + } { + if field.value != nil { + sets = append(sets, field.name+"=?") + args = append(args, *field.value) + } + } + return s.withOwnedConversationWrite(actor, id, allowImplicitDefault, func(tx *sql.Tx) error { + if len(sets) == 0 { + return nil + } + return s.execOwnedConversationWrite(tx, + `UPDATE conversations SET `+strings.Join(sets, ", ")+`,updated_at=? WHERE id=? AND owner=?`, + append(args, nowISO(), id, actor)...) + }) +} + +// SetPermissionModeAccountChecked validates and stores the legacy mode values. +func (s *Store) SetPermissionModeAccountChecked(actor, id, mode string, allowImplicitDefault bool) error { + switch mode { + case "", "default", "plan", "auto_edit": + default: + return fmt.Errorf("store: unknown permission mode %q", mode) + } + if mode == "default" { + mode = "" + } + return s.withOwnedConversationWrite(actor, id, allowImplicitDefault, func(tx *sql.Tx) error { + return s.execOwnedConversationWrite(tx, + `UPDATE conversations SET permission_mode=?,updated_at=? WHERE id=? AND owner=?`, + mode, nowISO(), id, actor) + }) +} + +// SetSessionNoteAccountChecked stores the note for the active conversation owner. +func (s *Store) SetSessionNoteAccountChecked(actor, id, note string, allowImplicitDefault bool) error { + return s.withOwnedConversationWrite(actor, id, allowImplicitDefault, func(tx *sql.Tx) error { + return s.execOwnedConversationWrite(tx, + `UPDATE conversations SET session_note=?,updated_at=? WHERE id=? AND owner=?`, + note, nowISO(), id, actor) + }) +} diff --git a/internal/store/prompts.go b/internal/store/prompts.go index 2b164ad..7be7aa2 100644 --- a/internal/store/prompts.go +++ b/internal/store/prompts.go @@ -1,7 +1,9 @@ package store import ( + "database/sql" "encoding/json" + "errors" core "agent-system-core" ) @@ -61,3 +63,45 @@ func (s *Store) DeletePromptModule(owner, id string) error { _, err := s.db.Exec(s.q(`DELETE FROM prompt_modules WHERE id=? AND owner=?`), id, owner) return err } + +// SavePromptModuleAccountChecked stores an actor-owned module in the final +// account-guarded transaction. The payload owner cannot name another account. +func (s *Store) SavePromptModuleAccountChecked(actor string, m core.PromptModule, allowImplicitDefault bool) error { + if m.Owner != "" && m.Owner != actor { + return ErrAccountForbidden + } + m.Owner = actor + payload, err := json.Marshal(m) + if err != nil { + return err + } + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + _, err := tx.Exec(s.q(`INSERT INTO prompt_modules(id,owner,payload) VALUES(?,?,?) + ON CONFLICT(id,owner) DO UPDATE SET payload=excluded.payload`), m.ID, actor, payload) + return err + }) +} + +// DeletePromptModuleAccountChecked preserves the legacy absent-row no-op while +// checking the stored owner inside the final actor-guarded transaction. +func (s *Store) DeletePromptModuleAccountChecked(actor, id string, allowImplicitDefault bool) error { + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + var owner string + query := s.q(`SELECT owner FROM prompt_modules WHERE id=? AND owner=?`) + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + err := tx.QueryRow(query, id, actor).Scan(&owner) + if errors.Is(err, sql.ErrNoRows) { + return nil + } + if err != nil { + return err + } + if owner != actor { + return ErrNotFound + } + _, err = tx.Exec(s.q(`DELETE FROM prompt_modules WHERE id=? AND owner=?`), id, actor) + return err + }) +} diff --git a/internal/store/recovery.go b/internal/store/recovery.go index 0ce0b49..71b77a7 100644 --- a/internal/store/recovery.go +++ b/internal/store/recovery.go @@ -86,15 +86,70 @@ func (s *Store) ListRecoverableRuns(instanceID string, all bool) ([]core.Run, er // SealInterrupted 把仍在进行的 run 收成 interrupted。 // 条件更新保证并发对账时只有一个赢家;已经是终态的行不受影响。 +// 赢家并在同一事务中为该 run 写入一条助手中断错误消息(若尚无助手消息)。 func (s *Store) SealInterrupted(id string) (bool, error) { - errPart := marshalJSON(&core.ErrorPart{Code: "run.interrupted", Message: "进程重启或停机,运行已中断"}) - res, err := s.db.Exec(s.q(`UPDATE runs SET status=?, phase=?, error=?, ended_at=? WHERE id=? AND status IN ('pending','running','waiting_hitl')`), - string(core.RunInterrupted), string(core.PhaseDone), errPart, nowISO(), id) + tx, err := s.db.Begin() + if err != nil { + return false, err + } + defer tx.Rollback() + + var convID string + err = tx.QueryRow(s.q(`SELECT conversation_id FROM runs WHERE id=?`), id).Scan(&convID) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } else if err != nil { + return false, err + } + if convID != "" { + if err := s.dialect.lockConversation(tx, convID); err != nil { + return false, err + } + } + + errPartObj := &core.ErrorPart{Code: "run.interrupted", Message: "进程重启或停机,运行已中断"} + errPartJSON := marshalJSON(errPartObj) + res, err := tx.Exec(s.q(`UPDATE runs SET status=?, phase=?, error=?, ended_at=? WHERE id=? AND status IN ('pending','running','waiting_hitl')`), + string(core.RunInterrupted), string(core.PhaseDone), errPartJSON, nowISO(), id) if err != nil { return false, err } n, err := res.RowsAffected() - return n == 1, err + if err != nil { + return false, err + } + if n != 1 { + return false, nil + } + + var count int + if err := tx.QueryRow(s.q(`SELECT COUNT(*) FROM messages WHERE run_id=? AND role=?`), id, string(core.RoleAssistant)).Scan(&count); err != nil { + return false, err + } + if count == 0 && convID != "" { + msg := &core.Message{ + ID: newID("msg"), + ConversationID: convID, + RunID: id, + Role: core.RoleAssistant, + Parts: []core.Part{{Type: core.PartError, Err: errPartObj}}, + CreatedAt: time.Now().UTC(), + } + var variantGroup string + var variantIndex int + if err := tx.QueryRow(s.q(`SELECT variant_group, variant_index FROM regen_pending WHERE conversation_id=?`), convID).Scan(&variantGroup, &variantIndex); err == nil && variantGroup != "" { + msg.VariantGroup = variantGroup + msg.VariantIndex = variantIndex + } + if err := s.insertSettledMessageTx(tx, msg); err != nil { + return false, err + } + } + + if err := tx.Commit(); err != nil { + return false, err + } + return true, nil } // ActiveHITLCardsByRun 返回某个 run 上尚未关闭的 HITL 卡片。 @@ -126,6 +181,36 @@ func (s *Store) ActiveHITLCardsByRun(runID string) ([]core.HITLCard, error) { // (instance,epoch) 仍是该会话的当前执行权,被接管则拒写(fenced=true,run // 不变)。无租约行(未启用执行所有权)恒放行,语义与 SettleRun 一致。 func (s *Store) SettleRunFenced(r *core.Run, msg *core.Message, instanceID string, epoch int64) (updated, fenced bool, err error) { + return s.settleRunRetried(r, msg, &fenceRef{instance: instanceID, epoch: epoch, conversation: string(r.ConversationID)}, nil) +} + +// SettleRunFencedAccountChecked adds a transaction-time account check to the +// execution fence. A disable committing first prevents a later business +// message from reaching the authoritative message tree. +func (s *Store) SettleRunFencedAccountChecked(r *core.Run, msg *core.Message, instanceID string, epoch int64, allowImplicitLocal bool) (bool, bool, error) { + return s.settleRunRetried(r, msg, &fenceRef{instance: instanceID, epoch: epoch, conversation: string(r.ConversationID)}, + &accountSettleCheck{allowImplicitLocal: allowImplicitLocal}) +} + +// fenceRef 执行围栏引用(nil = 不校验)。 +type fenceRef struct { + instance, conversation string + epoch int64 +} + +type accountSettleCheck struct{ allowImplicitLocal bool } + +func (s *Store) SettleRun(r *core.Run, msg *core.Message) (updated bool, err error) { + updated, _, err = s.settleRunRetried(r, msg, nil, nil) + return updated, err +} + +func (s *Store) SettleRunAccountChecked(r *core.Run, msg *core.Message, allowImplicitLocal bool) (bool, error) { + updated, _, err := s.settleRunRetried(r, msg, nil, &accountSettleCheck{allowImplicitLocal: allowImplicitLocal}) + return updated, err +} + +func (s *Store) settleRunRetried(r *core.Run, msg *core.Message, fence *fenceRef, account *accountSettleCheck) (updated, fenced bool, err error) { s.settleMu.Lock() fault := s.settleFault s.settleMu.Unlock() @@ -137,7 +222,7 @@ func (s *Store) SettleRunFenced(r *core.Run, msg *core.Message, instanceID strin } var last error for attempt := 0; attempt < 8; attempt++ { - updated, fenced, last = s.settleRunOnce(r, msg, &fenceRef{instance: instanceID, epoch: epoch, conversation: string(r.ConversationID)}) + updated, fenced, last = s.settleRunOnce(r, msg, fence, account) if last == nil { return updated, fenced, nil } @@ -152,46 +237,46 @@ func (s *Store) SettleRunFenced(r *core.Run, msg *core.Message, instanceID strin return false, false, last } -// fenceRef 执行围栏引用(nil = 不校验)。 -type fenceRef struct { - instance, conversation string - epoch int64 -} - -func (s *Store) SettleRun(r *core.Run, msg *core.Message) (updated bool, err error) { - s.settleMu.Lock() - fault := s.settleFault - s.settleMu.Unlock() - if fault != nil { - return false, fault - } - if msg != nil && msg.ID == "" { - msg.ID = newID("msg") - } - var last error - for attempt := 0; attempt < 8; attempt++ { - var fenced bool - updated, fenced, last = s.settleRunOnce(r, msg, nil) - if last == nil { - return updated && !fenced, nil - } - if !isUniqueViolation(last) { - return false, last - } - time.Sleep(time.Duration(attempt) * time.Millisecond) - } - if last == nil { - last = fmt.Errorf("store: settle retries exhausted (run %s)", r.ID) - } - return false, last -} - -func (s *Store) settleRunOnce(r *core.Run, msg *core.Message, fence *fenceRef) (bool, bool, error) { +func (s *Store) settleRunOnce(r *core.Run, msg *core.Message, fence *fenceRef, account *accountSettleCheck) (bool, bool, error) { tx, err := s.db.Begin() if err != nil { return false, false, err } defer tx.Rollback() + if account != nil { + query := s.q(`SELECT enabled FROM users WHERE owner=?`) + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + var enabled bool + err := tx.QueryRow(query, r.Owner).Scan(&enabled) + if errors.Is(err, sql.ErrNoRows) { + if account.allowImplicitLocal && r.Owner == OwnerDefault { + enabled, err = true, nil + } else { + return false, false, ErrAccountForbidden + } + } + if err != nil { + return false, false, err + } + if !enabled { + // An already identified account-denial failure may persist a minimal + // error state; it contains no assistant content or business output. + allowedFailure := r.Status == core.RunFailed && r.Error != nil && r.Error.Code == "run.account_inactive" + if msg != nil { + for _, part := range msg.Parts { + if part.Type != core.PartError || part.Err == nil || part.Err.Code != "run.account_inactive" { + allowedFailure = false + break + } + } + } + if !allowedFailure { + return false, false, ErrAccountForbidden + } + } + } if err := s.dialect.lockConversation(tx, string(r.ConversationID)); err != nil { return false, false, err } @@ -226,30 +311,54 @@ func (s *Store) settleRunOnce(r *core.Run, msg *core.Message, fence *fenceRef) ( return false, false, err } if msg != nil { - insert := `INSERT INTO messages(id,conversation_id,run_id,role,parts,seq,created_at,request_id) - SELECT ?,?,?,?,?,COALESCE(MAX(seq),0)+1,?,? FROM messages WHERE conversation_id=?` - if _, err := tx.Exec(s.q(insert), msg.ID, msg.ConversationID, msg.RunID, string(msg.Role), marshalJSON(msg.Parts), nowISO(), "", msg.ConversationID); err != nil { + if err := s.insertSettledMessageTx(tx, msg); err != nil { return false, false, err } - if msg.VariantGroup != "" || msg.Excluded { - ex := 0 - if msg.Excluded { - ex = 1 - } - if _, err := tx.Exec(s.q(`UPDATE messages SET excluded=?, variant_group=?, variant_index=? WHERE id=?`), ex, msg.VariantGroup, msg.VariantIndex, msg.ID); err != nil { - return false, false, err - } - } } if err := tx.Commit(); err != nil { return false, false, err } - if msg != nil { - s.TouchConversation(string(msg.ConversationID)) - } return n == 1, false, nil } +func (s *Store) insertSettledMessageTx(tx *sql.Tx, msg *core.Message) error { + if msg == nil { + return nil + } + if msg.ID == "" { + msg.ID = newID("msg") + } + if msg.CreatedAt.IsZero() { + msg.CreatedAt = time.Now().UTC() + } + insert := `INSERT INTO messages(id,conversation_id,run_id,role,parts,seq,created_at,request_id) + SELECT ?,?,?,?,?,COALESCE(MAX(seq),0)+1,?,? FROM messages WHERE conversation_id=?` + if _, err := tx.Exec(s.q(insert), msg.ID, msg.ConversationID, msg.RunID, string(msg.Role), marshalJSON(msg.Parts), msg.CreatedAt.UTC().Format(time.RFC3339Nano), "", msg.ConversationID); err != nil { + return err + } + if err := s.appendProjectMessageObservationsTx(tx, msg); err != nil { + return err + } + if msg.VariantGroup != "" || msg.Excluded { + ex := 0 + if msg.Excluded { + ex = 1 + } + if _, err := tx.Exec(s.q(`UPDATE messages SET excluded=?, variant_group=?, variant_index=? WHERE id=?`), ex, msg.VariantGroup, msg.VariantIndex, msg.ID); err != nil { + return err + } + } + if msg.VariantGroup != "" { + if _, err := tx.Exec(s.q(`DELETE FROM regen_pending WHERE conversation_id=? AND variant_group=? AND variant_index=?`), msg.ConversationID, msg.VariantGroup, msg.VariantIndex); err != nil { + return err + } + } + if _, err := tx.Exec(s.q(`UPDATE conversations SET updated_at=? WHERE id=?`), nowISO(), msg.ConversationID); err != nil { + return err + } + return nil +} + // ListInterruptedSubagentRuns 列出被收成 interrupted 的子代理 run // (N2 WP5 重启续跑的候选集;幂等过滤由转录目录的 done/resumed 标记完成)。 func (s *Store) ListInterruptedSubagentRuns() ([]core.Run, error) { diff --git a/internal/store/recovery_holds.go b/internal/store/recovery_holds.go index 4ec9674..5ae946e 100644 --- a/internal/store/recovery_holds.go +++ b/internal/store/recovery_holds.go @@ -83,3 +83,16 @@ func (s *Store) ReleaseRecoveryHold(runID string) error { _ = res // 0 行 = 已解除或不存在:幂等成功 return nil } + +// ReleaseRecoveryHoldAccountChecked rechecks the actual operator at the final +// write. The hold may belong to a different owner; this method grants no +// project-content read permission. +func (s *Store) ReleaseRecoveryHoldAccountChecked(actor, runID string, allowImplicitDefault bool) error { + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, account Account) error { + if !account.IsAdmin && !(allowImplicitDefault && actor == OwnerDefault && account.ID == "") { + return ErrAccountForbidden + } + _, err := tx.Exec(s.q(`UPDATE recovery_holds SET released_at=? WHERE run_id=? AND released_at IS NULL`), nowISO(), runID) + return err + }) +} diff --git a/internal/store/recovery_holds_test.go b/internal/store/recovery_holds_test.go index 0572dbd..69b50c8 100644 --- a/internal/store/recovery_holds_test.go +++ b/internal/store/recovery_holds_test.go @@ -51,3 +51,25 @@ func TestRecoveryHoldsStoreCRUD(t *testing.T) { t.Fatalf("open holds=%v err=%v", holds, err) } } + +func TestRecoveryHoldReleaseRequiresCurrentAdmin(t *testing.T) { + st := openTestStore(t) + if err := st.CreateAccount("admin", "Admin", "hash", true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("ordinary", "Ordinary", "hash", false); err != nil { + t.Fatal(err) + } + if err := st.SetRecoveryHold("run-held", "ordinary", "", "unknown effect"); err != nil { + t.Fatal(err) + } + if err := st.ReleaseRecoveryHoldAccountChecked("ordinary", "run-held", false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("ordinary account released an admin hold: %v", err) + } + if held, err := st.HasOpenRecoveryHold("run-held", ""); err != nil || !held { + t.Fatalf("rejected release changed hold: held=%v err=%v", held, err) + } + if err := st.ReleaseRecoveryHoldAccountChecked("admin", "run-held", false); err != nil { + t.Fatal(err) + } +} diff --git a/internal/store/regeneration_account_write.go b/internal/store/regeneration_account_write.go new file mode 100644 index 0000000..152daa3 --- /dev/null +++ b/internal/store/regeneration_account_write.go @@ -0,0 +1,120 @@ +package store + +import ( + "database/sql" + "errors" + + core "agent-system-core" +) + +// PrepareRegenerationAccountChecked excludes the selected assistant run and +// records the next variant in one final actor-checked transaction. The target +// and conversation owner are read again after the account and conversation +// locks, so an earlier HTTP lookup cannot authorize a late write. +func (s *Store) PrepareRegenerationAccountChecked(actor, conversationID, messageID string, allowImplicitDefault bool) (string, int, error) { + var group string + var nextIndex int + err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + if err := s.dialect.lockConversation(tx, conversationID); err != nil { + return err + } + query := s.q(`SELECT COALESCE(m.run_id,''),m.variant_group,m.variant_index,m.role,c.owner + FROM messages m JOIN conversations c ON c.id=m.conversation_id + WHERE m.id=? AND m.conversation_id=?`) + if s.Dialect() == "postgres" { + query += ` FOR UPDATE OF m` + } + var runID, existingGroup, role, owner string + var variantIndex int + if err := tx.QueryRow(query, messageID, conversationID).Scan(&runID, &existingGroup, &variantIndex, &role, &owner); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + if owner != actor || role != string(core.RoleAssistant) { + return ErrNotFound + } + maxIndex := variantIndex + if existingGroup != "" { + if err := tx.QueryRow(s.q(`SELECT COALESCE(MAX(variant_index),0) FROM messages WHERE conversation_id=? AND variant_group=?`), + conversationID, existingGroup).Scan(&maxIndex); err != nil { + return err + } + if variantIndex > maxIndex { + maxIndex = variantIndex + } + } + group = existingGroup + if group == "" { + group = "var_" + messageID + } + var pendingGroup string + var pendingIndex int + err := tx.QueryRow(s.q(`SELECT variant_group,variant_index FROM regen_pending WHERE conversation_id=?`), conversationID).Scan(&pendingGroup, &pendingIndex) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + if pendingGroup == group && pendingIndex > maxIndex { + maxIndex = pendingIndex + } + nextIndex = maxIndex + 1 + if _, err := tx.Exec(s.q(`UPDATE messages SET excluded=1,variant_group=?,variant_index=? WHERE id=? AND conversation_id=?`), + group, variantIndex, messageID, conversationID); err != nil { + return err + } + if runID != "" { + if _, err := tx.Exec(s.q(`UPDATE messages SET excluded=1 WHERE run_id=? AND conversation_id=?`), runID, conversationID); err != nil { + return err + } + } + _, err = tx.Exec(s.q(`INSERT INTO regen_pending(conversation_id,variant_group,variant_index) VALUES(?,?,?) + ON CONFLICT(conversation_id) DO UPDATE SET variant_group=excluded.variant_group,variant_index=excluded.variant_index`), + conversationID, group, nextIndex) + return err + }) + if err != nil { + return "", 0, err + } + return group, nextIndex, nil +} + +// ClearRegenPendingAfterFailedStart is safety cleanup, not a new business +// write. It may remove this request's stale marker after account disable, but +// still locks the actor and verifies persisted conversation ownership. A later +// regeneration with another group or index is never cleared. +func (s *Store) ClearRegenPendingAfterFailedStart(actor, conversationID, group string, index int, allowImplicitDefault bool) error { + if actor == "" || conversationID == "" || group == "" || index <= 0 { + return ErrAccountForbidden + } + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + _, err = s.lockAccountUsers(tx, actor) + if errors.Is(err, ErrNotFound) && allowImplicitDefault && actor == OwnerDefault { + err = nil + } + if errors.Is(err, ErrNotFound) { + return ErrAccountForbidden + } + if err != nil { + return err + } + if err := s.dialect.lockConversation(tx, conversationID); err != nil { + return err + } + var owner string + if err := tx.QueryRow(s.q(`SELECT owner FROM conversations WHERE id=?`), conversationID).Scan(&owner); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + if owner != actor { + return ErrNotFound + } + if _, err := tx.Exec(s.q(`DELETE FROM regen_pending WHERE conversation_id=? AND variant_group=? AND variant_index=?`), conversationID, group, index); err != nil { + return err + } + return tx.Commit() +} diff --git a/internal/store/regeneration_account_write_test.go b/internal/store/regeneration_account_write_test.go new file mode 100644 index 0000000..2902292 --- /dev/null +++ b/internal/store/regeneration_account_write_test.go @@ -0,0 +1,176 @@ +package store + +import ( + "errors" + "testing" + + core "agent-system-core" +) + +func TestPrepareRegenerationAccountCheckedFencesAllWrites(t *testing.T) { + st := openTestStore(t) + for _, account := range []struct { + owner string + admin bool + }{{"admin", true}, {"alice", false}, {"bob", false}} { + if err := st.CreateAccount(account.owner, "", "", account.admin); err != nil { + t.Fatal(err) + } + } + project, err := st.CreateProject("alice", "regeneration") + if err != nil { + t.Fatal(err) + } + conv, err := st.CreateConversation("alice", project.ID, "thread", "") + if err != nil { + t.Fatal(err) + } + run := seedRun(t, st, conv, core.RunCompleted) + target := core.Message{ConversationID: conv.ID, RunID: run.ID, Role: core.RoleAssistant} + sibling := core.Message{ConversationID: conv.ID, RunID: run.ID, Role: core.RoleAssistant} + for _, msg := range []*core.Message{&target, &sibling} { + if err := st.AppendMessage(msg); err != nil { + t.Fatal(err) + } + } + assertUnchanged := func() { + t.Helper() + msgs, err := st.ListMessages(conv.ID) + if err != nil { + t.Fatal(err) + } + if len(msgs) != 2 || msgs[0].Excluded || msgs[1].Excluded || msgs[0].VariantGroup != "" { + t.Fatalf("rejected regeneration changed messages: %+v", msgs) + } + if _, _, ok := st.PeekRegenPending(conv.ID); ok { + t.Fatal("rejected regeneration created pending marker") + } + } + if _, _, err := st.PrepareRegenerationAccountChecked("bob", conv.ID, target.ID, false); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign actor: %v", err) + } + assertUnchanged() + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + if _, _, err := st.PrepareRegenerationAccountChecked("alice", conv.ID, target.ID, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled actor: %v", err) + } + assertUnchanged() + account, err = st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, true); err != nil { + t.Fatal(err) + } + group, index, err := st.PrepareRegenerationAccountChecked("alice", conv.ID, target.ID, false) + if err != nil || group != "var_"+target.ID || index != 1 { + t.Fatalf("active regeneration: group=%q index=%d err=%v", group, index, err) + } + msgs, err := st.ListMessages(conv.ID) + if err != nil || len(msgs) != 2 || !msgs[0].Excluded || !msgs[1].Excluded || msgs[0].VariantGroup != group { + t.Fatalf("active regeneration missed message state: %+v %v", msgs, err) + } + if pendingGroup, pendingIndex, ok := st.PeekRegenPending(conv.ID); !ok || pendingGroup != group || pendingIndex != index { + t.Fatalf("active regeneration marker: group=%q index=%d ok=%v", pendingGroup, pendingIndex, ok) + } + if err := st.ClearRegenPendingAfterFailedStart("alice", conv.ID, group, index, false); err != nil { + t.Fatal(err) + } + if _, _, ok := st.PeekRegenPending(conv.ID); ok { + t.Fatal("failed-start cleanup left pending marker") + } + group, index, err = st.PrepareRegenerationAccountChecked("alice", conv.ID, target.ID, false) + if err != nil { + t.Fatal(err) + } + if err := st.ClearRegenPendingAfterFailedStart("bob", conv.ID, group, index, false); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign marker cleanup: %v", err) + } + account, err = st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + if err := st.ClearRegenPendingAfterFailedStart("alice", conv.ID, group, index, false); err != nil { + t.Fatalf("disabled marker safety cleanup: %v", err) + } + if _, _, ok := st.PeekRegenPending(conv.ID); ok { + t.Fatal("failed-start cleanup left stale marker after disable") + } +} + +func TestPrepareRegenerationAccountCheckedUsesExistingVariantGroup(t *testing.T) { + st := openTestStore(t) + if err := st.CreateAccount("alice", "", "", false); err != nil { + t.Fatal(err) + } + project, err := st.CreateProject("alice", "regeneration") + if err != nil { + t.Fatal(err) + } + conv, err := st.CreateConversation("alice", project.ID, "thread", "") + if err != nil { + t.Fatal(err) + } + target := core.Message{ConversationID: conv.ID, Role: core.RoleAssistant} + later := core.Message{ConversationID: conv.ID, Role: core.RoleAssistant} + for _, msg := range []*core.Message{&target, &later} { + if err := st.AppendMessage(msg); err != nil { + t.Fatal(err) + } + } + if err := st.SetMessageFlags(target.ID, false, "variants", 1); err != nil { + t.Fatal(err) + } + if err := st.SetMessageFlags(later.ID, false, "variants", 3); err != nil { + t.Fatal(err) + } + group, index, err := st.PrepareRegenerationAccountChecked("alice", conv.ID, target.ID, false) + if err != nil || group != "variants" || index != 4 { + t.Fatalf("existing group: %q index=%d err=%v", group, index, err) + } + if pendingGroup, pendingIndex, ok := st.PeekRegenPending(conv.ID); !ok || pendingGroup != group || pendingIndex != index { + t.Fatalf("next group index: group=%q index=%d ok=%v", pendingGroup, pendingIndex, ok) + } +} + +func TestFailedStartCleanupDoesNotRemoveNewerSameGroupMarker(t *testing.T) { + st := openTestStore(t) + if err := st.CreateAccount("alice", "", "", false); err != nil { + t.Fatal(err) + } + project, err := st.CreateProject("alice", "regeneration") + if err != nil { + t.Fatal(err) + } + conv, err := st.CreateConversation("alice", project.ID, "thread", "") + if err != nil { + t.Fatal(err) + } + target := core.Message{ConversationID: conv.ID, Role: core.RoleAssistant} + if err := st.AppendMessage(&target); err != nil { + t.Fatal(err) + } + group, first, err := st.PrepareRegenerationAccountChecked("alice", conv.ID, target.ID, false) + if err != nil { + t.Fatal(err) + } + group2, second, err := st.PrepareRegenerationAccountChecked("alice", conv.ID, target.ID, false) + if err != nil || group2 != group || second <= first { + t.Fatalf("newer marker failed to advance: %q %d -> %q %d: %v", group, first, group2, second, err) + } + if err := st.ClearRegenPendingAfterFailedStart("alice", conv.ID, group, first, false); err != nil { + t.Fatal(err) + } + if gotGroup, gotIndex, ok := st.PeekRegenPending(conv.ID); !ok || gotGroup != group || gotIndex != second { + t.Fatalf("old cleanup removed newer marker: %q %d %v", gotGroup, gotIndex, ok) + } +} diff --git a/internal/store/role_policy.go b/internal/store/role_policy.go new file mode 100644 index 0000000..3d4cece --- /dev/null +++ b/internal/store/role_policy.go @@ -0,0 +1,1382 @@ +package store + +import ( + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "time" + + "agent-system-backend/internal/authz/catalog" +) + +var ( + ErrInvalidRolePolicy = errors.New("store: invalid role policy") + ErrLastPermissionManager = errors.New("store: final permission manager") + // Role permission writers reject a stale or missing role as an invalid + // policy; the wrapped conflict/not-found class keeps HTTP retry semantics. + ErrRolePolicyVersion = fmt.Errorf("%w: role version changed: %w", ErrInvalidRolePolicy, ErrConflict) + ErrRolePolicyRoleMissing = fmt.Errorf("%w: role missing: %w", ErrInvalidRolePolicy, ErrNotFound) +) + +type RoleCreate struct { + Name string + Description string +} + +type RolePatch struct { + Name *string + Description *string + Enabled *bool +} + +type RolePermissionChange struct { + ActionID string `json:"actionId"` + Allowed bool `json:"allowed"` + CapAllowed bool `json:"capAllowed"` +} + +type RolePermissionRecord struct { + ActionID string `json:"actionId"` + Allowed bool `json:"allowed"` + CapAllowed bool `json:"capAllowed"` + PermissionVersion int64 `json:"permissionVersion"` +} + +type RoleAdminView struct { + RoleRecord + BindingCount int `json:"bindingCount"` + Permissions []RolePermissionRecord `json:"permissions"` +} + +type PolicyReceipt struct { + ReceiptID string `json:"receiptId"` + AuthorityEpoch int64 `json:"authorityEpoch"` + RoleVersion int64 `json:"roleVersion,omitempty"` + // LinkedExpansion lists linked rows the write added for project.create. + LinkedExpansion []string `json:"linkedExpansion,omitempty"` + MigrationNote string `json:"migrationNote,omitempty"` +} + +// CapabilityAssociation is one actor's project.create link computed from a +// single authority snapshot. HTTP and command surfaces both read it here. +// Conflicts lists linked actions an enabled role still caps off. +type CapabilityAssociation struct { + ProjectCreate bool `json:"projectCreate"` + Source string `json:"source,omitempty"` + LinkedActions []string `json:"linkedActions"` + Conflicts []string `json:"conflicts,omitempty"` + AuthorityEpoch int64 `json:"authorityEpoch"` +} + +// RoleRecord is the stable position definition stored by Backend. +type RoleRecord struct { + RoleID string `json:"roleId"` + Name string `json:"name"` + Description string `json:"description"` + Enabled bool `json:"enabled"` + RoleVersion int64 `json:"roleVersion"` + CreatedBy string `json:"createdBy"` + UpdatedBy string `json:"updatedBy"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// RoleBinding records one appointment. At most one active binding exists for +// a user; inactive rows retain the appointment history. +type RoleBinding struct { + BindingID string `json:"bindingId"` + UserID string `json:"userId"` + RoleID string `json:"roleId"` + Active bool `json:"active"` + BindingVersion int64 `json:"bindingVersion"` + AssignedBy string `json:"assignedBy"` + AssignedAt time.Time `json:"assignedAt"` + EndedAt *time.Time `json:"endedAt,omitempty"` + UserStateVersion int64 `json:"userStateVersion"` + AuthorityEpoch int64 `json:"authorityEpoch"` +} + +type RoleBindingView struct { + Binding *RoleBinding `json:"binding,omitempty"` + Role *RoleRecord `json:"role,omitempty"` +} + +type RoleAuthorityImportRole struct { + RoleID string + Name string + Description string + Enabled bool + RoleVersion int64 + Permissions []RolePermissionChange + Owners []string +} + +type RoleAuthorityImport struct { + SourceSchemaVersion int + SourceSHA256 string + Roles []RoleAuthorityImportRole +} + +type RoleAuthorityImportResult struct { + AuthorityEpoch int64 + ImportedRoles int + ImportedUsers int +} + +func normalizeRoleName(name string) string { + return strings.ToLower(strings.TrimSpace(name)) +} + +func (s *Store) seedSystemAdminGrantsTx(tx *sql.Tx, owner string) error { + var version sql.NullInt64 + if err := tx.QueryRow(`SELECT MAX(version) FROM schema_migrations`).Scan(&version); err != nil { + return err + } + if !version.Valid || version.Int64 < 49 { + return nil + } + var userID string + if err := tx.QueryRow(s.q(`SELECT id FROM users WHERE owner=?`), owner).Scan(&userID); err != nil { + return err + } + now := nowISO() + for _, actionID := range rolePolicyAdminActionsV49 { + if _, err := tx.Exec(s.q(`INSERT INTO system_grants(user_id,action_id,allowed,grant_version,updated_by,updated_at) + VALUES(?,?,?,?,?,?) ON CONFLICT(user_id,action_id) DO UPDATE SET allowed=excluded.allowed,updated_by=excluded.updated_by,updated_at=excluded.updated_at`), + userID, actionID, true, 1, userID, now); err != nil { + return err + } + } + return nil +} + +func validateRoleText(name, description string) (string, string, error) { + name = strings.TrimSpace(name) + description = strings.TrimSpace(description) + if name == "" || len([]rune(name)) > 128 || len([]rune(description)) > 2000 { + return "", "", ErrInvalidRolePolicy + } + return name, description, nil +} + +func (s *Store) policyWrite(actor, targetUserID string, fn func(*sql.Tx, Account, Account, int64) error) error { + if actor == "" || fn == nil { + return ErrAccountForbidden + } + targetOwner := actor + if targetUserID != "" { + target, err := s.GetAccount(targetUserID) + if err != nil { + return ErrAccountForbidden + } + targetOwner = target.Owner + } + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + accounts, err := s.lockAccountUsers(tx, actor, targetOwner) + if err != nil { + return ErrAccountForbidden + } + actorAccount := accounts[actor] + targetAccount := accounts[targetOwner] + if !actorAccount.Enabled || !targetAccount.Enabled || (targetUserID != "" && targetAccount.ID != targetUserID) { + return ErrAccountForbidden + } + query := `SELECT authority_epoch FROM authority_state WHERE id=1` + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + var epoch int64 + if err := tx.QueryRow(query).Scan(&epoch); err != nil { + return err + } + if err := fn(tx, actorAccount, targetAccount, epoch); err != nil { + return err + } + return tx.Commit() +} + +func scanRole(row *sql.Row) (RoleRecord, error) { + var role RoleRecord + var createdAt, updatedAt string + err := row.Scan(&role.RoleID, &role.Name, &role.Description, &role.Enabled, &role.RoleVersion, + &role.CreatedBy, &role.UpdatedBy, &createdAt, &updatedAt) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return RoleRecord{}, ErrNotFound + } + return RoleRecord{}, err + } + role.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAt) + role.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updatedAt) + return role, nil +} + +func (s *Store) roleTx(tx *sql.Tx, roleID string, lock bool) (RoleRecord, error) { + query := `SELECT role_id,name,description,enabled,role_version,created_by,updated_by,created_at,updated_at FROM roles WHERE role_id=?` + if lock && s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + return scanRole(tx.QueryRow(s.q(query), roleID)) +} + +func (s *Store) requireRoleAuthorityWritableTx(tx *sql.Tx) error { + query := `SELECT state FROM role_authority_import_state WHERE id=1` + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + var state string + if err := tx.QueryRow(query).Scan(&state); err != nil { + return err + } + if state == "staged" { + return ErrConflict + } + return nil +} + +func (s *Store) CreateRole(actor string, in RoleCreate) (RoleRecord, error) { + name, description, err := validateRoleText(in.Name, in.Description) + if err != nil { + return RoleRecord{}, err + } + var out RoleRecord + err = s.policyWrite(actor, "", func(tx *sql.Tx, actorAccount, _ Account, epoch int64) error { + if err := s.requireRoleAuthorityWritableTx(tx); err != nil { + return err + } + now := nowISO() + roleID := newID("role") + _, err := tx.Exec(s.q(`INSERT INTO roles(role_id,name,normalized_name,description,enabled,role_version,created_by,updated_by,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?)`), roleID, name, normalizeRoleName(name), description, true, 1, actorAccount.ID, actorAccount.ID, now, now) + if err != nil { + if strings.Contains(strings.ToLower(err.Error()), "unique") || strings.Contains(strings.ToLower(err.Error()), "duplicate") { + return ErrConflict + } + return err + } + if _, err := tx.Exec(s.q(`UPDATE authority_state SET authority_epoch=?,updated_at=? WHERE id=1 AND authority_epoch=?`), epoch+1, now, epoch); err != nil { + return err + } + out, err = s.roleTx(tx, roleID, false) + return err + }) + return out, err +} + +func (s *Store) PatchRole(actor, roleID string, expected int64, patch RolePatch) (RoleRecord, error) { + var out RoleRecord + err := s.policyWrite(actor, "", func(tx *sql.Tx, actorAccount, _ Account, epoch int64) error { + if err := s.requireRoleAuthorityWritableTx(tx); err != nil { + return err + } + current, err := s.roleTx(tx, roleID, true) + if err != nil { + return err + } + if current.RoleVersion != expected { + return ErrConflict + } + name, description, enabled := current.Name, current.Description, current.Enabled + if patch.Name != nil { + name = *patch.Name + } + if patch.Description != nil { + description = *patch.Description + } + if patch.Enabled != nil { + enabled = *patch.Enabled + } + name, description, err = validateRoleText(name, description) + if err != nil { + return err + } + now := nowISO() + result, err := tx.Exec(s.q(`UPDATE roles SET name=?,normalized_name=?,description=?,enabled=?,role_version=role_version+1,updated_by=?,updated_at=? WHERE role_id=? AND role_version=?`), + name, normalizeRoleName(name), description, enabled, actorAccount.ID, now, roleID, expected) + if err != nil { + return err + } + if n, _ := result.RowsAffected(); n != 1 { + return ErrConflict + } + if _, err := tx.Exec(s.q(`UPDATE authority_state SET authority_epoch=?,updated_at=? WHERE id=1 AND authority_epoch=?`), epoch+1, now, epoch); err != nil { + return err + } + out, err = s.roleTx(tx, roleID, false) + return err + }) + return out, err +} + +func policyRequestHash(value any) string { + data, _ := json.Marshal(value) + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} + +func (s *Store) replayReceipt(tx *sql.Tx, actorID, key, operation, requestHash string, out any) (bool, error) { + if key == "" { + return false, ErrInvalidRolePolicy + } + var storedOperation, storedHash, result string + err := tx.QueryRow(s.q(`SELECT operation,request_hash,result_json FROM policy_receipts WHERE actor_user_id=? AND idempotency_key=?`), actorID, key). + Scan(&storedOperation, &storedHash, &result) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + if err != nil { + return false, err + } + if storedOperation != operation || storedHash != requestHash { + return false, ErrConflict + } + return true, json.Unmarshal([]byte(result), out) +} + +func (s *Store) saveReceipt(tx *sql.Tx, actorID, key, operation, requestHash string, value any) error { + data, err := json.Marshal(value) + if err != nil { + return err + } + _, err = tx.Exec(s.q(`INSERT INTO policy_receipts(receipt_id,actor_user_id,idempotency_key,operation,request_hash,result_json,created_at) + VALUES(?,?,?,?,?,?,?)`), receiptID(value), actorID, key, operation, requestHash, string(data), nowISO()) + return err +} + +func receiptID(value any) string { + if receipt, ok := value.(PolicyReceipt); ok && receipt.ReceiptID != "" { + return receipt.ReceiptID + } + if binding, ok := value.(RoleBinding); ok && binding.BindingID != "" { + return "receipt_" + binding.BindingID + } + return newID("receipt") +} + +func (s *Store) ReplaceRolePermissions(actor, roleID string, expected int64, changes []RolePermissionChange, key string) (PolicyReceipt, error) { + requestHash := policyRequestHash(struct { + RoleID string + Expected int64 + Changes []RolePermissionChange + }{roleID, expected, changes}) + var out PolicyReceipt + err := s.policyWrite(actor, "", func(tx *sql.Tx, actorAccount, _ Account, epoch int64) error { + if err := s.requireRoleAuthorityWritableTx(tx); err != nil { + return err + } + replayed, err := s.replayReceipt(tx, actorAccount.ID, key, "role.permissions.replace", requestHash, &out) + if err != nil || replayed { + return err + } + role, err := s.roleTx(tx, roleID, true) + if errors.Is(err, ErrNotFound) { + return ErrRolePolicyRoleMissing + } + if err != nil { + return err + } + if role.RoleVersion != expected { + return ErrRolePolicyVersion + } + seen := map[string]bool{} + next := map[string]RolePermissionChange{} + for _, change := range changes { + descriptor, ok := catalog.DescriptorByID(change.ActionID) + if !ok || descriptor.Protected || seen[change.ActionID] || (change.Allowed && !change.CapAllowed) { + return ErrInvalidRolePolicy + } + seen[change.ActionID] = true + next[change.ActionID] = change + } + previous, err := s.loadCapabilityAssociationTx(tx, nil, []string{roleID}) + if err != nil { + return err + } + wasCreator, isCreator := projectCreateEnabled(previous.roles[roleID]), projectCreateEnabled(next) + kept, expanded := []string{}, []string{} + rows := append([]RolePermissionChange(nil), changes...) + if wasCreator || isCreator { + for _, actionID := range catalog.ProjectCreateLinkedActions() { + if seen[actionID] { + continue + } + if _, existed := previous.roles[roleID][actionID]; existed { + kept = append(kept, actionID) + } else if isCreator { + // The cap links management to the creator's own projects only; + // the default stays off for every other member project. + rows = append(rows, RolePermissionChange{ActionID: actionID, Allowed: false, CapAllowed: true}) + expanded = append(expanded, actionID) + } + } + } + migrationNote := "" + if wasCreator && !isCreator { + migrationNote = projectCreateDisabledNote(kept) + } + deleteQuery := `DELETE FROM role_permissions WHERE role_id=?` + deleteArgs := []any{roleID} + if len(kept) > 0 { + deleteQuery += ` AND action_id NOT IN (` + strings.TrimSuffix(strings.Repeat("?,", len(kept)), ",") + `)` + for _, actionID := range kept { + deleteArgs = append(deleteArgs, actionID) + } + } + if _, err := tx.Exec(s.q(deleteQuery), deleteArgs...); err != nil { + return err + } + now := nowISO() + for _, change := range rows { + if _, err := tx.Exec(s.q(`INSERT INTO role_permissions(role_id,action_id,allowed,cap_allowed,permission_version,updated_by,updated_at) + VALUES(?,?,?,?,?,?,?)`), roleID, change.ActionID, change.Allowed, change.CapAllowed, 1, actorAccount.ID, now); err != nil { + return err + } + } + result, err := tx.Exec(s.q(`UPDATE roles SET role_version=role_version+1,updated_by=?,updated_at=? WHERE role_id=? AND role_version=?`), actorAccount.ID, now, roleID, expected) + if err != nil { + return err + } + if n, _ := result.RowsAffected(); n != 1 { + return ErrRolePolicyVersion + } + if err := s.checkCapabilityAssociationTx(tx, nil, []string{roleID}, nil); err != nil { + return err + } + if _, err := tx.Exec(s.q(`UPDATE authority_state SET authority_epoch=?,updated_at=? WHERE id=1 AND authority_epoch=?`), epoch+1, now, epoch); err != nil { + return err + } + out = PolicyReceipt{ReceiptID: newID("receipt"), AuthorityEpoch: epoch + 1, RoleVersion: expected + 1, + LinkedExpansion: expanded, MigrationNote: migrationNote} + return s.saveReceipt(tx, actorAccount.ID, key, "role.permissions.replace", requestHash, out) + }) + return out, err +} + +func (s *Store) AssignUserRole(actor, userID, roleID string, expectedState int64, key string) (RoleBinding, error) { + requestHash := policyRequestHash(struct { + UserID, RoleID string + Expected int64 + }{userID, roleID, expectedState}) + var out RoleBinding + err := s.policyWrite(actor, userID, func(tx *sql.Tx, actorAccount, target Account, epoch int64) error { + if err := requireSuperAdminTargetAccess(actorAccount, target); err != nil { + return err + } + if !actorAccount.IsSuperAdmin { + grantsAdministration, err := s.roleGrantsAccountAdministrationTx(tx, roleID) + if err != nil { + return err + } + if grantsAdministration { + return ErrAccountForbidden + } + } + if err := s.requireRoleAuthorityWritableTx(tx); err != nil { + return err + } + replayed, err := s.replayReceipt(tx, actorAccount.ID, key, "user.role.assign", requestHash, &out) + if err != nil || replayed { + return err + } + if target.StateVersion != expectedState { + return ErrConflict + } + role, err := s.roleTx(tx, roleID, true) + if err != nil || !role.Enabled { + return ErrInvalidRolePolicy + } + now := nowISO() + if _, err := tx.Exec(s.q(`UPDATE user_role_bindings SET active=?,binding_version=binding_version+1,ended_at=? WHERE user_id=? AND active=?`), false, now, userID, true); err != nil { + return err + } + bindingID := newID("binding") + if _, err := tx.Exec(s.q(`INSERT INTO user_role_bindings(binding_id,user_id,role_id,active,binding_version,assigned_by,assigned_at,ended_at) + VALUES(?,?,?,?,?,?,?,?)`), bindingID, userID, roleID, true, 1, actorAccount.ID, now, ""); err != nil { + return err + } + result, err := tx.Exec(s.q(`UPDATE users SET state_version=state_version+1 WHERE id=? AND state_version=? AND enabled=?`), userID, expectedState, true) + if err != nil { + return err + } + if n, _ := result.RowsAffected(); n != 1 { + return ErrConflict + } + if err := s.checkCapabilityAssociationTx(tx, []string{userID}, nil, nil); err != nil { + return err + } + if _, err := tx.Exec(s.q(`UPDATE authority_state SET authority_epoch=?,updated_at=? WHERE id=1 AND authority_epoch=?`), epoch+1, now, epoch); err != nil { + return err + } + assignedAt, _ := time.Parse(time.RFC3339Nano, now) + out = RoleBinding{BindingID: bindingID, UserID: userID, RoleID: roleID, Active: true, BindingVersion: 1, + AssignedBy: actorAccount.ID, AssignedAt: assignedAt, UserStateVersion: expectedState + 1, AuthorityEpoch: epoch + 1} + return s.saveReceipt(tx, actorAccount.ID, key, "user.role.assign", requestHash, out) + }) + return out, err +} + +func (s *Store) RoleForUser(userID string) (RoleBindingView, error) { + row := s.db.QueryRow(s.q(`SELECT b.binding_id,b.user_id,b.role_id,b.active,b.binding_version,b.assigned_by,b.assigned_at,b.ended_at, + r.name,r.description,r.enabled,r.role_version,r.created_by,r.updated_by,r.created_at,r.updated_at,u.state_version,a.authority_epoch + FROM user_role_bindings b JOIN roles r ON r.role_id=b.role_id JOIN users u ON u.id=b.user_id CROSS JOIN authority_state a + WHERE b.user_id=? AND b.active=? AND a.id=1`), userID, true) + var binding RoleBinding + var role RoleRecord + var assignedAt, endedAt, createdAt, updatedAt string + err := row.Scan(&binding.BindingID, &binding.UserID, &binding.RoleID, &binding.Active, &binding.BindingVersion, + &binding.AssignedBy, &assignedAt, &endedAt, &role.Name, &role.Description, &role.Enabled, &role.RoleVersion, + &role.CreatedBy, &role.UpdatedBy, &createdAt, &updatedAt, &binding.UserStateVersion, &binding.AuthorityEpoch) + if errors.Is(err, sql.ErrNoRows) { + return RoleBindingView{}, nil + } + if err != nil { + return RoleBindingView{}, err + } + role.RoleID = binding.RoleID + binding.AssignedAt, _ = time.Parse(time.RFC3339Nano, assignedAt) + if endedAt != "" { + parsed, _ := time.Parse(time.RFC3339Nano, endedAt) + binding.EndedAt = &parsed + } + role.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAt) + role.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updatedAt) + return RoleBindingView{Binding: &binding, Role: &role}, nil +} + +func (s *Store) AuthorityEpoch() (int64, error) { + var epoch int64 + err := s.db.QueryRow(`SELECT authority_epoch FROM authority_state WHERE id=1`).Scan(&epoch) + return epoch, err +} + +func (s *Store) GetRole(roleID string) (RoleRecord, error) { + return scanRole(s.db.QueryRow(s.q(`SELECT role_id,name,description,enabled,role_version,created_by,updated_by,created_at,updated_at FROM roles WHERE role_id=?`), roleID)) +} + +func (s *Store) RoleAdmin(roleID string) (RoleAdminView, error) { + role, err := s.GetRole(roleID) + if err != nil { + return RoleAdminView{}, err + } + view := RoleAdminView{RoleRecord: role} + if err := s.db.QueryRow(s.q(`SELECT COUNT(*) FROM user_role_bindings WHERE role_id=? AND active=?`), roleID, true).Scan(&view.BindingCount); err != nil { + return RoleAdminView{}, err + } + view.Permissions, err = s.RolePermissions(roleID) + return view, err +} + +func (s *Store) RolePermissions(roleID string) ([]RolePermissionRecord, error) { + rows, err := s.db.Query(s.q(`SELECT action_id,allowed,cap_allowed,permission_version FROM role_permissions WHERE role_id=? ORDER BY action_id`), roleID) + if err != nil { + return nil, err + } + defer rows.Close() + out := []RolePermissionRecord{} + for rows.Next() { + var permission RolePermissionRecord + if err := rows.Scan(&permission.ActionID, &permission.Allowed, &permission.CapAllowed, &permission.PermissionVersion); err != nil { + return nil, err + } + out = append(out, permission) + } + return out, rows.Err() +} + +func (s *Store) ListRoles(query, mode, status, actionID, cursor string, limit int) ([]RoleAdminView, string, error) { + if limit < 1 || limit > 100 { + limit = 20 + } + where := []string{"role_id>?"} + args := []any{cursor} + normalized := normalizeRoleName(query) + if normalized != "" { + switch mode { + case "exact": + where = append(where, "normalized_name=?") + args = append(args, normalized) + case "fuzzy", "": + where = append(where, "(LOWER(name) LIKE ? OR LOWER(description) LIKE ?)") + args = append(args, "%"+normalized+"%", "%"+normalized+"%") + default: + return nil, "", ErrInvalidRolePolicy + } + } + switch status { + case "enabled": + where = append(where, "enabled=?") + args = append(args, true) + case "disabled": + where = append(where, "enabled=?") + args = append(args, false) + case "", "all": + default: + return nil, "", ErrInvalidRolePolicy + } + if actionID != "" { + if _, ok := catalog.DescriptorByID(actionID); !ok { + return nil, "", ErrInvalidRolePolicy + } + where = append(where, "EXISTS(SELECT 1 FROM role_permissions p WHERE p.role_id=roles.role_id AND p.action_id=?)") + args = append(args, actionID) + } + args = append(args, limit+1) + rows, err := s.db.Query(s.q(`SELECT role_id,name,description,enabled,role_version,created_by,updated_by,created_at,updated_at, + (SELECT COUNT(*) FROM user_role_bindings b WHERE b.role_id=roles.role_id AND b.active=TRUE) + FROM roles WHERE `+strings.Join(where, " AND ")+` ORDER BY role_id LIMIT ?`), args...) + if err != nil { + return nil, "", err + } + defer rows.Close() + out := []RoleAdminView{} + for rows.Next() { + var view RoleAdminView + var createdAt, updatedAt string + if err := rows.Scan(&view.RoleID, &view.Name, &view.Description, &view.Enabled, &view.RoleVersion, + &view.CreatedBy, &view.UpdatedBy, &createdAt, &updatedAt, &view.BindingCount); err != nil { + return nil, "", err + } + view.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAt) + view.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updatedAt) + out = append(out, view) + } + if err := rows.Err(); err != nil { + return nil, "", err + } + next := "" + if len(out) > limit { + next = out[limit-1].RoleID + out = out[:limit] + } + for i := range out { + permissions, err := s.RolePermissions(out[i].RoleID) + if err != nil { + return nil, "", err + } + out[i].Permissions = permissions + } + return out, next, nil +} + +func (s *Store) SystemGrantsForUser(userID string) ([]string, int64, error) { + if _, err := s.GetAccount(userID); err != nil { + return nil, 0, err + } + rows, err := s.db.Query(s.q(`SELECT action_id FROM system_grants WHERE user_id=? AND allowed=? ORDER BY action_id`), userID, true) + if err != nil { + return nil, 0, err + } + defer rows.Close() + actions := []string{} + for rows.Next() { + var actionID string + if err := rows.Scan(&actionID); err != nil { + return nil, 0, err + } + actions = append(actions, actionID) + } + epoch, err := s.AuthorityEpoch() + return actions, epoch, err +} + +func (s *Store) SystemGrantAllowed(owner, actionID string) (bool, error) { + var allowed bool + err := s.db.QueryRow(s.q(`SELECT g.allowed FROM system_grants g JOIN users u ON u.id=g.user_id + WHERE u.owner=? AND u.enabled=? AND g.action_id=?`), owner, true, actionID).Scan(&allowed) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + return allowed, err +} + +// requireSystemActionTx rechecks delegated system authority in the same +// transaction that consumes it. Callers lock the actor account first, keeping +// the account-before-policy lock order used by policyWrite. +func (s *Store) requireSystemActionTx(tx *sql.Tx, actor Account, actionID string) error { + descriptor, ok := catalog.DescriptorByID(actionID) + if !ok || descriptor.Scope != catalog.ScopeSystem || !actor.Enabled { + return ErrAccountForbidden + } + query := `SELECT authority_epoch FROM authority_state WHERE id=1` + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + var epoch int64 + if err := tx.QueryRow(query).Scan(&epoch); err != nil { + return err + } + if actor.IsAdmin { + return nil + } + var allowed bool + err := tx.QueryRow(s.q(`SELECT allowed FROM system_grants WHERE user_id=? AND action_id=?`), actor.ID, actionID).Scan(&allowed) + if errors.Is(err, sql.ErrNoRows) || (err == nil && !allowed) { + return ErrAccountForbidden + } + return err +} + +func (s *Store) RolePermissionAllowed(owner, actionID string) (bool, bool, error) { + var allowed, capAllowed bool + err := s.db.QueryRow(s.q(`SELECT p.allowed,p.cap_allowed FROM users u + JOIN user_role_bindings b ON b.user_id=u.id AND b.active=TRUE + JOIN roles r ON r.role_id=b.role_id AND r.enabled=TRUE + JOIN role_permissions p ON p.role_id=r.role_id AND p.action_id=? + WHERE u.owner=? AND u.enabled=TRUE`), actionID, owner).Scan(&allowed, &capAllowed) + if errors.Is(err, sql.ErrNoRows) { + return false, false, nil + } + return allowed, capAllowed, err +} + +// roleGrantsAccountAdministrationTx reports whether a role allows any +// account-administration action. Catalog validation already keeps protected +// actions out of roles; this keeps role assignment from becoming an elevation +// path even if that invariant is ever broken. +func (s *Store) roleGrantsAccountAdministrationTx(tx *sql.Tx, roleID string) (bool, error) { + rows, err := tx.Query(s.q(`SELECT action_id FROM role_permissions WHERE role_id=? AND allowed=?`), roleID, true) + if err != nil { + return false, err + } + defer rows.Close() + for rows.Next() { + var actionID string + if err := rows.Scan(&actionID); err != nil { + return false, err + } + if IsAccountAdministrationAction(actionID) { + return true, nil + } + } + return false, rows.Err() +} + +// changesAccountAdministrationGrantsTx reports whether replacing userID's +// explicit grants with actions would add or remove any account-administration +// action. Only the super administrator may make such a change (R4, R5). +func (s *Store) changesAccountAdministrationGrantsTx(tx *sql.Tx, userID string, actions []string) (bool, error) { + current := map[string]bool{} + rows, err := tx.Query(s.q(`SELECT action_id FROM system_grants WHERE user_id=? AND allowed=?`), userID, true) + if err != nil { + return false, err + } + for rows.Next() { + var actionID string + if err := rows.Scan(&actionID); err != nil { + rows.Close() + return false, err + } + if IsAccountAdministrationAction(actionID) { + current[actionID] = true + } + } + if err := rows.Close(); err != nil { + return false, err + } + if err := rows.Err(); err != nil { + return false, err + } + requested := map[string]bool{} + for _, actionID := range actions { + if IsAccountAdministrationAction(actionID) { + requested[actionID] = true + } + } + if len(current) != len(requested) { + return true, nil + } + for actionID := range requested { + if !current[actionID] { + return true, nil + } + } + return false, nil +} + +func (s *Store) ReplaceSystemGrants(actor, userID string, expectedEpoch int64, actions []string, key string) (PolicyReceipt, error) { + requestHash := policyRequestHash(struct { + UserID string + Epoch int64 + Actions []string + }{userID, expectedEpoch, actions}) + var out PolicyReceipt + err := s.policyWrite(actor, userID, func(tx *sql.Tx, actorAccount, target Account, epoch int64) error { + if err := requireSuperAdminTargetAccess(actorAccount, target); err != nil { + return err + } + if !actorAccount.IsSuperAdmin { + changes, err := s.changesAccountAdministrationGrantsTx(tx, target.ID, actions) + if err != nil { + return err + } + if changes { + return ErrAccountForbidden + } + } + replayed, err := s.replayReceipt(tx, actorAccount.ID, key, "system.grants.replace", requestHash, &out) + if err != nil || replayed { + return err + } + if epoch != expectedEpoch { + return ErrConflict + } + seen := map[string]bool{} + for _, actionID := range actions { + descriptor, ok := catalog.DescriptorByID(actionID) + if !ok || descriptor.Scope != catalog.ScopeSystem || seen[actionID] { + return ErrInvalidRolePolicy + } + seen[actionID] = true + } + previous, err := s.loadCapabilityAssociationTx(tx, []string{userID}, nil) + if err != nil { + return err + } + migrationNote := "" + if previous.creators[userID] && !seen[catalog.ProjectCreateAction] { + kept := []string{} + if roleID, bound := previous.bindings[userID]; bound { + for _, actionID := range catalog.ProjectCreateLinkedActions() { + if _, exists := previous.roles[roleID][actionID]; exists { + kept = append(kept, actionID) + } + } + } + migrationNote = projectCreateDisabledNote(kept) + } + if _, err := tx.Exec(s.q(`DELETE FROM system_grants WHERE user_id=?`), userID); err != nil { + return err + } + now := nowISO() + for _, actionID := range actions { + if _, err := tx.Exec(s.q(`INSERT INTO system_grants(user_id,action_id,allowed,grant_version,updated_by,updated_at) + VALUES(?,?,?,?,?,?)`), userID, actionID, true, 1, actorAccount.ID, now); err != nil { + return err + } + } + var managers int + if err := tx.QueryRow(s.q(`SELECT COUNT(DISTINCT g.user_id) FROM system_grants g JOIN users u ON u.id=g.user_id + WHERE g.action_id=? AND g.allowed=? AND u.enabled=?`), "system.permissions.manage", true, true).Scan(&managers); err != nil { + return err + } + if managers == 0 { + return ErrLastPermissionManager + } + if err := s.checkCapabilityAssociationTx(tx, []string{userID}, nil, nil); err != nil { + return err + } + if _, err := tx.Exec(s.q(`UPDATE authority_state SET authority_epoch=?,updated_at=? WHERE id=1 AND authority_epoch=?`), epoch+1, now, epoch); err != nil { + return err + } + out = PolicyReceipt{ReceiptID: newID("receipt"), AuthorityEpoch: epoch + 1, MigrationNote: migrationNote} + return s.saveReceipt(tx, actorAccount.ID, key, "system.grants.replace", requestHash, out) + }) + return out, err +} + +// ImportRoleAuthority stages a validated, invisible role projection. Live role +// authorization remains unchanged until ActivateRoleAuthorityImport commits it. +func (s *Store) ImportRoleAuthority(actor string, in RoleAuthorityImport, key string) (RoleAuthorityImportResult, error) { + if actor == "" || key == "" || in.SourceSchemaVersion < 1 || len(in.SourceSHA256) != 64 { + return RoleAuthorityImportResult{}, ErrInvalidRolePolicy + } + roles := append([]RoleAuthorityImportRole(nil), in.Roles...) + sort.Slice(roles, func(i, j int) bool { return roles[i].RoleID < roles[j].RoleID }) + roleIDs, names, boundOwners := map[string]bool{}, map[string]bool{}, map[string]bool{} + owners := []string{actor} + for i := range roles { + role := &roles[i] + role.RoleID = strings.TrimSpace(role.RoleID) + var err error + role.Name, role.Description, err = validateRoleText(role.Name, role.Description) + if err != nil || role.RoleID == "" || role.RoleVersion < 1 || roleIDs[role.RoleID] || names[normalizeRoleName(role.Name)] { + return RoleAuthorityImportResult{}, ErrInvalidRolePolicy + } + roleIDs[role.RoleID], names[normalizeRoleName(role.Name)] = true, true + seenActions := map[string]bool{} + for _, permission := range role.Permissions { + descriptor, ok := catalog.DescriptorByID(permission.ActionID) + if !ok || descriptor.Protected || seenActions[permission.ActionID] || (permission.Allowed && !permission.CapAllowed) { + return RoleAuthorityImportResult{}, ErrInvalidRolePolicy + } + seenActions[permission.ActionID] = true + } + sort.Strings(role.Owners) + for _, owner := range role.Owners { + if owner == "" || boundOwners[owner] { + return RoleAuthorityImportResult{}, ErrInvalidRolePolicy + } + boundOwners[owner] = true + owners = append(owners, owner) + } + } + normalized := RoleAuthorityImport{SourceSchemaVersion: in.SourceSchemaVersion, SourceSHA256: in.SourceSHA256, Roles: roles} + payload, err := json.Marshal(normalized) + if err != nil { + return RoleAuthorityImportResult{}, err + } + requestHash := policyRequestHash(normalized) + tx, err := s.db.Begin() + if err != nil { + return RoleAuthorityImportResult{}, err + } + defer tx.Rollback() + accounts, err := s.lockAccountUsers(tx, owners...) + if err != nil { + return RoleAuthorityImportResult{}, err + } + actorAccount := accounts[actor] + if err := s.requireSystemActionTx(tx, actorAccount, "system.permissions.manage"); err != nil { + return RoleAuthorityImportResult{}, err + } + var receipt PolicyReceipt + replayed, err := s.replayReceipt(tx, actorAccount.ID, key, "role.authority.import", requestHash, &receipt) + if err != nil { + return RoleAuthorityImportResult{}, err + } + if replayed { + return RoleAuthorityImportResult{AuthorityEpoch: receipt.AuthorityEpoch, ImportedRoles: len(roles), ImportedUsers: len(boundOwners)}, nil + } + var roleCount, bindingCount int + if err := tx.QueryRow(`SELECT COUNT(*) FROM roles`).Scan(&roleCount); err != nil { + return RoleAuthorityImportResult{}, err + } + if err := tx.QueryRow(`SELECT COUNT(*) FROM user_role_bindings WHERE active=TRUE`).Scan(&bindingCount); err != nil { + return RoleAuthorityImportResult{}, err + } + if roleCount != 0 || bindingCount != 0 { + return RoleAuthorityImportResult{}, ErrConflict + } + pending := capabilityAssociation{roles: map[string]map[string]RolePermissionChange{}, bindings: map[string]string{}} + boundUsers := []string{} + for _, role := range roles { + permissions := map[string]RolePermissionChange{} + for _, permission := range role.Permissions { + permissions[permission.ActionID] = permission + } + pending.roles[role.RoleID] = permissions + for _, owner := range role.Owners { + pending.bindings[accounts[owner].ID] = role.RoleID + boundUsers = append(boundUsers, accounts[owner].ID) + } + } + if err := s.checkCapabilityAssociationTx(tx, boundUsers, nil, &pending); err != nil { + return RoleAuthorityImportResult{}, err + } + var epoch int64 + query := `SELECT authority_epoch FROM authority_state WHERE id=1` + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + if err := tx.QueryRow(query).Scan(&epoch); err != nil { + return RoleAuthorityImportResult{}, err + } + stateQuery := `SELECT source_sha256,state,payload_json FROM role_authority_import_state WHERE id=1` + if s.Dialect() == "postgres" { + stateQuery += ` FOR UPDATE` + } + var sourceSHA, state, storedPayload string + if err := tx.QueryRow(stateQuery).Scan(&sourceSHA, &state, &storedPayload); err != nil { + return RoleAuthorityImportResult{}, err + } + if state != "idle" { + if sourceSHA != in.SourceSHA256 || storedPayload != string(payload) { + return RoleAuthorityImportResult{}, ErrConflict + } + } else if _, err := tx.Exec(s.q(`UPDATE role_authority_import_state SET source_sha256=?,state='staged',payload_json=?,staged_by=?,updated_at=? WHERE id=1 AND state='idle'`), + in.SourceSHA256, string(payload), actorAccount.ID, nowISO()); err != nil { + return RoleAuthorityImportResult{}, err + } + receipt = PolicyReceipt{ReceiptID: newID("receipt"), AuthorityEpoch: epoch} + if err := s.saveReceipt(tx, actorAccount.ID, key, "role.authority.import", requestHash, receipt); err != nil { + return RoleAuthorityImportResult{}, err + } + if err := tx.Commit(); err != nil { + return RoleAuthorityImportResult{}, err + } + return RoleAuthorityImportResult{AuthorityEpoch: epoch, ImportedRoles: len(roles), ImportedUsers: len(boundOwners)}, nil +} + +func (s *Store) ReadRoleAuthorityImport(actor, sourceSHA string) (RoleAuthorityImport, error) { + tx, err := s.db.Begin() + if err != nil { + return RoleAuthorityImport{}, err + } + defer tx.Rollback() + accounts, err := s.lockAccountUsers(tx, actor) + if err != nil { + return RoleAuthorityImport{}, err + } + if err := s.requireSystemActionTx(tx, accounts[actor], "system.permissions.manage"); err != nil { + return RoleAuthorityImport{}, err + } + var storedSHA, state, payload string + if err := tx.QueryRow(`SELECT source_sha256,state,payload_json FROM role_authority_import_state WHERE id=1`).Scan(&storedSHA, &state, &payload); err != nil { + return RoleAuthorityImport{}, err + } + if storedSHA != sourceSHA || (state != "staged" && state != "active") { + return RoleAuthorityImport{}, ErrConflict + } + var out RoleAuthorityImport + if err := json.Unmarshal([]byte(payload), &out); err != nil { + return RoleAuthorityImport{}, err + } + if err := tx.Commit(); err != nil { + return RoleAuthorityImport{}, err + } + return out, nil +} + +func (s *Store) ActivateRoleAuthorityImport(actor, sourceSHA string, expectedEpoch int64) (RoleAuthorityImportResult, error) { + staged, err := s.ReadRoleAuthorityImport(actor, sourceSHA) + if err != nil { + return RoleAuthorityImportResult{}, err + } + owners := []string{actor} + for _, role := range staged.Roles { + owners = append(owners, role.Owners...) + } + tx, err := s.db.Begin() + if err != nil { + return RoleAuthorityImportResult{}, err + } + defer tx.Rollback() + accounts, err := s.lockAccountUsers(tx, owners...) + if err != nil { + return RoleAuthorityImportResult{}, err + } + actorAccount := accounts[actor] + if err := s.requireSystemActionTx(tx, actorAccount, "system.permissions.manage"); err != nil { + return RoleAuthorityImportResult{}, err + } + var epoch int64 + query := `SELECT authority_epoch FROM authority_state WHERE id=1` + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + if err := tx.QueryRow(query).Scan(&epoch); err != nil { + return RoleAuthorityImportResult{}, err + } + stateQuery := `SELECT source_sha256,state FROM role_authority_import_state WHERE id=1` + if s.Dialect() == "postgres" { + stateQuery += ` FOR UPDATE` + } + var storedSHA, state string + if err := tx.QueryRow(stateQuery).Scan(&storedSHA, &state); err != nil { + return RoleAuthorityImportResult{}, err + } + if storedSHA != sourceSHA { + return RoleAuthorityImportResult{}, ErrConflict + } + if state == "active" { + return RoleAuthorityImportResult{AuthorityEpoch: epoch, ImportedRoles: len(staged.Roles), ImportedUsers: len(owners) - 1}, nil + } + if epoch != expectedEpoch { + return RoleAuthorityImportResult{}, ErrConflict + } + if state != "staged" { + return RoleAuthorityImportResult{}, ErrConflict + } + var roleCount, bindingCount int + if err := tx.QueryRow(`SELECT COUNT(*) FROM roles`).Scan(&roleCount); err != nil { + return RoleAuthorityImportResult{}, err + } + if err := tx.QueryRow(`SELECT COUNT(*) FROM user_role_bindings WHERE active=TRUE`).Scan(&bindingCount); err != nil { + return RoleAuthorityImportResult{}, err + } + if roleCount != 0 || bindingCount != 0 { + return RoleAuthorityImportResult{}, ErrConflict + } + now := nowISO() + importedUsers := 0 + for _, role := range staged.Roles { + if _, err := tx.Exec(s.q(`INSERT INTO roles(role_id,name,normalized_name,description,enabled,role_version,created_by,updated_by,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?)`), role.RoleID, role.Name, normalizeRoleName(role.Name), role.Description, role.Enabled, + role.RoleVersion, actorAccount.ID, actorAccount.ID, now, now); err != nil { + return RoleAuthorityImportResult{}, err + } + for _, permission := range role.Permissions { + if _, err := tx.Exec(s.q(`INSERT INTO role_permissions(role_id,action_id,allowed,cap_allowed,permission_version,updated_by,updated_at) + VALUES(?,?,?,?,?,?,?)`), role.RoleID, permission.ActionID, permission.Allowed, permission.CapAllowed, 1, actorAccount.ID, now); err != nil { + return RoleAuthorityImportResult{}, err + } + } + for _, owner := range role.Owners { + target := accounts[owner] + if _, err := tx.Exec(s.q(`INSERT INTO user_role_bindings(binding_id,user_id,role_id,active,binding_version,assigned_by,assigned_at,ended_at) + VALUES(?,?,?,?,?,?,?,?)`), newID("binding"), target.ID, role.RoleID, true, 1, actorAccount.ID, now, ""); err != nil { + return RoleAuthorityImportResult{}, err + } + result, err := tx.Exec(s.q(`UPDATE users SET state_version=state_version+1 WHERE id=? AND state_version=?`), target.ID, target.StateVersion) + if err != nil { + return RoleAuthorityImportResult{}, err + } + if n, err := result.RowsAffected(); err != nil || n != 1 { + if err != nil { + return RoleAuthorityImportResult{}, err + } + return RoleAuthorityImportResult{}, ErrConflict + } + importedUsers++ + } + } + importedRoleIDs := make([]string, 0, len(staged.Roles)) + for _, role := range staged.Roles { + importedRoleIDs = append(importedRoleIDs, role.RoleID) + } + if err := s.checkCapabilityAssociationTx(tx, nil, importedRoleIDs, nil); err != nil { + return RoleAuthorityImportResult{}, err + } + if _, err := tx.Exec(s.q(`UPDATE authority_state SET authority_epoch=?,updated_at=? WHERE id=1 AND authority_epoch=?`), epoch+1, now, epoch); err != nil { + return RoleAuthorityImportResult{}, err + } + stateResult, err := tx.Exec(s.q(`UPDATE role_authority_import_state SET state='active',updated_at=? WHERE id=1 AND source_sha256=? AND state='staged'`), now, sourceSHA) + if err != nil { + return RoleAuthorityImportResult{}, err + } + if n, err := stateResult.RowsAffected(); err != nil || n != 1 { + if err != nil { + return RoleAuthorityImportResult{}, err + } + return RoleAuthorityImportResult{}, ErrConflict + } + if err := tx.Commit(); err != nil { + return RoleAuthorityImportResult{}, err + } + return RoleAuthorityImportResult{AuthorityEpoch: epoch + 1, ImportedRoles: len(staged.Roles), ImportedUsers: importedUsers}, nil +} + +// capabilityAssociation is the slice of authority that decides whether +// project.create and its linked own-project management actions agree. +type capabilityAssociation struct { + roles map[string]map[string]RolePermissionChange // roleID -> actionID -> bits + bindings map[string]string // userID -> active roleID + creators map[string]bool // userID -> project.create system grant +} + +func projectCreateEnabled(permissions map[string]RolePermissionChange) bool { + permission, ok := permissions[catalog.ProjectCreateAction] + return ok && permission.Allowed && permission.CapAllowed +} + +// linkedActionDenied reports an explicit hard deny: the row caps the action +// off. An absent row is unconfigured and derived from project.create; a row +// with allowed=false under an open cap still serves the creator's own projects. +func linkedActionDenied(permissions map[string]RolePermissionChange, actionID string) bool { + permission, ok := permissions[actionID] + return ok && !permission.CapAllowed +} + +func deniedLinkedAction(permissions map[string]RolePermissionChange) string { + for _, actionID := range catalog.ProjectCreateLinkedActions() { + if linkedActionDenied(permissions, actionID) { + return actionID + } + } + return "" +} + +func projectCreateDisabledNote(kept []string) string { + if len(kept) == 0 { + return "project.create disabled: no linked capability rows existed; nothing was deleted" + } + return "project.create disabled: kept linked capability rows " + strings.Join(kept, ",") + + "; remove them explicitly to revoke own-project management" +} + +func associationActionArgs() []any { + args := []any{catalog.ProjectCreateAction} + for _, actionID := range catalog.ProjectCreateLinkedActions() { + args = append(args, actionID) + } + return args +} + +// loadCapabilityAssociationTx reads the users' project.create grants and active +// roles plus, for each role, its bound project.create grant holders and the +// role rows for project.create and the linked actions. +func (s *Store) loadCapabilityAssociationTx(tx *sql.Tx, userIDs, roleIDs []string) (capabilityAssociation, error) { + state := capabilityAssociation{roles: map[string]map[string]RolePermissionChange{}, + bindings: map[string]string{}, creators: map[string]bool{}} + roleIDs = append([]string(nil), roleIDs...) + for _, userID := range userIDs { + if _, loaded := state.creators[userID]; loaded { + continue + } + var allowed bool + err := tx.QueryRow(s.q(`SELECT allowed FROM system_grants WHERE user_id=? AND action_id=?`), userID, catalog.ProjectCreateAction).Scan(&allowed) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return state, err + } + state.creators[userID] = allowed + var roleID string + err = tx.QueryRow(s.q(`SELECT role_id FROM user_role_bindings WHERE user_id=? AND active=?`), userID, true).Scan(&roleID) + switch { + case err == nil: + state.bindings[userID] = roleID + roleIDs = append(roleIDs, roleID) + case !errors.Is(err, sql.ErrNoRows): + return state, err + } + } + actionArgs := associationActionArgs() + placeholders := strings.TrimSuffix(strings.Repeat("?,", len(actionArgs)), ",") + for _, roleID := range roleIDs { + if _, loaded := state.roles[roleID]; loaded { + continue + } + permissions := map[string]RolePermissionChange{} + rows, err := tx.Query(s.q(`SELECT action_id,allowed,cap_allowed FROM role_permissions WHERE role_id=? AND action_id IN (`+placeholders+`)`), + append([]any{roleID}, actionArgs...)...) + if err != nil { + return state, err + } + for rows.Next() { + var permission RolePermissionChange + if err := rows.Scan(&permission.ActionID, &permission.Allowed, &permission.CapAllowed); err != nil { + rows.Close() + return state, err + } + permissions[permission.ActionID] = permission + } + if err := rows.Close(); err != nil { + return state, err + } + state.roles[roleID] = permissions + holders, err := tx.Query(s.q(`SELECT b.user_id FROM user_role_bindings b + JOIN system_grants g ON g.user_id=b.user_id AND g.action_id=? AND g.allowed=? + WHERE b.role_id=? AND b.active=?`), catalog.ProjectCreateAction, true, roleID, true) + if err != nil { + return state, err + } + for holders.Next() { + var userID string + if err := holders.Scan(&userID); err != nil { + holders.Close() + return state, err + } + state.creators[userID] = true + state.bindings[userID] = roleID + } + if err := holders.Close(); err != nil { + return state, err + } + } + return state, nil +} + +// checkCapabilityAssociationTx is the single in-transaction gate shared by all +// role authority writers. It rejects any role that enables project.create while +// denying a linked action, and any project.create grant holder whose active +// role denies one. pending overlays rows staged but not yet written. +func (s *Store) checkCapabilityAssociationTx(tx *sql.Tx, userIDs, roleIDs []string, pending *capabilityAssociation) error { + state, err := s.loadCapabilityAssociationTx(tx, userIDs, roleIDs) + if err != nil { + return err + } + if pending != nil { + for roleID, permissions := range pending.roles { + state.roles[roleID] = permissions + } + for userID, roleID := range pending.bindings { + state.bindings[userID] = roleID + } + for userID, allowed := range pending.creators { + state.creators[userID] = allowed + } + } + roleIDs = roleIDs[:0:0] + for roleID := range state.roles { + roleIDs = append(roleIDs, roleID) + } + sort.Strings(roleIDs) + for _, roleID := range roleIDs { + permissions := state.roles[roleID] + if denied := deniedLinkedAction(permissions); denied != "" && projectCreateEnabled(permissions) { + return fmt.Errorf("%w: role %s enables project.create but denies %s", ErrInvalidRolePolicy, roleID, denied) + } + } + userIDs = userIDs[:0:0] + for userID, creator := range state.creators { + if creator { + userIDs = append(userIDs, userID) + } + } + sort.Strings(userIDs) + for _, userID := range userIDs { + roleID, bound := state.bindings[userID] + if !bound { + continue + } + if denied := deniedLinkedAction(state.roles[roleID]); denied != "" { + return fmt.Errorf("%w: project.create grant holder %s has role %s denying %s", ErrInvalidRolePolicy, userID, roleID, denied) + } + } + return nil +} + +// LinkedCapabilities computes, from one authority snapshot, whether the actor +// holds project.create and which own-project management actions it links. +// An explicit deny on the actor's enabled role still wins over the link. +func (s *Store) LinkedCapabilities(owner string) (CapabilityAssociation, error) { + out := CapabilityAssociation{LinkedActions: []string{}} + tx, err := s.db.Begin() + if err != nil { + return out, err + } + defer tx.Rollback() + if err := tx.QueryRow(`SELECT authority_epoch FROM authority_state WHERE id=1`).Scan(&out.AuthorityEpoch); err != nil { + return out, err + } + var userID string + var enabled, isAdmin bool + err = tx.QueryRow(s.q(`SELECT id,enabled,is_admin FROM users WHERE owner=?`), owner).Scan(&userID, &enabled, &isAdmin) + if errors.Is(err, sql.ErrNoRows) { + return out, ErrNotFound + } + if err != nil || !enabled { + return out, err + } + state, err := s.loadCapabilityAssociationTx(tx, []string{userID}, nil) + if err != nil { + return out, err + } + var rolePermissions map[string]RolePermissionChange + if roleID, bound := state.bindings[userID]; bound { + role, err := s.roleTx(tx, roleID, false) + if err != nil { + return out, err + } + if role.Enabled { + rolePermissions = state.roles[roleID] + } + } + switch { + case isAdmin: + out.Source = "bootstrap-admin" + case state.creators[userID]: + out.Source = "system-grant" + case projectCreateEnabled(rolePermissions): + out.Source = "role" + default: + return out, tx.Commit() + } + out.ProjectCreate = true + for _, actionID := range catalog.ProjectCreateLinkedActions() { + if linkedActionDenied(rolePermissions, actionID) { + out.Conflicts = append(out.Conflicts, actionID) + continue + } + out.LinkedActions = append(out.LinkedActions, actionID) + } + return out, tx.Commit() +} diff --git a/internal/store/role_policy_migration_test.go b/internal/store/role_policy_migration_test.go new file mode 100644 index 0000000..73a5c96 --- /dev/null +++ b/internal/store/role_policy_migration_test.go @@ -0,0 +1,122 @@ +package store + +import ( + "strings" + "testing" +) + +func TestRolePolicyMigrationSupportsOneRoleForManyUsers(t *testing.T) { + st := openTestStore(t) + if err := st.CreateAccount("alice", "Alice", "hash", false); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("bob", "Bob", "hash", false); err != nil { + t.Fatal(err) + } + var aliceID, bobID string + if err := st.db.QueryRow(`SELECT id FROM users WHERE owner='alice'`).Scan(&aliceID); err != nil { + t.Fatal(err) + } + if err := st.db.QueryRow(`SELECT id FROM users WHERE owner='bob'`).Scan(&bobID); err != nil { + t.Fatal(err) + } + now := nowISO() + if _, err := st.db.Exec(st.q(`INSERT INTO roles(role_id,name,normalized_name,description,enabled,role_version,created_by,updated_by,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?)`), "role_engineer", "软件工程师", "软件工程师", "负责软件研发", true, 1, aliceID, aliceID, now, now); err != nil { + t.Fatal(err) + } + for _, binding := range []struct{ id, userID string }{{"bind_alice", aliceID}, {"bind_bob", bobID}} { + if _, err := st.db.Exec(st.q(`INSERT INTO user_role_bindings(binding_id,user_id,role_id,active,binding_version,assigned_by,assigned_at,ended_at) + VALUES(?,?,?,?,?,?,?,?)`), binding.id, binding.userID, "role_engineer", true, 1, aliceID, now, ""); err != nil { + t.Fatalf("assign shared role to %s: %v", binding.userID, err) + } + } + var count int + if err := st.db.QueryRow(`SELECT COUNT(*) FROM user_role_bindings WHERE role_id='role_engineer' AND active=TRUE`).Scan(&count); err != nil || count != 2 { + t.Fatalf("active role bindings=%d err=%v", count, err) + } +} + +func TestRolePolicyMigrationRejectsTwoActiveRolesForOneUser(t *testing.T) { + st := openTestStore(t) + if err := st.CreateAccount("alice", "Alice", "hash", false); err != nil { + t.Fatal(err) + } + var userID string + if err := st.db.QueryRow(`SELECT id FROM users WHERE owner='alice'`).Scan(&userID); err != nil { + t.Fatal(err) + } + now := nowISO() + for _, roleID := range []string{"role_engineer", "role_reviewer"} { + if _, err := st.db.Exec(st.q(`INSERT INTO roles(role_id,name,normalized_name,description,enabled,role_version,created_by,updated_by,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?)`), roleID, roleID, roleID, "", true, 1, userID, userID, now, now); err != nil { + t.Fatal(err) + } + } + if _, err := st.db.Exec(st.q(`INSERT INTO user_role_bindings(binding_id,user_id,role_id,active,binding_version,assigned_by,assigned_at,ended_at) + VALUES(?,?,?,?,?,?,?,?)`), "bind_1", userID, "role_engineer", true, 1, userID, now, ""); err != nil { + t.Fatal(err) + } + _, err := st.db.Exec(st.q(`INSERT INTO user_role_bindings(binding_id,user_id,role_id,active,binding_version,assigned_by,assigned_at,ended_at) + VALUES(?,?,?,?,?,?,?,?)`), "bind_2", userID, "role_reviewer", true, 1, userID, now, "") + if err == nil || !strings.Contains(strings.ToLower(err.Error()), "unique") { + t.Fatalf("second active role must hit unique constraint: %v", err) + } +} + +func TestRolePolicyMigrationCanRollbackAndReapply(t *testing.T) { + st := openTestStore(t) + if err := st.MigrateDown(48); err != nil { + t.Fatal(err) + } + if _, err := st.db.Exec(`SELECT role_id FROM roles LIMIT 1`); err == nil { + t.Fatal("roles table remained after v49 rollback") + } + if err := st.migrate(); err != nil { + t.Fatal(err) + } + if _, err := st.db.Exec(`SELECT role_id FROM roles LIMIT 1`); err != nil { + t.Fatalf("roles table missing after reapply: %v", err) + } +} + +func TestRoleAuthorityImportStageUpgradesExistingV49(t *testing.T) { + st := openTestStore(t) + if err := st.MigrateDown(49); err != nil { + t.Fatal(err) + } + if _, err := st.db.Exec(`SELECT role_id FROM roles LIMIT 1`); err != nil { + t.Fatalf("v49 role authority was removed: %v", err) + } + if _, err := st.db.Exec(`SELECT state FROM role_authority_import_state WHERE id=1`); err == nil { + t.Fatal("v50 staging table remained after rollback to v49") + } + if err := st.migrate(); err != nil { + t.Fatal(err) + } + var state string + if err := st.db.QueryRow(`SELECT state FROM role_authority_import_state WHERE id=1`).Scan(&state); err != nil || state != "idle" { + t.Fatalf("v49 to v50 state=%q err=%v", state, err) + } +} + +func TestRolePolicyMigrationSeedsLegacyAdministratorGrants(t *testing.T) { + st := openTestStore(t) + if err := st.MigrateDown(48); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("admin", "Admin", "hash", true); err != nil { + t.Fatal(err) + } + if err := st.migrate(); err != nil { + t.Fatal(err) + } + var grants int + if err := st.db.QueryRow(`SELECT COUNT(*) FROM system_grants g JOIN users u ON u.id=g.user_id + WHERE u.owner='admin' AND g.allowed=TRUE`).Scan(&grants); err != nil { + t.Fatal(err) + } + if grants != 12 { + t.Fatalf("legacy administrator grants=%d", grants) + } +} diff --git a/internal/store/role_policy_test.go b/internal/store/role_policy_test.go new file mode 100644 index 0000000..e4311f7 --- /dev/null +++ b/internal/store/role_policy_test.go @@ -0,0 +1,547 @@ +package store + +import ( + "errors" + "strings" + "sync" + "testing" +) + +func rolePolicyAccounts(t *testing.T, st *Store) (Account, Account, Account) { + t.Helper() + if err := st.CreateAccount("admin", "Admin", "hash", true); err != nil { + t.Fatal(err) + } + markSuperAdmin(t, st, "admin") + if err := st.CreateAccount("alice", "Alice", "hash", false); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("bob", "Bob", "hash", false); err != nil { + t.Fatal(err) + } + admin, _ := st.GetAccountByOwner("admin") + alice, _ := st.GetAccountByOwner("alice") + bob, _ := st.GetAccountByOwner("bob") + return admin, alice, bob +} + +func TestAssignUserRoleKeepsOneEffectiveBinding(t *testing.T) { + st := openTestStore(t) + _, alice, bob := rolePolicyAccounts(t, st) + engineer, err := st.CreateRole("admin", RoleCreate{Name: "软件工程师", Description: "软件研发"}) + if err != nil { + t.Fatal(err) + } + reviewer, err := st.CreateRole("admin", RoleCreate{Name: "审核工程师"}) + if err != nil { + t.Fatal(err) + } + first, err := st.AssignUserRole("admin", alice.ID, engineer.RoleID, alice.StateVersion, "assign-1") + if err != nil { + t.Fatal(err) + } + if _, err := st.AssignUserRole("admin", bob.ID, engineer.RoleID, bob.StateVersion, "assign-2"); err != nil { + t.Fatal(err) + } + second, err := st.AssignUserRole("admin", alice.ID, reviewer.RoleID, first.UserStateVersion, "assign-3") + if err != nil { + t.Fatal(err) + } + if second.RoleID != reviewer.RoleID { + t.Fatalf("binding=%+v", second) + } + var active, engineerUsers int + if err := st.db.QueryRow(st.q(`SELECT COUNT(*) FROM user_role_bindings WHERE user_id=? AND active=?`), alice.ID, true).Scan(&active); err != nil { + t.Fatal(err) + } + if err := st.db.QueryRow(st.q(`SELECT COUNT(*) FROM user_role_bindings WHERE role_id=? AND active=?`), engineer.RoleID, true).Scan(&engineerUsers); err != nil { + t.Fatal(err) + } + if active != 1 || engineerUsers != 1 { + t.Fatalf("alice active=%d engineer users=%d", active, engineerUsers) + } +} + +func TestAssignUserRoleConcurrentExpectedVersionAllowsOne(t *testing.T) { + st := openTestStore(t) + _, alice, _ := rolePolicyAccounts(t, st) + first, _ := st.CreateRole("admin", RoleCreate{Name: "职位一"}) + second, _ := st.CreateRole("admin", RoleCreate{Name: "职位二"}) + start := make(chan struct{}) + errs := make(chan error, 2) + var wg sync.WaitGroup + for i, roleID := range []string{first.RoleID, second.RoleID} { + wg.Add(1) + go func(index int, id string) { + defer wg.Done() + <-start + _, err := st.AssignUserRole("admin", alice.ID, id, alice.StateVersion, "race-"+string(rune('a'+index))) + errs <- err + }(i, roleID) + } + close(start) + wg.Wait() + close(errs) + var passed, conflicted int + for err := range errs { + switch { + case err == nil: + passed++ + case errors.Is(err, ErrConflict): + conflicted++ + default: + t.Fatalf("unexpected error: %v", err) + } + } + if passed != 1 || conflicted != 1 { + t.Fatalf("passed=%d conflicted=%d", passed, conflicted) + } +} + +func TestReplaceRolePermissionsIsAtomicAndIdempotent(t *testing.T) { + st := openTestStore(t) + rolePolicyAccounts(t, st) + role, err := st.CreateRole("admin", RoleCreate{Name: "软件工程师"}) + if err != nil { + t.Fatal(err) + } + bad := []RolePermissionChange{ + {ActionID: "project.create", Allowed: true, CapAllowed: true}, + {ActionID: "document.read", Allowed: true, CapAllowed: false}, + } + if _, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, bad, "policy-bad"); !errors.Is(err, ErrInvalidRolePolicy) { + t.Fatalf("invalid batch: %v", err) + } + var count int + if err := st.db.QueryRow(st.q(`SELECT COUNT(*) FROM role_permissions WHERE role_id=?`), role.RoleID).Scan(&count); err != nil || count != 0 { + t.Fatalf("partial writes=%d err=%v", count, err) + } + good := []RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}} + receipt, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, good, "policy-good") + if err != nil { + t.Fatal(err) + } + replayed, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, good, "policy-good") + if err != nil || replayed.ReceiptID != receipt.ReceiptID || replayed.AuthorityEpoch != receipt.AuthorityEpoch { + t.Fatalf("replay=%+v err=%v original=%+v", replayed, err, receipt) + } + if _, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, good, "policy-stale"); !errors.Is(err, ErrConflict) { + t.Fatalf("stale role version: %v", err) + } +} + +func TestReplaceSystemGrantsProtectsFinalPermissionManager(t *testing.T) { + st := openTestStore(t) + admin, alice, _ := rolePolicyAccounts(t, st) + epoch, err := st.AuthorityEpoch() + if err != nil { + t.Fatal(err) + } + if _, err := st.ReplaceSystemGrants("admin", admin.ID, epoch, nil, "remove-last"); !errors.Is(err, ErrLastPermissionManager) { + t.Fatalf("remove final manager: %v", err) + } + if _, err := st.ReplaceSystemGrants("admin", alice.ID, epoch, []string{"system.permissions.manage"}, "add-second"); err != nil { + t.Fatal(err) + } + epoch, _ = st.AuthorityEpoch() + if _, err := st.ReplaceSystemGrants("admin", admin.ID, epoch, nil, "remove-first"); err != nil { + t.Fatal(err) + } +} + +func TestDisableProtectsFinalPermissionManager(t *testing.T) { + st := openTestStore(t) + admin, alice, _ := rolePolicyAccounts(t, st) + epoch, _ := st.AuthorityEpoch() + if _, err := st.ReplaceSystemGrants("admin", alice.ID, epoch, []string{"system.permissions.manage"}, "grant-final-manager"); err != nil { + t.Fatal(err) + } + epoch, _ = st.AuthorityEpoch() + if _, err := st.ReplaceSystemGrants("admin", admin.ID, epoch, nil, "remove-root-explicit-grants"); err != nil { + t.Fatal(err) + } + alice, _ = st.GetAccount(alice.ID) + if _, err := st.SetAccountEnabled("admin", alice.ID, alice.StateVersion, false); !errors.Is(err, ErrLastPermissionManager) { + t.Fatalf("disabled final permission manager: %v", err) + } +} + +func TestPatchRoleKeepsStableIDAndDisablingBlocksAssignment(t *testing.T) { + st := openTestStore(t) + _, alice, _ := rolePolicyAccounts(t, st) + role, err := st.CreateRole("admin", RoleCreate{Name: "软件工程师"}) + if err != nil { + t.Fatal(err) + } + description := "负责软件研发" + updated, err := st.PatchRole("admin", role.RoleID, role.RoleVersion, RolePatch{Description: &description}) + if err != nil { + t.Fatal(err) + } + if updated.RoleID != role.RoleID || updated.Description != description || updated.RoleVersion != role.RoleVersion+1 { + t.Fatalf("updated role=%+v original=%+v", updated, role) + } + disabled := false + updated, err = st.PatchRole("admin", role.RoleID, updated.RoleVersion, RolePatch{Enabled: &disabled}) + if err != nil { + t.Fatal(err) + } + if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "disabled-role"); !errors.Is(err, ErrInvalidRolePolicy) { + t.Fatalf("disabled role assignment: %v", err) + } +} + +func TestRolePolicyIdempotencyKeyCannotChangeRequest(t *testing.T) { + st := openTestStore(t) + rolePolicyAccounts(t, st) + role, err := st.CreateRole("admin", RoleCreate{Name: "软件工程师"}) + if err != nil { + t.Fatal(err) + } + first := []RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}} + if _, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, first, "same-key"); err != nil { + t.Fatal(err) + } + changed := []RolePermissionChange{{ActionID: "project.create", Allowed: false, CapAllowed: true}} + if _, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion+1, changed, "same-key"); !errors.Is(err, ErrConflict) { + t.Fatalf("changed request reused idempotency key: %v", err) + } +} + +func capabilityRolePermissions(t *testing.T, st *Store, roleID string) map[string]RolePermissionRecord { + t.Helper() + permissions, err := st.RolePermissions(roleID) + if err != nil { + t.Fatal(err) + } + out := map[string]RolePermissionRecord{} + for _, permission := range permissions { + out[permission.ActionID] = permission + } + return out +} + +var linkedManagementActions = []string{"project.settings", "project.members.manage", "project.permissions.manage", "document.duties.assign"} + +func TestCapabilityAssociationProjectCreateOnlyLinksFullManagement(t *testing.T) { + st := openTestStore(t) + _, alice, _ := rolePolicyAccounts(t, st) + role, err := st.CreateRole("admin", RoleCreate{Name: "项目负责人"}) + if err != nil { + t.Fatal(err) + } + receipt, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, + []RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}}, "create-only") + if err != nil { + t.Fatal(err) + } + if strings.Join(receipt.LinkedExpansion, ",") != strings.Join(linkedManagementActions, ",") { + t.Fatalf("expansion=%v", receipt.LinkedExpansion) + } + permissions := capabilityRolePermissions(t, st, role.RoleID) + for _, actionID := range linkedManagementActions { + permission, ok := permissions[actionID] + // The cap opens the creator's own projects; the default stays off. + if !ok || permission.Allowed || !permission.CapAllowed { + t.Fatalf("%s=%+v ok=%v", actionID, permission, ok) + } + } + if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "assign-creator"); err != nil { + t.Fatal(err) + } + linked, err := st.LinkedCapabilities("alice") + if err != nil { + t.Fatal(err) + } + if !linked.ProjectCreate || linked.Source != "role" || strings.Join(linked.LinkedActions, ",") != strings.Join(linkedManagementActions, ",") || len(linked.Conflicts) != 0 { + t.Fatalf("linked=%+v", linked) + } + if replayed, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, + []RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}}, "create-only"); err != nil || replayed.ReceiptID != receipt.ReceiptID || len(replayed.LinkedExpansion) != 4 { + t.Fatalf("replay=%+v err=%v", replayed, err) + } +} + +func TestCapabilityAssociationRejectsExplicitConflictAtomically(t *testing.T) { + st := openTestStore(t) + rolePolicyAccounts(t, st) + role, err := st.CreateRole("admin", RoleCreate{Name: "受限创建者"}) + if err != nil { + t.Fatal(err) + } + epoch, _ := st.AuthorityEpoch() + conflict := []RolePermissionChange{ + {ActionID: "project.create", Allowed: true, CapAllowed: true}, + {ActionID: "project.settings", Allowed: false, CapAllowed: false}, + } + if _, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, conflict, "explicit-conflict"); !errors.Is(err, ErrInvalidRolePolicy) { + t.Fatalf("explicit role conflict: %v", err) + } + after, _ := st.GetRole(role.RoleID) + afterEpoch, _ := st.AuthorityEpoch() + if len(capabilityRolePermissions(t, st, role.RoleID)) != 0 || after.RoleVersion != role.RoleVersion || afterEpoch != epoch { + t.Fatalf("partial write: role=%+v epoch=%d->%d", after, epoch, afterEpoch) + } + // A cap already closed on the role still blocks a later project.create. + capped := []RolePermissionChange{{ActionID: "project.members.manage", Allowed: false, CapAllowed: false}} + receipt, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, capped, "platform-cap") + if err != nil { + t.Fatal(err) + } + enable := []RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}} + if _, err := st.ReplaceRolePermissions("admin", role.RoleID, receipt.RoleVersion, enable, "enable-over-cap"); !errors.Is(err, ErrInvalidRolePolicy) { + t.Fatalf("existing platform cap conflict: %v", err) + } + permissions := capabilityRolePermissions(t, st, role.RoleID) + if _, ok := permissions["project.create"]; ok || len(permissions) != 1 || permissions["project.members.manage"].CapAllowed { + t.Fatalf("permissions after rejected enable=%+v", permissions) + } + resolved := append(enable, RolePermissionChange{ActionID: "project.members.manage", Allowed: false, CapAllowed: true}) + if _, err := st.ReplaceRolePermissions("admin", role.RoleID, receipt.RoleVersion, resolved, "explicitly-resolved"); err != nil { + t.Fatalf("explicit resolution: %v", err) + } +} + +func TestCapabilityAssociationRejectsStaleVersionAndMissingRole(t *testing.T) { + st := openTestStore(t) + rolePolicyAccounts(t, st) + role, err := st.CreateRole("admin", RoleCreate{Name: "版本校验"}) + if err != nil { + t.Fatal(err) + } + enable := []RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}} + _, err = st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion+1, enable, "stale-version") + if !errors.Is(err, ErrInvalidRolePolicy) || !errors.Is(err, ErrConflict) { + t.Fatalf("stale version: %v", err) + } + _, err = st.ReplaceRolePermissions("admin", "role_missing", 1, enable, "missing-role") + if !errors.Is(err, ErrInvalidRolePolicy) || !errors.Is(err, ErrNotFound) { + t.Fatalf("missing role: %v", err) + } + if len(capabilityRolePermissions(t, st, role.RoleID)) != 0 { + t.Fatal("rejected write left role permissions") + } +} + +func TestLinkedCapabilitiesSystemGrantWithoutRoleClosesLoop(t *testing.T) { + st := openTestStore(t) + _, alice, bob := rolePolicyAccounts(t, st) + if linked, err := st.LinkedCapabilities("bob"); err != nil || linked.ProjectCreate || len(linked.LinkedActions) != 0 { + t.Fatalf("ungranted linked=%+v err=%v", linked, err) + } + epoch, _ := st.AuthorityEpoch() + if _, err := st.ReplaceSystemGrants("admin", bob.ID, epoch, []string{"project.create"}, "grant-bob-create"); err != nil { + t.Fatal(err) + } + linked, err := st.LinkedCapabilities("bob") + if err != nil { + t.Fatal(err) + } + if !linked.ProjectCreate || linked.Source != "system-grant" || strings.Join(linked.LinkedActions, ",") != strings.Join(linkedManagementActions, ",") { + t.Fatalf("direct grant linked=%+v", linked) + } + if admin, err := st.LinkedCapabilities("admin"); err != nil || admin.Source != "bootstrap-admin" || len(admin.LinkedActions) != 4 { + t.Fatalf("admin linked=%+v err=%v", admin, err) + } + + capped, err := st.CreateRole("admin", RoleCreate{Name: "禁止管理"}) + if err != nil { + t.Fatal(err) + } + cappedReceipt, err := st.ReplaceRolePermissions("admin", capped.RoleID, capped.RoleVersion, + []RolePermissionChange{{ActionID: "document.duties.assign", Allowed: false, CapAllowed: false}}, "capped-role") + if err != nil { + t.Fatal(err) + } + bob, _ = st.GetAccount(bob.ID) + if _, err := st.AssignUserRole("admin", bob.ID, capped.RoleID, bob.StateVersion, "assign-capped-to-creator"); !errors.Is(err, ErrInvalidRolePolicy) { + t.Fatalf("capped role bound to project.create grant holder: %v", err) + } + if _, err := st.AssignUserRole("admin", alice.ID, capped.RoleID, alice.StateVersion, "assign-capped"); err != nil { + t.Fatal(err) + } + epoch, _ = st.AuthorityEpoch() + if _, err := st.ReplaceSystemGrants("admin", alice.ID, epoch, []string{"project.create"}, "grant-capped-create"); !errors.Is(err, ErrInvalidRolePolicy) { + t.Fatalf("system grant over role cap: %v", err) + } + if grants, _, err := st.SystemGrantsForUser(alice.ID); err != nil || len(grants) != 0 { + t.Fatalf("rejected grant persisted=%v err=%v", grants, err) + } + // A role writer cannot later close a linked cap under a grant holder either. + open, err := st.CreateRole("admin", RoleCreate{Name: "普通职位"}) + if err != nil { + t.Fatal(err) + } + bob, _ = st.GetAccount(bob.ID) + if _, err := st.AssignUserRole("admin", bob.ID, open.RoleID, bob.StateVersion, "assign-open"); err != nil { + t.Fatal(err) + } + if _, err := st.ReplaceRolePermissions("admin", open.RoleID, open.RoleVersion, + []RolePermissionChange{{ActionID: "project.settings", Allowed: false, CapAllowed: false}}, "cap-under-holder"); !errors.Is(err, ErrInvalidRolePolicy) { + t.Fatalf("role cap under grant holder: %v", err) + } + if permissions := capabilityRolePermissions(t, st, open.RoleID); len(permissions) != 0 { + t.Fatalf("rejected role cap persisted: %+v", permissions) + } + if role, _ := st.GetRole(capped.RoleID); role.RoleVersion != cappedReceipt.RoleVersion { + t.Fatalf("capped role changed: %+v", role) + } +} + +func TestCapabilityAssociationDisablingProjectCreateKeepsLinkedRows(t *testing.T) { + st := openTestStore(t) + _, alice, bob := rolePolicyAccounts(t, st) + role, err := st.CreateRole("admin", RoleCreate{Name: "创建者"}) + if err != nil { + t.Fatal(err) + } + receipt, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, []RolePermissionChange{ + {ActionID: "project.create", Allowed: true, CapAllowed: true}, + {ActionID: "project.settings", Allowed: true, CapAllowed: true}, + }, "enable-create") + if err != nil { + t.Fatal(err) + } + if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "assign-alice"); err != nil { + t.Fatal(err) + } + disabled, err := st.ReplaceRolePermissions("admin", role.RoleID, receipt.RoleVersion, + []RolePermissionChange{{ActionID: "document.read", Allowed: true, CapAllowed: true}}, "disable-create") + if err != nil { + t.Fatal(err) + } + permissions := capabilityRolePermissions(t, st, role.RoleID) + if _, ok := permissions["project.create"]; ok { + t.Fatalf("project.create still present: %+v", permissions) + } + if settings := permissions["project.settings"]; !settings.Allowed || !settings.CapAllowed { + t.Fatalf("project.settings not kept: %+v", permissions) + } + for _, actionID := range linkedManagementActions { + if _, ok := permissions[actionID]; !ok { + t.Fatalf("%s deleted by disabling project.create: %+v", actionID, permissions) + } + } + if !strings.Contains(disabled.MigrationNote, "project.create disabled") || !strings.Contains(disabled.MigrationNote, "project.settings") { + t.Fatalf("migration note=%q", disabled.MigrationNote) + } + if linked, err := st.LinkedCapabilities("alice"); err != nil || linked.ProjectCreate { + t.Fatalf("disabled create still linked=%+v err=%v", linked, err) + } + // Removal after project.create is closed is an explicit second write. + if _, err := st.ReplaceRolePermissions("admin", role.RoleID, disabled.RoleVersion, nil, "explicit-revoke"); err != nil { + t.Fatal(err) + } + if permissions := capabilityRolePermissions(t, st, role.RoleID); len(permissions) != 0 { + t.Fatalf("explicit revoke kept=%+v", permissions) + } + + epoch, _ := st.AuthorityEpoch() + if _, err := st.ReplaceSystemGrants("admin", bob.ID, epoch, []string{"project.create"}, "grant-bob"); err != nil { + t.Fatal(err) + } + epoch, _ = st.AuthorityEpoch() + revoked, err := st.ReplaceSystemGrants("admin", bob.ID, epoch, nil, "revoke-bob") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(revoked.MigrationNote, "project.create disabled") { + t.Fatalf("system grant migration note=%q", revoked.MigrationNote) + } +} + +func TestCapabilityAssociationImportRejectsLinkedConflict(t *testing.T) { + st := openTestStore(t) + _, alice, _ := rolePolicyAccounts(t, st) + bad := RoleAuthorityImport{SourceSchemaVersion: 1, SourceSHA256: strings.Repeat("b", 64), Roles: []RoleAuthorityImportRole{{ + RoleID: "role_legacy_creator", Name: "旧创建者", Enabled: true, RoleVersion: 2, + Permissions: []RolePermissionChange{ + {ActionID: "project.create", Allowed: true, CapAllowed: true}, + {ActionID: "project.permissions.manage", Allowed: false, CapAllowed: false}, + }, + }}} + if _, err := st.ImportRoleAuthority("admin", bad, "import-conflict"); !errors.Is(err, ErrInvalidRolePolicy) { + t.Fatalf("conflicting import staged: %v", err) + } + epoch, _ := st.AuthorityEpoch() + if _, err := st.ReplaceSystemGrants("admin", alice.ID, epoch, []string{"project.create"}, "grant-alice"); err != nil { + t.Fatal(err) + } + cappedForHolder := RoleAuthorityImport{SourceSchemaVersion: 1, SourceSHA256: strings.Repeat("c", 64), Roles: []RoleAuthorityImportRole{{ + RoleID: "role_legacy_viewer", Name: "旧查看者", Enabled: true, RoleVersion: 1, + Permissions: []RolePermissionChange{{ActionID: "project.settings", Allowed: false, CapAllowed: false}}, + Owners: []string{"alice"}, + }}} + if _, err := st.ImportRoleAuthority("admin", cappedForHolder, "import-capped-holder"); !errors.Is(err, ErrInvalidRolePolicy) { + t.Fatalf("import capped grant holder: %v", err) + } + good := RoleAuthorityImport{SourceSchemaVersion: 1, SourceSHA256: strings.Repeat("d", 64), Roles: []RoleAuthorityImportRole{{ + RoleID: "role_legacy_creator", Name: "旧创建者", Enabled: true, RoleVersion: 2, + Permissions: []RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}}, + Owners: []string{"bob"}, + }}} + result, err := st.ImportRoleAuthority("admin", good, "import-good") + if err != nil { + t.Fatalf("valid import after rejected attempts: %v", err) + } + if _, err := st.ActivateRoleAuthorityImport("admin", good.SourceSHA256, result.AuthorityEpoch); err != nil { + t.Fatal(err) + } + linked, err := st.LinkedCapabilities("bob") + if err != nil || linked.Source != "role" || len(linked.LinkedActions) != 4 { + t.Fatalf("imported creator linked=%+v err=%v", linked, err) + } +} + +func TestImportRoleAuthorityPreservesStableIDsAndBindingsAtomically(t *testing.T) { + st := openTestStore(t) + for _, row := range []struct { + owner string + admin bool + }{{"admin", true}, {"alice", false}, {"bob", false}} { + if err := st.CreateAccount(row.owner, row.owner, "hash", row.admin); err != nil { + t.Fatal(err) + } + } + in := RoleAuthorityImport{SourceSchemaVersion: 1, SourceSHA256: strings.Repeat("a", 64), Roles: []RoleAuthorityImportRole{{ + RoleID: "role_legacy_engineer", Name: "软件工程师", Description: "迁移职位", Enabled: true, RoleVersion: 4, + Permissions: []RolePermissionChange{{ActionID: "document.read", Allowed: true, CapAllowed: true}}, + Owners: []string{"alice", "bob"}, + }}} + result, err := st.ImportRoleAuthority("admin", in, "import-role-authority") + if err != nil || result.ImportedRoles != 1 || result.ImportedUsers != 2 { + t.Fatalf("result=%+v err=%v", result, err) + } + if _, err := st.RoleAdmin("role_legacy_engineer"); !errors.Is(err, ErrNotFound) { + t.Fatalf("staged role became live before activation: %v", err) + } + if _, err := st.CreateRole("admin", RoleCreate{Name: "并发角色"}); !errors.Is(err, ErrConflict) { + t.Fatalf("live role write bypassed staged migration fence: %v", err) + } + staged, err := st.ReadRoleAuthorityImport("admin", in.SourceSHA256) + if err != nil || len(staged.Roles) != 1 || len(staged.Roles[0].Owners) != 2 { + t.Fatalf("staged=%+v err=%v", staged, err) + } + activated, err := st.ActivateRoleAuthorityImport("admin", in.SourceSHA256, result.AuthorityEpoch) + if err != nil || activated.AuthorityEpoch != result.AuthorityEpoch+1 { + t.Fatalf("activated=%+v err=%v", activated, err) + } + view, err := st.RoleAdmin("role_legacy_engineer") + if err != nil || view.RoleVersion != 4 || view.BindingCount != 2 || view.Description != "迁移职位" { + t.Fatalf("view=%+v err=%v", view, err) + } + for _, owner := range []string{"alice", "bob"} { + account, _ := st.GetAccountByOwner(owner) + binding, err := st.RoleForUser(account.ID) + if err != nil || binding.Role == nil || binding.Role.RoleID != "role_legacy_engineer" { + t.Fatalf("owner=%s binding=%+v err=%v", owner, binding, err) + } + } + replayed, err := st.ImportRoleAuthority("admin", in, "import-role-authority") + if err != nil || replayed.AuthorityEpoch != result.AuthorityEpoch { + t.Fatalf("replay=%+v err=%v", replayed, err) + } + if _, err := st.ImportRoleAuthority("admin", in, "second-import"); !errors.Is(err, ErrConflict) { + t.Fatalf("non-empty authority accepted a second import: %v", err) + } +} diff --git a/internal/store/run_account_write.go b/internal/store/run_account_write.go new file mode 100644 index 0000000..f5d57b6 --- /dev/null +++ b/internal/store/run_account_write.go @@ -0,0 +1,110 @@ +package store + +import ( + "database/sql" + "errors" + "time" + + core "agent-system-core" +) + +// UpdateRunAccountChecked fences forward run state and completed output at the +// final run-row mutation. Terminal failure cleanup has a separate legacy path. +func (s *Store) UpdateRunAccountChecked(actor string, r *core.Run, allowImplicitDefault bool) error { + if r == nil || actor == "" || string(r.Owner) != actor { + return ErrAccountForbidden + } + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + var owner string + if err := tx.QueryRow(s.q(`SELECT owner FROM runs WHERE id=?`), r.ID).Scan(&owner); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + if owner != actor { + return ErrNotFound + } + var ended any + if r.EndedAt != nil { + ended = nowISO() + } + _, err := tx.Exec(s.q(`UPDATE runs SET status=?,phase=?,usage=?,error=?,ended_at=? WHERE id=? AND owner=?`), + string(r.Status), string(r.Phase), marshalJSON(r.Usage), marshalJSON(r.Error), ended, r.ID, actor) + return err + }) +} + +// CreateRunAccountChecked fences run admission against account disable. The +// conversation's stored owner is checked in the same final transaction. +func (s *Store) CreateRunAccountChecked(r *core.Run, allowImplicitDefault bool) error { + if r == nil || r.Owner == "" { + return ErrAccountForbidden + } + if r.ID == "" { + r.ID = newID("run") + } + if r.CreatedAt.IsZero() { + r.CreatedAt = time.Now().UTC() + } else { + r.CreatedAt = r.CreatedAt.UTC() + } + ca := r.CreatedAt.Format(time.RFC3339Nano) + return s.withEffectiveAccountWrite(string(r.Owner), allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + var owner string + if err := tx.QueryRow(s.q(`SELECT owner FROM conversations WHERE id=?`), r.ConversationID).Scan(&owner); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + if owner != string(r.Owner) { + return ErrNotFound + } + _, err := tx.Exec(s.q(`INSERT INTO runs(id,parent_run_id,conversation_id,project_id,owner,kind,profile_id,status,phase,agent_name,created_at,instance_id) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`), + r.ID, r.ParentRunID, r.ConversationID, r.ProjectID, r.Owner, string(r.Kind), r.ProfileID, string(r.Status), string(r.Phase), r.AgentName, ca, s.instanceID) + return err + }) +} + +// SetMessageRunIDAccountChecked links an admitted run only while its actor is +// still effective. Both rows must belong to the actor's conversation. +func (s *Store) SetMessageRunIDAccountChecked(actor, messageID, runID string, allowImplicitDefault bool) error { + var routeConversation string + if err := s.db.QueryRow(s.q(`SELECT conversation_id FROM messages WHERE id=?`), messageID).Scan(&routeConversation); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + if err := s.dialect.lockConversation(tx, routeConversation); err != nil { + return err + } + var messageConversation, messageOwner, runConversation, runOwner string + if err := tx.QueryRow(s.q(`SELECT m.conversation_id,c.owner FROM messages m JOIN conversations c ON c.id=m.conversation_id WHERE m.id=?`), messageID). + Scan(&messageConversation, &messageOwner); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + if err := tx.QueryRow(s.q(`SELECT conversation_id,owner FROM runs WHERE id=?`), runID).Scan(&runConversation, &runOwner); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + if messageConversation != routeConversation || runConversation != routeConversation || messageOwner != actor || runOwner != actor { + return ErrNotFound + } + res, err := tx.Exec(s.q(`UPDATE messages SET run_id=? WHERE id=? AND conversation_id=?`), runID, messageID, routeConversation) + if err != nil { + return err + } + if n, err := res.RowsAffected(); err != nil { + return err + } else if n != 1 { + return ErrNotFound + } + if _, err := tx.Exec(s.q(`UPDATE project_work_outbox SET run_id=? WHERE source_kind='user-message' AND source_id=?`), runID, messageID); err != nil { + return err + } + return nil + }) +} diff --git a/internal/store/runtime_account_sink_test.go b/internal/store/runtime_account_sink_test.go new file mode 100644 index 0000000..4a83a79 --- /dev/null +++ b/internal/store/runtime_account_sink_test.go @@ -0,0 +1,145 @@ +package store + +import ( + "errors" + "testing" + "time" + + agentcommand "agent-command" + core "agent-system-core" +) + +func TestDisabledAccountCannotAdvanceRunOrCreateNotice(t *testing.T) { + st := openTestStore(t) + if err := st.CreateAccount("admin", "Admin", "hash", true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("alice", "Alice", "hash", false); err != nil { + t.Fatal(err) + } + project, err := st.CreateProject("alice", "fixture") + if err != nil { + t.Fatal(err) + } + conv, err := st.CreateConversation("alice", string(project.ID), "fixture", "") + if err != nil { + t.Fatal(err) + } + run := core.Run{ConversationID: conv.ID, ProjectID: project.ID, Owner: "alice", Kind: core.RunConversation, Status: core.RunPending, Phase: core.PhaseThinking} + if err := st.CreateRun(&run); err != nil { + t.Fatal(err) + } + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + run.Status = core.RunRunning + if err := st.UpdateRunAccountChecked("alice", &run, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled account advanced run: %v", err) + } + if err := st.AppendNoticeAccountChecked(Notice{ID: "late", ConversationID: string(conv.ID), Owner: "alice"}, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled account created notice: %v", err) + } + got, err := st.GetRunByID(run.ID) + if err != nil || got.Status != core.RunPending { + t.Fatalf("run changed after disable: %+v %v", got, err) + } + var count int + if err := st.DB().QueryRow(st.q(`SELECT COUNT(*) FROM pending_notices WHERE id=?`), "late").Scan(&count); err != nil || count != 0 { + t.Fatalf("late notice persisted: %d %v", count, err) + } +} + +func TestRegenMarkerIsConsumedOnlyBySuccessfulSettlement(t *testing.T) { + st := openTestStore(t) + if err := st.CreateAccount("admin", "Admin", "hash", true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("alice", "Alice", "hash", false); err != nil { + t.Fatal(err) + } + project, err := st.CreateProject("alice", "fixture") + if err != nil { + t.Fatal(err) + } + conv, err := st.CreateConversation("alice", string(project.ID), "fixture", "") + if err != nil { + t.Fatal(err) + } + run := core.Run{ConversationID: conv.ID, ProjectID: project.ID, Owner: "alice", Kind: core.RunConversation, Status: core.RunRunning, Phase: core.PhaseThinking} + if err := st.CreateRun(&run); err != nil { + t.Fatal(err) + } + if err := st.SetRegenPending(string(conv.ID), "group", 2); err != nil { + t.Fatal(err) + } + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + run.Status, run.Phase, run.EndedAt = core.RunCompleted, core.PhaseDone, &now + msg := core.Message{ConversationID: conv.ID, RunID: run.ID, Role: core.RoleAssistant, + VariantGroup: "group", VariantIndex: 2, Parts: []core.Part{{Type: core.PartText, Text: "answer"}}} + if _, err := st.SettleRunAccountChecked(&run, &msg, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled account settled regenerated output: %v", err) + } + if group, index, ok := st.PeekRegenPending(string(conv.ID)); !ok || group != "group" || index != 2 { + t.Fatalf("failed settlement consumed marker: %q %d %v", group, index, ok) + } + account, err = st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, true); err != nil { + t.Fatal(err) + } + if updated, err := st.SettleRunAccountChecked(&run, &msg, false); err != nil || !updated { + t.Fatalf("enabled settlement: %v updated=%v", err, updated) + } + if _, _, ok := st.PeekRegenPending(string(conv.ID)); ok { + t.Fatal("settled regeneration left marker") + } +} + +func TestCommandBusDurableSinksRejectDisabledAccount(t *testing.T) { + st := openTestStore(t) + if err := st.CreateAccount("admin", "Admin", "hash", true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("alice", "Alice", "hash", false); err != nil { + t.Fatal(err) + } + if err := st.SavePendingConfirmAccountChecked("card-one", "alice", `{}`, false); err != nil { + t.Fatal(err) + } + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + if err := st.SavePendingConfirmAccountChecked("card-late", "alice", `{}`, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("late pending confirm: %v", err) + } + if _, _, ok, err := st.TakePendingConfirmAccountChecked("card-one", "alice", false); !errors.Is(err, ErrAccountForbidden) || ok { + t.Fatalf("disabled account consumed pending confirm: ok=%v err=%v", ok, err) + } + if err := st.AppendAuditAccountChecked("alice", agentcommand.AuditEnvelope{Time: time.Now().UTC(), CommandID: "demo"}, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("late command audit: %v", err) + } + var pending, audits int + if err := st.DB().QueryRow(st.q(`SELECT COUNT(*) FROM pending_confirms WHERE card_id=?`), "card-one").Scan(&pending); err != nil || pending != 1 { + t.Fatalf("original pending lost: %d %v", pending, err) + } + if err := st.DB().QueryRow(st.q(`SELECT COUNT(*) FROM command_audit WHERE owner=?`), "alice").Scan(&audits); err != nil || audits != 0 { + t.Fatalf("late command audit persisted: %d %v", audits, err) + } +} diff --git a/internal/store/search_deadlock_run_timing_test.go b/internal/store/search_deadlock_run_timing_test.go new file mode 100644 index 0000000..74eda07 --- /dev/null +++ b/internal/store/search_deadlock_run_timing_test.go @@ -0,0 +1,192 @@ +package store + +import ( + "context" + "testing" + "time" + + core "agent-system-core" +) + +func TestSearchDeadlockRegression(t *testing.T) { + st := openTestStore(t) + ctx := context.Background() + + // Seed account for allow/pred callback to query + if err := st.CreateAccount("alice", "", "", false); err != nil { + t.Fatal(err) + } + + // Seed knowledge doc and vector + docVec := core.KnowledgeDoc{ + ID: "doc_vec_deadlock", + Owner: "alice", + ProjectID: "proj_deadlock", + Source: "src_vec", + Title: "vector doc", + Chunk: "vector chunk content", + } + docFTS := core.KnowledgeDoc{ + ID: "doc_fts_deadlock", + Owner: "alice", + ProjectID: "proj_deadlock", + Source: "src_fts", + Title: "Database Optimization Guide", + Chunk: "Performance tuning and indexing for relational databases", + } + docLike := core.KnowledgeDoc{ + ID: "doc_like_deadlock", + Owner: "alice", + ProjectID: "proj_deadlock", + Source: "src_like", + Title: "微服务架构治理", + Chunk: "分布式系统与全链路追踪方案", + } + + if err := st.AddKnowledge(docVec, docFTS, docLike); err != nil { + t.Fatal(err) + } + if err := st.UpsertKnowledgeVector(ctx, "alice", docVec.ID, "ref_vec", []float32{1.0, 0.0}); err != nil { + t.Fatal(err) + } + + runBounded := func(name string, fn func()) { + t.Helper() + done := make(chan struct{}) + go func() { + defer close(done) + fn() + }() + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatalf("deadlock detected in %s: did not finish within 10s", name) + } + } + + // 1. Vector scan: allow callback queries the same Store + runBounded("searchKnowledgeVectorsSharedAuthorizedScan", func() { + hits, err := st.SearchKnowledgeVectorsSharedAuthorized(ctx, "bob", "", []float32{1.0, 0.0}, 5, core.RetrievalQuery{}, func(d core.KnowledgeDoc) bool { + // Query the store inside the callback: on a single-connection SQLite store, + // this would deadlock if rows was not closed before invoking the callback. + _, qerr := st.GetAccountByOwner(d.Owner) + if qerr != nil { + t.Errorf("GetAccountByOwner failed in callback: %v", qerr) + } + return true + }) + if err != nil { + t.Errorf("SearchKnowledgeVectorsSharedAuthorized failed: %v", err) + } + if len(hits) == 0 { + t.Errorf("expected at least 1 hit, got 0") + } + }) + + // 2. Content FTS scan: pred callback queries the same Store + // knowledge_fts MATCH is SQLite FTS5 only; production never calls it on PostgreSQL. + if st.Dialect() == "sqlite" { + runBounded("searchKnowledgeFTSCandidates", func() { + hits, err := st.searchKnowledgeFTSCandidates(func(d core.KnowledgeDoc) bool { + _, qerr := st.GetAccountByOwner(d.Owner) + if qerr != nil { + t.Errorf("GetAccountByOwner failed in callback: %v", qerr) + } + return true + }, core.RetrievalQuery{Text: "database optimization", TopK: 5}, true) + if err != nil { + t.Errorf("searchKnowledgeFTSCandidates failed: %v", err) + } + if len(hits) == 0 { + t.Errorf("expected at least 1 hit, got 0") + } + }) + } + + // 3. Content LIKE scan: pred callback queries the same Store + runBounded("searchKnowledgeLikeSharedCandidates", func() { + hits, err := st.searchKnowledgeLikeSharedCandidates("bob", core.RetrievalQuery{Text: "微服务", TopK: 5}, func(d core.KnowledgeDoc) bool { + _, qerr := st.GetAccountByOwner(d.Owner) + if qerr != nil { + t.Errorf("GetAccountByOwner failed in callback: %v", qerr) + } + return true + }, true) + if err != nil { + t.Errorf("searchKnowledgeLikeSharedCandidates failed: %v", err) + } + if len(hits) == 0 { + t.Errorf("expected at least 1 hit, got 0") + } + }) +} + +func TestRunCreatedAtNonZeroRecentAndMatchesReadBack(t *testing.T) { + st := openTestStore(t) + + if err := st.CreateAccount("alice", "", "", false); err != nil { + t.Fatal(err) + } + proj, err := st.CreateProject("alice", "test_proj") + if err != nil { + t.Fatal(err) + } + conv, err := st.CreateConversation("alice", proj.ID, "test_conv", "") + if err != nil { + t.Fatal(err) + } + + // 1. CreateRun with zero CreatedAt + before1 := time.Now().UTC().Add(-time.Second) + r1 := core.Run{ + ConversationID: conv.ID, + ProjectID: conv.ProjectID, + Owner: conv.Owner, + Kind: core.RunConversation, + Status: core.RunRunning, + Phase: core.PhaseThinking, + } + if err := st.CreateRun(&r1); err != nil { + t.Fatalf("CreateRun failed: %v", err) + } + if r1.CreatedAt.IsZero() { + t.Fatalf("CreateRun: returned run has zero CreatedAt") + } + if r1.CreatedAt.Before(before1) || r1.CreatedAt.After(time.Now().UTC().Add(time.Second)) { + t.Fatalf("CreateRun: returned run CreatedAt %v not recent (before=%v)", r1.CreatedAt, before1) + } + readBack1, err := st.GetRunByID(r1.ID) + if err != nil { + t.Fatalf("GetRunByID failed: %v", err) + } + if !readBack1.CreatedAt.Equal(r1.CreatedAt) { + t.Fatalf("CreateRun: in-memory CreatedAt %v != stored CreatedAt %v", r1.CreatedAt, readBack1.CreatedAt) + } + + // 2. CreateRunAccountChecked with zero CreatedAt + before2 := time.Now().UTC().Add(-time.Second) + r2 := core.Run{ + ConversationID: conv.ID, + ProjectID: conv.ProjectID, + Owner: conv.Owner, + Kind: core.RunConversation, + Status: core.RunRunning, + Phase: core.PhaseThinking, + } + if err := st.CreateRunAccountChecked(&r2, false); err != nil { + t.Fatalf("CreateRunAccountChecked failed: %v", err) + } + if r2.CreatedAt.IsZero() { + t.Fatalf("CreateRunAccountChecked: returned run has zero CreatedAt") + } + if r2.CreatedAt.Before(before2) || r2.CreatedAt.After(time.Now().UTC().Add(time.Second)) { + t.Fatalf("CreateRunAccountChecked: returned run CreatedAt %v not recent (before=%v)", r2.CreatedAt, before2) + } + readBack2, err := st.GetRunByID(r2.ID) + if err != nil { + t.Fatalf("GetRunByID failed: %v", err) + } + if !readBack2.CreatedAt.Equal(r2.CreatedAt) { + t.Fatalf("CreateRunAccountChecked: in-memory CreatedAt %v != stored CreatedAt %v", r2.CreatedAt, readBack2.CreatedAt) + } +} diff --git a/internal/store/search_postgres.go b/internal/store/search_postgres.go index f5ffea5..e812ec6 100644 --- a/internal/store/search_postgres.go +++ b/internal/store/search_postgres.go @@ -79,7 +79,11 @@ func (s *Store) searchKnowledgePGSharedCandidates(requester string, q core.Retri return nil, err } defer rows.Close() - out := []core.RetrievalHit{} + type rawPGCandidate struct { + doc core.KnowledgeDoc + rank float64 + } + var rawCands []rawPGCandidate for rows.Next() { var d core.KnowledgeDoc var own, ca string @@ -89,17 +93,25 @@ func (s *Store) searchKnowledgePGSharedCandidates(requester string, q core.Retri } d.Owner = own d.CreatedAt, _ = parseTime(ca) - if !pred(d) { + rawCands = append(rawCands, rawPGCandidate{doc: d, rank: rank}) + } + if err := rows.Err(); err != nil { + return nil, err + } + _ = rows.Close() + out := []core.RetrievalHit{} + for _, c := range rawCands { + if !pred(c.doc) { continue } - score := rank + score := c.rank if score == 0 { score = 0.5 } - out = append(out, core.RetrievalHit{Doc: d, Score: score, Kind: "fulltext"}) + out = append(out, core.RetrievalHit{Doc: c.doc, Score: score, Kind: "fulltext"}) if len(out) >= topK { break } } - return out, rows.Err() + return out, nil } diff --git a/internal/store/settings_account_write_test.go b/internal/store/settings_account_write_test.go new file mode 100644 index 0000000..a23ca49 --- /dev/null +++ b/internal/store/settings_account_write_test.go @@ -0,0 +1,169 @@ +package store + +import ( + "errors" + "testing" + "time" + + core "agent-system-core" +) + +func TestSettingsAccountCheckedScopesAndSnapshots(t *testing.T) { + st := openTestStore(t) + for _, a := range []struct { + owner string + admin bool + }{{"admin", true}, {"admin2", true}, {"alice", false}, {"bob", false}} { + if err := st.CreateAccount(a.owner, "", "", a.admin); err != nil { + t.Fatal(err) + } + } + project, err := st.CreateProject("alice", "owned") + if err != nil { + t.Fatal(err) + } + merge := func(cur map[string]any) (map[string]any, error) { + return core.MergeSettings(cur, map[string]any{"ui": map[string]any{"theme": "light"}}), nil + } + if err := st.UpdateSettingsScopeAccountChecked("alice", "alice", "user", false, merge); err != nil { + t.Fatal(err) + } + if err := st.UpdateSettingsScopeAccountChecked("alice", "alice", "project:"+project.ID, false, merge); err != nil { + t.Fatal(err) + } + if err := st.UpdateSettingsScopeAccountChecked("bob", "alice", "user", false, merge); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("foreign user scope admitted: %v", err) + } + if err := st.UpdateSettingsScopeAccountChecked("alice", "", "system", false, merge); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("non-admin system scope admitted: %v", err) + } + if err := st.UpdateSettingsScopeAccountChecked("admin", "", "system", false, merge); err != nil { + t.Fatal(err) + } + if err := st.UpdateSettingsScopeAccountChecked("alice", "alice", "project:missing", false, merge); !errors.Is(err, ErrNotFound) { + t.Fatalf("missing project scope admitted: %v", err) + } + if saved, err := st.SaveSnapshotIfChangedAccountChecked("alice", "alice", "user", `{"ui":{"theme":"light"}}`, "save", false); err != nil || !saved { + t.Fatalf("guarded snapshot: saved=%v err=%v", saved, err) + } + if saved, err := st.SaveSnapshotIfChangedAccountChecked("alice", "alice", "user", `{"ui":{"theme":"light"}}`, "save", false); err != nil || saved { + t.Fatalf("unchanged snapshot duplicated: saved=%v err=%v", saved, err) + } + if _, err := st.SaveSnapshotIfChangedAccountChecked("alice", "", "system", `{}`, "save", false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("non-admin system snapshot admitted: %v", err) + } + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + for _, mutate := range []func() error{ + func() error { return st.UpdateSettingsScopeAccountChecked("alice", "alice", "user", false, merge) }, + func() error { + return st.UpdateSettingsScopeAccountChecked("alice", "alice", "project:"+project.ID, false, merge) + }, + func() error { + _, err := st.SaveSnapshotIfChangedAccountChecked("alice", "alice", "user", `{"late":true}`, "save", false) + return err + }, + } { + if err := mutate(); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled account wrote settings/snapshot: %v", err) + } + } + var snapshots int + if err := st.DB().QueryRow(st.q(`SELECT COUNT(*) FROM settings_snapshots WHERE owner=? AND scope=?`), "alice", "user").Scan(&snapshots); err != nil || snapshots != 1 { + t.Fatalf("rejected snapshot changed row count: %d %v", snapshots, err) + } + admin, err := st.GetAccountByOwner("admin") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin2", admin.ID, admin.StateVersion, false); err != nil { + t.Fatal(err) + } + if err := st.UpdateSettingsScopeAccountChecked("admin", "", "system", false, merge); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled administrator wrote system settings: %v", err) + } + if _, err := st.SaveSnapshotIfChangedAccountChecked("admin", "", "system", `{}`, "save", false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled administrator wrote system snapshot: %v", err) + } +} + +func TestSettingsAccountCheckedImplicitDefault(t *testing.T) { + st := openTestStore(t) + merge := func(cur map[string]any) (map[string]any, error) { return map[string]any{"enabled": true}, nil } + if err := st.UpdateSettingsScopeAccountChecked(OwnerDefault, OwnerDefault, "user", false, merge); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("unapproved implicit default admitted: %v", err) + } + if err := st.UpdateSettingsScopeAccountChecked(OwnerDefault, OwnerDefault, "user", true, merge); err != nil { + t.Fatal(err) + } + if err := st.UpdateSettingsScopeAccountChecked(OwnerDefault, "", "system", true, merge); err != nil { + t.Fatalf("embedded local system write rejected: %v", err) + } + if err := st.CreateAccount("admin", "", "", true); err != nil { + t.Fatal(err) + } + if err := st.EnsureUser(OwnerDefault, ""); err != nil { + t.Fatal(err) + } + account, err := st.GetAccountByOwner(OwnerDefault) + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + if err := st.UpdateSettingsScopeAccountChecked(OwnerDefault, "", "system", true, merge); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled embedded local system write admitted: %v", err) + } +} + +func TestPGSettingsAccountWriteSerializesWithDisable(t *testing.T) { + st := openTestStore(t) + if st.Dialect() != "postgres" { + t.Skip("real PostgreSQL required") + } + other := reopenAccountModelStore(t, st) + if err := st.CreateAccount("admin", "", "", true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("alice", "", "", false); err != nil { + t.Fatal(err) + } + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + release := holdPGAccountWrite(t, st, "settings", "INSERT", "settings") + writeDone := make(chan error, 1) + go func() { + writeDone <- st.UpdateSettingsScopeAccountChecked("alice", "alice", "user", false, + func(cur map[string]any) (map[string]any, error) { return map[string]any{"first": true}, nil }) + }() + waitForPGAccountWrite(t, st, "settings") + disableDone := make(chan error, 1) + go func() { + _, err := other.SetAccountEnabled("admin", account.ID, account.StateVersion, false) + disableDone <- err + }() + select { + case err := <-disableDone: + t.Fatalf("disable committed ahead of paused settings insert: %v", err) + case <-time.After(100 * time.Millisecond): + } + release() + if err := awaitPGAccountResult(t, writeDone); err != nil { + t.Fatal(err) + } + if err := awaitPGAccountResult(t, disableDone); err != nil { + t.Fatal(err) + } + if err := st.UpdateSettingsScopeAccountChecked("alice", "alice", "user", false, + func(cur map[string]any) (map[string]any, error) { return map[string]any{"late": true}, nil }); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("late settings write passed: %v", err) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index ae22d78..63674b1 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -77,6 +77,11 @@ type Store struct { migrateMu sync.Mutex migrateFault error // 非空时 migratePass 在「DDL 已执行、版本未登记」处失败(原子性测试) + accountFault error // 非空时账户事务在提交前失败(半态测试) + + documentDeliveryMu sync.Mutex + documentDeliveryFaultStage string + documentDeliveryFault error vecPGOnce sync.Once // pgvector 列探测(U6-3:一次性缓存) vecPG bool @@ -87,7 +92,7 @@ func OpenSQLite(path string) (*Store, error) { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return nil, err } - return openSQLiteFile(path, true) + return openSQLiteFile(path, true, true) } // OpenIdentity 打开既有库做身份 ensure/verify:不创建文件或目录,不执行迁移。 @@ -171,8 +176,8 @@ func (s *Store) SchemaVersion() (int, error) { return int(v.Int64), nil } -// OpenExistingSQLite 打开既有嵌入式库:数据文件不存在则失败,不创建空库或父目录。 -// 仍执行兼容迁移(requireCompatible);用于账户运维,避免恢复操作顺带建库。 +// OpenExistingSQLite 打开既有嵌入式库:数据文件不存在则失败,不创建空库或父目录,也不迁移。 +// schema 与本二进制不一致时拒绝,并提示使用 migrate 或先启动服务。 func OpenExistingSQLite(path string) (*Store, error) { if _, err := os.Stat(path); err != nil { if os.IsNotExist(err) { @@ -180,22 +185,23 @@ func OpenExistingSQLite(path string) (*Store, error) { } return nil, err } - return openSQLiteFile(path, false) + return openSQLiteFile(path, false, false) } -func openSQLiteFile(path string, createArtifacts bool) (*Store, error) { +func openSQLiteFile(path string, createArtifacts, migrateSchema bool) (*Store, error) { // _txlock=immediate:写事务在 BEGIN 即取库写锁(而非首个写语句升级), // 跨进程迁移/写路径依赖 busy_timeout 排队而不是中途 SQLITE_BUSY(KTD8)。 // busy_timeout 30s:迁移竞争测试在慢速 CI 盘上 4 个迁移者串行全程可超 5s // (实测 job 2285 SQLITE_BUSY);本地档宁可排队不报错。 - dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(30000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_txlock=immediate", path) + // synchronous(FULL) 是恢复方案 KTD14 的可靠档。打开后在同一连接读回,见 verifyDurability。 + dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(30000)&_pragma=journal_mode(WAL)&_pragma=synchronous(FULL)&_pragma=foreign_keys(1)&_txlock=immediate", path) db, err := sql.Open("sqlite", dsn) if err != nil { return nil, err } // SQLite 单写者:串行化连接,语句级原子 + 版本化迁移事务。 db.SetMaxOpenConns(1) - return finishOpen(db, sqliteDialect{}, filepath.Join(filepath.Dir(path), "artifacts"), nil, PoolOptions{}, createArtifacts) + return finishOpenMode(db, sqliteDialect{}, filepath.Join(filepath.Dir(path), "artifacts"), nil, PoolOptions{}, createArtifacts, migrateSchema) } // OpenPostgres 打开 PostgreSQL 档存储并执行迁移(默认连接预算)。 @@ -214,15 +220,15 @@ func OpenPostgresWithBlobs(dsn, artifactsDir string, blobs blobstore.Store) (*St // 以 statement_timeout 运行参数附着到 DSN(对 URL 与键值两种形式均生效)。 // blobs 为 nil(含带类型的空指针,例如未配置端点时的 (*blobstore.S3)(nil))时回落本地目录。 func OpenPostgresWithOptions(dsn, artifactsDir string, pool PoolOptions, blobs blobstore.Store) (*Store, error) { - return openPostgresWithOptions(dsn, artifactsDir, pool, blobs, true) + return openPostgresWithOptions(dsn, artifactsDir, pool, blobs, true, true) } -// OpenExistingPostgres 打开既有 PG 库并执行兼容迁移;不创建 artifacts 目录。 +// OpenExistingPostgres 打开既有 PG 库,不创建 artifacts 目录,也不迁移。 func OpenExistingPostgres(dsn, artifactsDir string) (*Store, error) { - return openPostgresWithOptions(dsn, artifactsDir, PoolOptions{}, nil, false) + return openPostgresWithOptions(dsn, artifactsDir, PoolOptions{}, nil, false, false) } -func openPostgresWithOptions(dsn, artifactsDir string, pool PoolOptions, blobs blobstore.Store, createArtifacts bool) (*Store, error) { +func openPostgresWithOptions(dsn, artifactsDir string, pool PoolOptions, blobs blobstore.Store, createArtifacts, migrateSchema bool) (*Store, error) { pool = pool.effective() if pool.QueryTimeoutSec > 0 { dsn = withPGRuntimeParam(dsn, "statement_timeout", strconv.Itoa(pool.QueryTimeoutSec*1000)) @@ -237,7 +243,7 @@ func openPostgresWithOptions(dsn, artifactsDir string, pool PoolOptions, blobs b if pool.ConnMaxLifetimeSec > 0 { db.SetConnMaxLifetime(time.Duration(pool.ConnMaxLifetimeSec) * time.Second) } - return finishOpen(db, postgresDialect{}, artifactsDir, blobs, pool, createArtifacts) + return finishOpenMode(db, postgresDialect{}, artifactsDir, blobs, pool, createArtifacts, migrateSchema) } // withPGRuntimeParam 向 DSN 追加运行时参数(libpq 语义:URL 查询串或键值对)。 @@ -263,7 +269,13 @@ func AppendPGRuntimeParam(dsn, key, value string) string { return dsn + sep + kv } +// Preserve the existing initialization helper contract; maintenance opens select +// nonmigrating mode explicitly through finishOpenMode. func finishOpen(db *sql.DB, d dialect, artifactsDir string, blobs blobstore.Store, pool PoolOptions, createArtifacts bool) (*Store, error) { + return finishOpenMode(db, d, artifactsDir, blobs, pool, createArtifacts, true) +} + +func finishOpenMode(db *sql.DB, d dialect, artifactsDir string, blobs blobstore.Store, pool PoolOptions, createArtifacts, migrateSchema bool) (*Store, error) { if blobstore.IsNil(blobs) { var err error if createArtifacts { @@ -277,7 +289,16 @@ func finishOpen(db *sql.DB, d dialect, artifactsDir string, blobs blobstore.Stor } } s := &Store{db: db, dialect: d, dataDir: artifactsDir, blobs: blobs, pool: pool} - if err := s.migrate(); err != nil { + if migrateSchema { + if err := s.migrate(); err != nil { + db.Close() + return nil, err + } + } else if err := s.requireCurrentSchema(); err != nil { + db.Close() + return nil, err + } + if err := s.verifyDurability(); err != nil { db.Close() return nil, err } diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 55edadf..7c274c5 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -165,6 +165,11 @@ func TestTenantIsolation(t *testing.T) { t.Fatalf("cross-tenant artifact content must be NotFound, got %v", err) } // 密钥与设置隔离。 + // This legacy integration identity is a fixture, separate from formal key + // issuance. Creating a project above does not implicitly create an account. + if err := st.EnsureUser(a, ""); err != nil { + t.Fatal(err) + } keyA, err := st.CreateAPIKey(a, "ka") if err != nil { t.Fatal(err) @@ -549,6 +554,9 @@ func TestAdminAccountsAndSessions(t *testing.T) { if err := st.SetUserPassword("admin", hash2); err != nil { t.Fatal(err) } + if _, ok := st.SessionOwner(tok); ok { + t.Fatal("password reset must revoke prior session") + } if err := st.ClearMustChangePassword("admin"); err != nil { t.Fatal(err) } @@ -556,6 +564,10 @@ func TestAdminAccountsAndSessions(t *testing.T) { if st1.MustChangePassword { t.Fatal("must_change should clear after self-change") } + tok, err = st.CreateSession("admin") + if err != nil { + t.Fatal(err) + } st.DeleteUserSessions("admin", tok) if _, ok := st.SessionOwner(tok); !ok { t.Fatal("kept session (except-path) must survive") diff --git a/internal/store/super_admin.go b/internal/store/super_admin.go new file mode 100644 index 0000000..74e21da --- /dev/null +++ b/internal/store/super_admin.go @@ -0,0 +1,256 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" + "strings" +) + +// ErrSuperAdminConflict means persisted account state cannot be reconciled +// with the configured super administrator without promoting, replacing or +// duplicating an identity. Startup must stop instead of repairing it. +var ErrSuperAdminConflict = errors.New("store: super administrator identity conflict") + +const superAdminBootstrapActor = "startup-config:super-admin" + +// accountAdministrationActionPrefixes name the system actions that confer +// account-administration authority. Only the super administrator may grant +// or withdraw them (R4, R5). +var accountAdministrationActionPrefixes = []string{"system.users.", "system.roles.", "system.permissions."} + +// IsAccountAdministrationAction reports whether actionID confers user, role or +// permission management authority. +func IsAccountAdministrationAction(actionID string) bool { + for _, prefix := range accountAdministrationActionPrefixes { + if strings.HasPrefix(actionID, prefix) { + return true + } + } + return false +} + +// requireSuperAdminTargetAccess rejects every actor other than the super +// administrator itself from mutating the super-administrator account (R5). +// Both accounts must have been read under the write transaction's locks. +func requireSuperAdminTargetAccess(actor, target Account) error { + if target.IsSuperAdmin && actor.ID != target.ID { + return ErrAccountForbidden + } + return nil +} + +func (s *Store) bumpAuthorityEpochTx(tx *sql.Tx) error { + query := `SELECT authority_epoch FROM authority_state WHERE id=1` + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + var epoch int64 + if err := tx.QueryRow(query).Scan(&epoch); err != nil { + return err + } + _, err := tx.Exec(s.q(`UPDATE authority_state SET authority_epoch=?,updated_at=? WHERE id=1 AND authority_epoch=?`), epoch+1, nowISO(), epoch) + return err +} + +// SetAccountAdmin appoints or withdraws an ordinary system administrator. Only +// the enabled super administrator may do so (R3, R4); the super-administrator +// account itself can never be demoted or re-appointed (R5). Actor and target +// are rechecked under the account locks that also cover the write. +func (s *Store) SetAccountAdmin(actorOwner, userID string, expectedStateVersion int64, isAdmin bool) (Account, error) { + if expectedStateVersion < 1 { + return Account{}, ErrConflict + } + pre, err := s.GetAccount(userID) + if errors.Is(err, ErrNotFound) { + return Account{}, ErrAccountForbidden + } + if err != nil { + return Account{}, err + } + tx, err := s.db.Begin() + if err != nil { + return Account{}, err + } + defer tx.Rollback() + locked, err := s.lockAccountUsers(tx, actorOwner, pre.Owner) + if errors.Is(err, ErrNotFound) { + return Account{}, ErrAccountForbidden + } + if err != nil { + return Account{}, err + } + actor, target := locked[actorOwner], locked[pre.Owner] + if !actor.Enabled || !actor.IsSuperAdmin || target.IsSuperAdmin || target.ID != userID { + return Account{}, ErrAccountForbidden + } + if !target.Enabled || target.StateVersion != expectedStateVersion || target.IsAdmin == isAdmin { + return Account{}, ErrConflict + } + result, err := tx.Exec(s.q(`UPDATE users SET is_admin=?,state_version=state_version+1 WHERE id=? AND state_version=? AND is_super_admin=?`), + isAdmin, userID, expectedStateVersion, false) + if err != nil { + return Account{}, err + } + if n, err := result.RowsAffected(); err != nil { + return Account{}, err + } else if n != 1 { + return Account{}, ErrConflict + } + if isAdmin { + if err := s.seedSystemAdminGrantsTx(tx, target.Owner); err != nil { + return Account{}, err + } + } else { + for _, actionID := range rolePolicyAdminActionsV49 { + if _, err := tx.Exec(s.q(`DELETE FROM system_grants WHERE user_id=? AND action_id=?`), userID, actionID); err != nil { + return Account{}, err + } + } + var managers int + if err := tx.QueryRow(s.q(`SELECT COUNT(DISTINCT g.user_id) FROM system_grants g JOIN users u ON u.id=g.user_id + WHERE g.action_id=? AND g.allowed=? AND u.enabled=?`), "system.permissions.manage", true, true).Scan(&managers); err != nil { + return Account{}, err + } + if managers == 0 { + return Account{}, ErrLastPermissionManager + } + } + if err := s.bumpAuthorityEpochTx(tx); err != nil { + return Account{}, err + } + event, action := "admin_appointed", "account.admin_appoint" + if !isAdmin { + event, action = "admin_withdrawn", "account.admin_withdraw" + } + if err := s.insertAccountEvent(tx, actorOwner, "account-management", target.Owner, event, target.ID); err != nil { + return Account{}, err + } + if err := s.auditAccountMutation(tx, target.Owner, actorOwner, action); err != nil { + return Account{}, err + } + if target, err = s.accountByOwner(tx, target.Owner); err != nil { + return Account{}, err + } + if err := tx.Commit(); err != nil { + return Account{}, err + } + return target, nil +} + +// SuperAdminBootstrap reports what startup reconciliation did. It never +// carries credential material. +type SuperAdminBootstrap struct { + Account Account + Created bool +} + +// BootstrapSuperAdmin establishes the single configured super administrator +// (KTD4). An absent account is created with the marker, administrator flag and +// passwordHash in one audited transaction. A matching marked account succeeds +// without touching its credentials, except that an account without any +// password receives passwordHash. Every other state is a fail-closed conflict: +// the owner held by an unmarked account, a marker on a different owner, or more +// than one marker. The marker is never derived from is_admin or roles. +func (s *Store) BootstrapSuperAdmin(owner, passwordHash string) (SuperAdminBootstrap, error) { + if passwordHash == "" { + return SuperAdminBootstrap{}, fmt.Errorf("%w: password hash is required", ErrSuperAdminConflict) + } + tx, err := s.db.Begin() + if err != nil { + return SuperAdminBootstrap{}, err + } + defer tx.Rollback() + if err := s.dialect.lockBootstrap(tx); err != nil { + return SuperAdminBootstrap{}, err + } + if err := s.dialect.lockAccount(tx, owner); err != nil { + return SuperAdminBootstrap{}, err + } + rows, err := tx.Query(s.q(`SELECT owner FROM users WHERE is_super_admin=? ORDER BY owner`), true) + if err != nil { + return SuperAdminBootstrap{}, err + } + var marked []string + for rows.Next() { + var markedOwner string + if err := rows.Scan(&markedOwner); err != nil { + rows.Close() + return SuperAdminBootstrap{}, err + } + marked = append(marked, markedOwner) + } + if err := rows.Close(); err != nil { + return SuperAdminBootstrap{}, err + } + if err := rows.Err(); err != nil { + return SuperAdminBootstrap{}, err + } + switch { + case len(marked) > 1: + return SuperAdminBootstrap{}, fmt.Errorf("%w: %d accounts carry the super-admin marker", ErrSuperAdminConflict, len(marked)) + case len(marked) == 1 && marked[0] != owner: + return SuperAdminBootstrap{}, fmt.Errorf("%w: another account already carries the super-admin marker", ErrSuperAdminConflict) + case len(marked) == 1: + account, err := s.accountByOwner(tx, owner) + if err != nil { + return SuperAdminBootstrap{}, err + } + if !account.IsAdmin { + return SuperAdminBootstrap{}, fmt.Errorf("%w: marked account lacks administrator flag", ErrSuperAdminConflict) + } + if account.HasPassword { + return SuperAdminBootstrap{Account: account}, nil + } + if _, err := tx.Exec(s.q(`UPDATE users SET password_hash=?,password_set_at=?,must_change_password=?,credential_origin=?,initialization_state=? WHERE id=? AND password_hash=''`), + passwordHash, nowISO(), false, CredentialOriginOwnerInitialization, InitializationInitialized, account.ID); err != nil { + return SuperAdminBootstrap{}, err + } + if err := s.insertAccountEvent(tx, superAdminBootstrapActor, "startup-config", owner, "super_admin_password_initialized", account.ID); err != nil { + return SuperAdminBootstrap{}, err + } + if err := s.auditAccountMutation(tx, owner, superAdminBootstrapActor, "account.super_admin_initialize"); err != nil { + return SuperAdminBootstrap{}, err + } + if account, err = s.accountByOwner(tx, owner); err != nil { + return SuperAdminBootstrap{}, err + } + if err := tx.Commit(); err != nil { + return SuperAdminBootstrap{}, err + } + return SuperAdminBootstrap{Account: account}, nil + } + if _, err := s.accountByOwner(tx, owner); err == nil { + return SuperAdminBootstrap{}, fmt.Errorf("%w: configured owner belongs to an existing account without the super-admin marker", ErrSuperAdminConflict) + } else if !errors.Is(err, ErrNotFound) { + return SuperAdminBootstrap{}, err + } + account, err := s.insertPendingAccountTx(tx, owner, AccountProfile{Name: owner}) + if err != nil { + return SuperAdminBootstrap{}, err + } + if _, err := tx.Exec(s.q(`UPDATE users SET is_admin=?,is_super_admin=?,password_hash=?,password_set_at=?,must_change_password=?, + credential_origin=?,initialization_state=?,initialization_generation=? WHERE id=?`), + true, true, passwordHash, nowISO(), false, CredentialOriginOwnerInitialization, InitializationInitialized, 1, account.ID); err != nil { + if isUniqueViolation(err) { + return SuperAdminBootstrap{}, fmt.Errorf("%w: super-admin marker already taken", ErrSuperAdminConflict) + } + return SuperAdminBootstrap{}, err + } + if err := s.seedSystemAdminGrantsTx(tx, owner); err != nil { + return SuperAdminBootstrap{}, err + } + if err := s.insertAccountEvent(tx, superAdminBootstrapActor, "startup-config", owner, "super_admin_bootstrapped", account.ID); err != nil { + return SuperAdminBootstrap{}, err + } + if err := s.auditAccountMutation(tx, owner, superAdminBootstrapActor, "account.super_admin_bootstrap"); err != nil { + return SuperAdminBootstrap{}, err + } + if account, err = s.accountByOwner(tx, owner); err != nil { + return SuperAdminBootstrap{}, err + } + if err := tx.Commit(); err != nil { + return SuperAdminBootstrap{}, err + } + return SuperAdminBootstrap{Account: account, Created: true}, nil +} diff --git a/internal/store/super_admin_authority_test.go b/internal/store/super_admin_authority_test.go new file mode 100644 index 0000000..78abdab --- /dev/null +++ b/internal/store/super_admin_authority_test.go @@ -0,0 +1,316 @@ +package store + +import ( + "errors" + "sync" + "testing" + "time" +) + +func markSuperAdmin(t *testing.T, st *Store, owner string) { + t.Helper() + if _, err := st.db.Exec(st.q(`UPDATE users SET is_super_admin=? WHERE owner=?`), true, owner); err != nil { + t.Fatal(err) + } +} + +// superAdminAuthorityStore holds the configured super administrator "root", an +// ordinary administrator "ops" and an ordinary user "alice". +func superAdminAuthorityStore(t *testing.T) (*Store, Account, Account, Account) { + t.Helper() + st := openTestStore(t) + if _, err := st.BootstrapSuperAdmin("root", superAdminHash(t, superAdminTestPassword)); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("ops", "Ops", superAdminHash(t, "ops-password-1"), true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("alice", "Alice", superAdminHash(t, "alice-password-1"), false); err != nil { + t.Fatal(err) + } + root, _ := st.GetAccountByOwner("root") + ops, _ := st.GetAccountByOwner("ops") + alice, _ := st.GetAccountByOwner("alice") + return st, root, ops, alice +} + +func explicitGrants(t *testing.T, st *Store, userID string) map[string]bool { + t.Helper() + actions, _, err := st.SystemGrantsForUser(userID) + if err != nil { + t.Fatal(err) + } + out := map[string]bool{} + for _, action := range actions { + out[action] = true + } + return out +} + +func TestSuperAdminProjectionsCarryMarker(t *testing.T) { + st, _, _, _ := superAdminAuthorityStore(t) + rootState, err := st.GetUserAuthState("root") + if err != nil || !rootState.IsSuperAdmin || !rootState.IsAdmin { + t.Fatalf("root auth state: %+v %v", rootState, err) + } + opsState, err := st.GetUserAuthState("ops") + if err != nil || opsState.IsSuperAdmin || !opsState.IsAdmin { + t.Fatalf("ops auth state: %+v %v", opsState, err) + } + users, err := st.ListUsersAdmin() + if err != nil { + t.Fatal(err) + } + for _, u := range users { + if u.IsSuperAdmin != (u.Owner == "root") { + t.Fatalf("admin listing marker for %s = %v", u.Owner, u.IsSuperAdmin) + } + } +} + +func TestSuperAdminCreatesAdministratorAccount(t *testing.T) { + st, _, _, _ := superAdminAuthorityStore(t) + before, _ := st.AuthorityEpoch() + boss, err := st.CreatePasswordAccountWithAuthority("root", "boss", AccountProfile{Name: "Boss"}, superAdminHash(t, "boss-password-1"), true) + if err != nil { + t.Fatal(err) + } + if !boss.IsAdmin || boss.IsSuperAdmin || !boss.MustChangePassword { + t.Fatalf("appointed account: %+v", boss) + } + if grants := explicitGrants(t, st, boss.ID); !grants["system.users.create"] || !grants["system.permissions.manage"] { + t.Fatalf("appointed administrator lacks seeded grants: %v", grants) + } + if after, _ := st.AuthorityEpoch(); after <= before { + t.Fatalf("authority epoch not advanced: %d -> %d", before, after) + } +} + +func TestOrdinaryAdminCreatesOnlyOrdinaryAccounts(t *testing.T) { + st, _, _, _ := superAdminAuthorityStore(t) + if _, err := st.CreatePasswordAccountWithAuthority("ops", "boss", AccountProfile{}, superAdminHash(t, "boss-password-1"), true); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("ordinary admin appointed an administrator: %v", err) + } + if _, err := st.GetAccountByOwner("boss"); !errors.Is(err, ErrNotFound) { + t.Fatalf("rejected appointment left an account: %v", err) + } + if n, _ := st.CountAudit("boss", "success"); n != 0 { + t.Fatalf("rejected appointment audited success: %d", n) + } + carol, err := st.CreateAdminPasswordAccount("ops", "carol", AccountProfile{Name: "Carol"}, superAdminHash(t, "carol-password-1")) + if err != nil || carol.IsAdmin || carol.IsSuperAdmin { + t.Fatalf("ordinary account creation: %+v %v", carol, err) + } + if _, _, err := st.CreateLocalPendingAccountWithAudit("local-admin", "", true, ""); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("local CLI path appointed an administrator: %v", err) + } + if _, err := st.GetAccountByOwner("local-admin"); !errors.Is(err, ErrNotFound) { + t.Fatalf("rejected local appointment left an account: %v", err) + } +} + +func TestSetAccountAdminIsReservedToSuperAdmin(t *testing.T) { + st, root, ops, alice := superAdminAuthorityStore(t) + if _, err := st.SetAccountAdmin("ops", alice.ID, alice.StateVersion, true); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("ordinary admin promoted a user: %v", err) + } + if _, err := st.SetAccountAdmin("alice", alice.ID, alice.StateVersion, true); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("user promoted itself: %v", err) + } + if _, err := st.SetAccountAdmin("ops", ops.ID, ops.StateVersion, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("ordinary admin changed its own authority: %v", err) + } + promoted, err := st.SetAccountAdmin("root", alice.ID, alice.StateVersion, true) + if err != nil || !promoted.IsAdmin || promoted.IsSuperAdmin || promoted.StateVersion != alice.StateVersion+1 { + t.Fatalf("super admin promotion: %+v %v", promoted, err) + } + if grants := explicitGrants(t, st, alice.ID); !grants["system.users.create"] { + t.Fatalf("promotion did not seed administrator grants: %v", grants) + } + if _, err := st.SetAccountAdmin("root", alice.ID, alice.StateVersion, false); !errors.Is(err, ErrConflict) { + t.Fatalf("stale state version accepted: %v", err) + } + if _, err := st.SetAccountAdmin("ops", alice.ID, promoted.StateVersion, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("ordinary admin demoted an administrator: %v", err) + } + demoted, err := st.SetAccountAdmin("root", alice.ID, promoted.StateVersion, false) + if err != nil || demoted.IsAdmin { + t.Fatalf("super admin demotion: %+v %v", demoted, err) + } + for action := range explicitGrants(t, st, alice.ID) { + if IsAccountAdministrationAction(action) { + t.Fatalf("demotion kept administrative grant %s", action) + } + } + for _, actor := range []string{"root", "ops"} { + current, _ := st.GetAccount(root.ID) + if _, err := st.SetAccountAdmin(actor, root.ID, current.StateVersion, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("%s demoted the super admin: %v", actor, err) + } + } + if current, _ := st.GetAccount(root.ID); !current.IsAdmin || !current.IsSuperAdmin || !current.Enabled { + t.Fatalf("super admin identity changed: %+v", current) + } +} + +func TestOrdinaryAdminCannotElevateThroughGrantsOrRoles(t *testing.T) { + st, root, ops, alice := superAdminAuthorityStore(t) + epoch, _ := st.AuthorityEpoch() + if _, err := st.ReplaceSystemGrants("ops", alice.ID, epoch, []string{"system.users.create"}, "ops-elevate-alice"); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("ordinary admin granted account administration: %v", err) + } + if _, err := st.ReplaceSystemGrants("ops", alice.ID, epoch, []string{"system.permissions.manage"}, "ops-elevate-alice-2"); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("ordinary admin granted permission management: %v", err) + } + if _, err := st.ReplaceSystemGrants("ops", ops.ID, epoch, []string{"project.create"}, "ops-self-demote"); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("ordinary admin changed its own administrative grants: %v", err) + } + if _, err := st.ReplaceSystemGrants("ops", root.ID, epoch, []string{"system.users.view"}, "ops-root"); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("ordinary admin changed the super admin's grants: %v", err) + } + if grants := explicitGrants(t, st, alice.ID); len(grants) != 0 { + t.Fatalf("rejected grant writes left grants: %v", grants) + } + if after, _ := st.AuthorityEpoch(); after != epoch { + t.Fatalf("rejected grant writes advanced the epoch: %d -> %d", epoch, after) + } + if _, err := st.ReplaceSystemGrants("ops", alice.ID, epoch, []string{"project.create"}, "ops-non-admin-grant"); err != nil { + t.Fatalf("ordinary admin non-administrative grant: %v", err) + } + epoch, _ = st.AuthorityEpoch() + if _, err := st.ReplaceSystemGrants("root", alice.ID, epoch, []string{"project.create", "system.users.view"}, "root-grant"); err != nil { + t.Fatalf("super admin administrative grant: %v", err) + } + + role, err := st.CreateRole("ops", RoleCreate{Name: "普通职位"}) + if err != nil { + t.Fatal(err) + } + rootNow, _ := st.GetAccount(root.ID) + if _, err := st.AssignUserRole("ops", root.ID, role.RoleID, rootNow.StateVersion, "ops-role-root"); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("ordinary admin assigned a role to the super admin: %v", err) + } + // Catalog validation keeps protected actions out of roles; plant one + // directly to prove assignment is not an elevation path regardless. + if _, err := st.db.Exec(st.q(`INSERT INTO role_permissions(role_id,action_id,allowed,cap_allowed,permission_version,updated_by,updated_at) + VALUES(?,?,?,?,?,?,?)`), role.RoleID, "system.users.create", true, true, 1, ops.ID, nowISO()); err != nil { + t.Fatal(err) + } + aliceNow, _ := st.GetAccount(alice.ID) + if _, err := st.AssignUserRole("ops", alice.ID, role.RoleID, aliceNow.StateVersion, "ops-admin-role"); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("ordinary admin assigned an administrative role: %v", err) + } + if view, err := st.RoleForUser(alice.ID); err != nil || view.Binding != nil { + t.Fatalf("rejected role assignment left a binding: %+v %v", view, err) + } +} + +func TestOrdinaryAdminCannotMutateSuperAdmin(t *testing.T) { + st, root, _, _ := superAdminAuthorityStore(t) + rootHash := storedPasswordHash(t, st, "root") + if _, err := st.PatchAccountProfile("ops", root.ID, root.ProfileVersion, map[string]string{"name": "renamed"}); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("ordinary admin renamed the super admin: %v", err) + } + if _, err := st.SetAccountEnabled("ops", root.ID, root.StateVersion, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("ordinary admin disabled the super admin: %v", err) + } + if err := st.AdminResetUserPasswordChecked("ops", "root", superAdminHash(t, "attacker-pass-1")); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("ordinary admin reset the super admin password: %v", err) + } + if err := st.AdminResetUserPassword("root", superAdminHash(t, "attacker-pass-1"), "cli", "cli"); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("CLI/legacy reset changed the super admin password: %v", err) + } + if _, _, err := st.IssueLoginKey("root", LoginKeyPurposeRecover, "cli", time.Minute); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("login key issued for the super admin: %v", err) + } + otpHash, err := HashPassword("otp-super-admin-blocked") + if err != nil { + t.Fatal(err) + } + if _, err := st.IssueOneTimePassword("ops", "root", otpHash); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("ordinary admin issued one-time password for the super admin: %v", err) + } + if _, err := st.InitializePendingAccountWithAudit(root.ID, root.StateVersion, root.InitializationGeneration, ""); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("CLI initialization targeted the super admin: %v", err) + } + current, err := st.GetAccount(root.ID) + if err != nil { + t.Fatal(err) + } + if current != root || storedPasswordHash(t, st, "root") != rootHash { + t.Fatalf("rejected operations changed the super admin: before=%+v after=%+v", root, current) + } + if keys, err := st.ListLoginKeys("root"); err != nil || len(keys) != 0 { + t.Fatalf("rejected issuance left keys: %v %v", keys, err) + } +} + +func TestSuperAdminCannotRemoveOwnIdentity(t *testing.T) { + st, root, _, _ := superAdminAuthorityStore(t) + if _, err := st.SetAccountEnabled("root", root.ID, root.StateVersion, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("super admin disabled itself: %v", err) + } + if err := st.AdminResetUserPasswordChecked("root", "root", superAdminHash(t, "self-reset-pass-1")); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("super admin used administrator reset on itself: %v", err) + } + renamed, err := st.PatchAccountProfile("root", root.ID, root.ProfileVersion, map[string]string{"name": "Root Owner"}) + if err != nil || renamed.Profile.Name != "Root Owner" || !renamed.IsSuperAdmin { + t.Fatalf("super admin self profile edit: %+v %v", renamed, err) + } + if n := countSuperAdmins(t, st); n != 1 { + t.Fatalf("super admin markers = %d", n) + } +} + +func TestAdminResetCheckedRechecksActorAuthority(t *testing.T) { + st, _, _, _ := superAdminAuthorityStore(t) + if err := st.CreateAccount("bob", "Bob", superAdminHash(t, "bob-password-1"), false); err != nil { + t.Fatal(err) + } + bobHash := storedPasswordHash(t, st, "bob") + if err := st.AdminResetUserPasswordChecked("alice", "bob", superAdminHash(t, "attacker-pass-1")); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("non-admin actor reset a password: %v", err) + } + if err := st.AdminResetUserPasswordChecked("ghost", "bob", superAdminHash(t, "attacker-pass-1")); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("missing actor reset a password: %v", err) + } + if storedPasswordHash(t, st, "bob") != bobHash { + t.Fatal("rejected reset changed the password") + } + if err := st.AdminResetUserPasswordChecked("ops", "bob", superAdminHash(t, "temporary-pass-1")); err != nil { + t.Fatalf("ordinary admin reset of ordinary user: %v", err) + } +} + +func TestConcurrentAdminAppointmentAllowsOne(t *testing.T) { + st, _, _, alice := superAdminAuthorityStore(t) + var wg sync.WaitGroup + results := make(chan error, 4) + for i := 0; i < 4; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _, err := st.SetAccountAdmin("root", alice.ID, alice.StateVersion, true) + results <- err + }() + } + wg.Wait() + close(results) + successes := 0 + for err := range results { + switch { + case err == nil: + successes++ + case errors.Is(err, ErrConflict): + default: + t.Fatalf("unexpected concurrent appointment error: %v", err) + } + } + if successes != 1 { + t.Fatalf("concurrent appointments succeeded %d times", successes) + } + current, _ := st.GetAccount(alice.ID) + if !current.IsAdmin || current.StateVersion != alice.StateVersion+1 { + t.Fatalf("concurrent appointment state: %+v", current) + } +} diff --git a/internal/store/super_admin_fallback_test.go b/internal/store/super_admin_fallback_test.go new file mode 100644 index 0000000..481df47 --- /dev/null +++ b/internal/store/super_admin_fallback_test.go @@ -0,0 +1,154 @@ +package store + +import ( + "errors" + "testing" + "time" +) + +func rootFallback(owner, password string) bool { + return owner == "root" && password == superAdminTestPassword +} + +func anyOwnerFallback(_, password string) bool { + return password == superAdminTestPassword +} + +func bootstrapRoot(t *testing.T, st *Store) { + t.Helper() + if _, err := st.BootstrapSuperAdmin("root", superAdminHash(t, superAdminTestPassword)); err != nil { + t.Fatal(err) + } +} + +func TestSuperAdminFallbackSurvivesDatabasePasswordChanges(t *testing.T) { + st := openTestStore(t) + bootstrapRoot(t, st) + if _, _, ok := st.VerifyLoginWithFallback("root", superAdminTestPassword, rootFallback); !ok { + t.Fatal("initial environment password must authenticate") + } + if err := st.ChangeUserPasswordActiveWithFallback("root", superAdminTestPassword, superAdminHash(t, "db-password-1"), "", rootFallback); err != nil { + t.Fatal(err) + } + if err := st.ChangeUserPasswordActiveWithFallback("root", "db-password-1", superAdminHash(t, "db-password-2"), "", rootFallback); err != nil { + t.Fatal(err) + } + if _, _, ok := st.VerifyLoginWithFallback("root", "db-password-1", rootFallback); ok { + t.Fatal("replaced database password must stop working") + } + if _, _, ok := st.VerifyLoginWithFallback("root", "db-password-2", rootFallback); !ok { + t.Fatal("current database password must authenticate") + } + state, ver, ok := st.VerifyLoginWithFallback("root", superAdminTestPassword, rootFallback) + if !ok || state.Owner != "root" || state.MustChangePassword { + t.Fatalf("environment fallback must still authenticate: ok=%v %+v", ok, state) + } + account, err := st.GetAccountByOwner("root") + if err != nil { + t.Fatal(err) + } + if ver != account.AuthVersion { + t.Fatalf("fallback login must report the account auth version: %d != %d", ver, account.AuthVersion) + } + if _, _, ok := st.VerifyLoginWithVersion("root", superAdminTestPassword); ok { + t.Fatal("fallback must not be persisted: plain verifier accepted the environment password") + } + if VerifyPassword(storedPasswordHash(t, st, "root"), superAdminTestPassword) { + t.Fatal("database hash must not be overwritten with the environment password") + } +} + +func TestSuperAdminFallbackAsCurrentPassword(t *testing.T) { + st := openTestStore(t) + bootstrapRoot(t, st) + if err := st.ChangeUserPasswordActiveWithFallback("root", superAdminTestPassword, superAdminHash(t, "db-password-1"), "", rootFallback); err != nil { + t.Fatal(err) + } + if err := st.ChangeUserPasswordActive("root", superAdminTestPassword, superAdminHash(t, "db-password-2"), ""); !errors.Is(err, ErrBadCredentials) { + t.Fatalf("without fallback the environment password is not current: %v", err) + } + if err := st.ChangeUserPasswordActiveWithFallback("root", superAdminTestPassword, superAdminHash(t, "db-password-2"), "", rootFallback); err != nil { + t.Fatalf("fallback must be accepted as current password: %v", err) + } + if _, _, ok := st.VerifyLoginWithFallback("root", "db-password-1", rootFallback); ok { + t.Fatal("previous database password must stop working") + } +} + +func TestSuperAdminFallbackCompletesForcedPasswordChange(t *testing.T) { + st := openTestStore(t) + bootstrapRoot(t, st) + if err := st.ChangeUserPasswordActiveWithFallback("root", superAdminTestPassword, superAdminHash(t, "db-password-1"), "", rootFallback); err != nil { + t.Fatal(err) + } + if _, err := st.db.Exec(st.q(`UPDATE users SET must_change_password=? WHERE owner=?`), true, "root"); err != nil { + t.Fatal(err) + } + state, ver, ok := st.VerifyLoginWithFallback("root", superAdminTestPassword, rootFallback) + if !ok || !state.MustChangePassword { + t.Fatalf("fallback login on forced account: ok=%v %+v", ok, state) + } + tok, err := st.CreateSessionVerified("root", SessionScopeSetPasswordOnly, 10*time.Minute, ver) + if err != nil { + t.Fatal(err) + } + if _, err := st.CompleteForcedPasswordChecked(tok, superAdminTestPassword, superAdminHash(t, "db-password-2")); !errors.Is(err, ErrBadCredentials) { + t.Fatalf("without fallback the environment password is not current: %v", err) + } + if _, err := st.CompleteForcedPasswordCheckedWithFallback(tok, superAdminTestPassword, superAdminHash(t, "db-password-2"), rootFallback); err != nil { + t.Fatalf("fallback must complete forced change: %v", err) + } + if _, _, ok := st.SessionInfo(tok); ok { + t.Fatal("forced change must revoke the restricted session") + } + if _, _, ok := st.VerifyLoginWithFallback("root", "db-password-1", rootFallback); ok { + t.Fatal("previous database password must stop working") + } + for _, pw := range []string{"db-password-2", superAdminTestPassword} { + if _, _, ok := st.VerifyLoginWithFallback("root", pw, rootFallback); !ok { + t.Fatalf("credential %q must authenticate after forced change", pw) + } + } +} + +func TestSuperAdminFallbackRejectedForUnmarkedAccounts(t *testing.T) { + st := openTestStore(t) + bootstrapRoot(t, st) + if err := st.CreateAccount("alice", "", superAdminHash(t, "alice-pass-1"), false); err != nil { + t.Fatal(err) + } + if err := st.ClearMustChangePassword("alice"); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("legacy-admin", "", superAdminHash(t, "legacy-pass-1"), true); err != nil { + t.Fatal(err) + } + if err := st.ClearMustChangePassword("legacy-admin"); err != nil { + t.Fatal(err) + } + for _, owner := range []string{"alice", "legacy-admin", "ghost"} { + if _, _, ok := st.VerifyLoginWithFallback(owner, superAdminTestPassword, anyOwnerFallback); ok { + t.Fatalf("fallback authenticated unmarked account %q", owner) + } + } + if err := st.ChangeUserPasswordActiveWithFallback("alice", superAdminTestPassword, superAdminHash(t, "alice-pass-2"), "", anyOwnerFallback); !errors.Is(err, ErrBadCredentials) { + t.Fatalf("fallback accepted as current password for unmarked account: %v", err) + } + if _, _, ok := st.VerifyLoginWithFallback("alice", "alice-pass-1", rootFallback); !ok { + t.Fatal("ordinary database login must be unaffected") + } +} + +func TestSuperAdminFallbackRejectedWhenDisabled(t *testing.T) { + st := openTestStore(t) + bootstrapRoot(t, st) + if _, err := st.db.Exec(st.q(`UPDATE users SET enabled=? WHERE owner=?`), false, "root"); err != nil { + t.Fatal(err) + } + if _, _, ok := st.VerifyLoginWithFallback("root", superAdminTestPassword, rootFallback); ok { + t.Fatal("disabled super admin must not authenticate with the fallback") + } + if err := st.ChangeUserPasswordActiveWithFallback("root", superAdminTestPassword, superAdminHash(t, "db-password-1"), "", rootFallback); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled super admin password change: %v", err) + } +} diff --git a/internal/store/super_admin_test.go b/internal/store/super_admin_test.go new file mode 100644 index 0000000..d3c4b52 --- /dev/null +++ b/internal/store/super_admin_test.go @@ -0,0 +1,239 @@ +package store + +import ( + "errors" + "strings" + "testing" +) + +const superAdminTestPassword = "Sup3r-Secret-Value" + +func superAdminHash(t *testing.T, password string) string { + t.Helper() + hash, err := HashPassword(password) + if err != nil { + t.Fatal(err) + } + return hash +} + +func countSuperAdmins(t *testing.T, st *Store) int { + t.Helper() + var n int + if err := st.db.QueryRow(st.q(`SELECT COUNT(*) FROM users WHERE is_super_admin=?`), true).Scan(&n); err != nil { + t.Fatal(err) + } + return n +} + +func storedPasswordHash(t *testing.T, st *Store, owner string) string { + t.Helper() + var hash string + if err := st.db.QueryRow(st.q(`SELECT password_hash FROM users WHERE owner=?`), owner).Scan(&hash); err != nil { + t.Fatal(err) + } + return hash +} + +func TestSuperAdminMigrationAddsDefaultFalseMarker(t *testing.T) { + st := openTestStore(t) + if err := st.CreateAccount("legacy-admin", "", superAdminHash(t, "legacy-pass-1"), true); err != nil { + t.Fatal(err) + } + account, err := st.GetAccountByOwner("legacy-admin") + if err != nil { + t.Fatal(err) + } + if !account.IsAdmin || account.IsSuperAdmin { + t.Fatalf("legacy admin must not be inferred as super admin: %+v", account) + } + if !migrationBreaksOldReaders[51] { + t.Fatal("v51 must exclude old account writers") + } +} + +func TestBootstrapSuperAdminFirstAndRepeated(t *testing.T) { + st := openTestStore(t) + out, err := st.BootstrapSuperAdmin("root", superAdminHash(t, superAdminTestPassword)) + if err != nil { + t.Fatal(err) + } + a := out.Account + if !out.Created || !a.IsSuperAdmin || !a.IsAdmin || !a.Enabled || !a.HasPassword || a.MustChangePassword { + t.Fatalf("first bootstrap account: %+v", out) + } + if a.InitializationState != InitializationInitialized { + t.Fatalf("initialization state = %q", a.InitializationState) + } + firstHash := storedPasswordHash(t, st, "root") + if !VerifyPassword(firstHash, superAdminTestPassword) { + t.Fatal("initial password hash does not verify") + } + if strings.Contains(firstHash, superAdminTestPassword) { + t.Fatal("password stored in plaintext") + } + if n, err := st.CountAudit("root", "success"); err != nil || n != 1 { + t.Fatalf("bootstrap audit count=%d err=%v", n, err) + } + var grants int + if err := st.db.QueryRow(st.q(`SELECT COUNT(*) FROM system_grants WHERE user_id=? AND allowed=?`), a.ID, true).Scan(&grants); err != nil { + t.Fatal(err) + } + if grants == 0 { + t.Fatal("super admin received no administrator system grants") + } + + again, err := st.BootstrapSuperAdmin("root", superAdminHash(t, "a-different-password")) + if err != nil { + t.Fatalf("repeated bootstrap: %v", err) + } + if again.Created || again.Account.ID != a.ID || !again.Account.IsSuperAdmin { + t.Fatalf("repeated bootstrap not idempotent: %+v", again) + } + if storedPasswordHash(t, st, "root") != firstHash { + t.Fatal("repeated bootstrap replaced existing credentials") + } + if n, _ := st.CountAudit("root", "success"); n != 1 { + t.Fatalf("repeated bootstrap wrote audit rows: %d", n) + } + if countSuperAdmins(t, st) != 1 { + t.Fatal("repeated bootstrap changed marker count") + } +} + +func TestBootstrapSuperAdminRejectsConflictingOwner(t *testing.T) { + for _, isAdmin := range []bool{false, true} { + st := openTestStore(t) + if err := st.CreateAccount("root", "", superAdminHash(t, "existing-pass"), isAdmin); err != nil { + t.Fatal(err) + } + before := storedPasswordHash(t, st, "root") + _, err := st.BootstrapSuperAdmin("root", superAdminHash(t, superAdminTestPassword)) + if !errors.Is(err, ErrSuperAdminConflict) { + t.Fatalf("isAdmin=%v: conflicting owner accepted: %v", isAdmin, err) + } + account, err := st.GetAccountByOwner("root") + if err != nil { + t.Fatal(err) + } + if account.IsSuperAdmin || account.IsAdmin != isAdmin || storedPasswordHash(t, st, "root") != before { + t.Fatalf("isAdmin=%v: conflicting account was modified: %+v", isAdmin, account) + } + } +} + +func TestBootstrapSuperAdminRejectsMarkerOnAnotherOwner(t *testing.T) { + st := openTestStore(t) + if _, err := st.BootstrapSuperAdmin("first-root", superAdminHash(t, superAdminTestPassword)); err != nil { + t.Fatal(err) + } + _, err := st.BootstrapSuperAdmin("second-root", superAdminHash(t, superAdminTestPassword)) + if !errors.Is(err, ErrSuperAdminConflict) { + t.Fatalf("second super admin accepted: %v", err) + } + if _, err := st.GetAccountByOwner("second-root"); !errors.Is(err, ErrNotFound) { + t.Fatalf("rejected bootstrap left an account behind: %v", err) + } + if countSuperAdmins(t, st) != 1 { + t.Fatal("marker count changed") + } +} + +func TestSuperAdminMarkerIsUniqueInSchema(t *testing.T) { + st := openTestStore(t) + if _, err := st.BootstrapSuperAdmin("root", superAdminHash(t, superAdminTestPassword)); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("other", "", superAdminHash(t, "other-pass-1"), true); err != nil { + t.Fatal(err) + } + if _, err := st.db.Exec(st.q(`UPDATE users SET is_super_admin=? WHERE owner=?`), true, "other"); err == nil { + t.Fatal("schema accepted a second super-admin marker") + } +} + +func TestBootstrapSuperAdminRejectsDuplicateMarkers(t *testing.T) { + st := openTestStore(t) + for _, owner := range []string{"root", "other"} { + if err := st.CreateAccount(owner, "", superAdminHash(t, "existing-pass"), true); err != nil { + t.Fatal(err) + } + } + if _, err := st.db.Exec(`DROP INDEX idx_users_single_super_admin`); err != nil { + t.Fatal(err) + } + if _, err := st.db.Exec(st.q(`UPDATE users SET is_super_admin=?`), true); err != nil { + t.Fatal(err) + } + _, err := st.BootstrapSuperAdmin("root", superAdminHash(t, superAdminTestPassword)) + if !errors.Is(err, ErrSuperAdminConflict) || !strings.Contains(err.Error(), "2 accounts") { + t.Fatalf("duplicate markers accepted: %v", err) + } +} + +func TestBootstrapSuperAdminLegacyAdminsStayOrdinary(t *testing.T) { + st := openTestStore(t) + if err := st.CreateAccount("legacy-admin", "", superAdminHash(t, "legacy-pass-1"), true); err != nil { + t.Fatal(err) + } + out, err := st.BootstrapSuperAdmin("root", superAdminHash(t, superAdminTestPassword)) + if err != nil { + t.Fatalf("legacy database with admins but no super admin: %v", err) + } + if !out.Created || !out.Account.IsSuperAdmin { + t.Fatalf("super admin not established: %+v", out) + } + legacy, err := st.GetAccountByOwner("legacy-admin") + if err != nil { + t.Fatal(err) + } + if !legacy.IsAdmin || legacy.IsSuperAdmin { + t.Fatalf("legacy admin changed: %+v", legacy) + } + if countSuperAdmins(t, st) != 1 { + t.Fatal("expected exactly one super admin") + } +} + +func TestBootstrapSuperAdminInitializesMarkedAccountWithoutPassword(t *testing.T) { + st := openTestStore(t) + if err := st.CreateAccount("root", "", "", true); err != nil { + t.Fatal(err) + } + if _, err := st.db.Exec(st.q(`UPDATE users SET is_super_admin=? WHERE owner=?`), true, "root"); err != nil { + t.Fatal(err) + } + out, err := st.BootstrapSuperAdmin("root", superAdminHash(t, superAdminTestPassword)) + if err != nil { + t.Fatal(err) + } + if out.Created || !out.Account.HasPassword || !VerifyPassword(storedPasswordHash(t, st, "root"), superAdminTestPassword) { + t.Fatalf("uninitialized super admin not given initial password: %+v", out) + } +} + +func TestBootstrapSuperAdminRejectsMarkedNonAdmin(t *testing.T) { + st := openTestStore(t) + if err := st.CreateAccount("root", "", superAdminHash(t, "existing-pass"), false); err != nil { + t.Fatal(err) + } + if _, err := st.db.Exec(st.q(`UPDATE users SET is_super_admin=? WHERE owner=?`), true, "root"); err != nil { + t.Fatal(err) + } + if _, err := st.BootstrapSuperAdmin("root", superAdminHash(t, superAdminTestPassword)); !errors.Is(err, ErrSuperAdminConflict) { + t.Fatalf("marked non-admin accepted: %v", err) + } +} + +func TestSuperAdminMigrationDown(t *testing.T) { + st := openTestStore(t) + if _, err := st.BootstrapSuperAdmin("root", superAdminHash(t, superAdminTestPassword)); err != nil { + t.Fatal(err) + } + if err := st.MigrateDown(50); err != nil { + t.Fatal(err) + } + if _, err := st.db.Exec(`SELECT is_super_admin FROM users`); err == nil { + t.Fatal("v51 down left is_super_admin column") + } +} diff --git a/internal/store/surface_account_write_test.go b/internal/store/surface_account_write_test.go new file mode 100644 index 0000000..5d64702 --- /dev/null +++ b/internal/store/surface_account_write_test.go @@ -0,0 +1,104 @@ +package store + +import ( + "errors" + "testing" + + core "agent-system-core" +) + +func TestSurfaceAccountCheckedWritesStopAfterDisable(t *testing.T) { + st := openTestStore(t) + for _, a := range []struct { + owner string + admin bool + }{{"admin", true}, {"alice", false}} { + if err := st.CreateAccount(a.owner, "", "", a.admin); err != nil { + t.Fatal(err) + } + } + activation, err := st.SetActivationAccountChecked("alice", "genui", true, false) + if err != nil || !activation.Enabled { + t.Fatalf("activation: %+v %v", activation, err) + } + snap := core.SurfaceSnapshot{Identity: core.SurfaceIdentity{SurfaceID: "surf_checked", Owner: "alice", PluginID: "genui"}, + Compat: core.SurfaceCompat{SchemaVersion: core.SurfaceSchemaVersion, CatalogVersion: core.SurfaceCatalogVersion}, + ActivationEpoch: activation.Epoch, Spec: core.SurfaceSpec{Title: "Before"}} + snap, err = st.SaveSurfaceSnapshotAccountChecked("alice", snap, false) + if err != nil { + t.Fatal(err) + } + if err := st.SetSurfaceEventPendingAccountChecked("alice", snap.Identity.SurfaceID, snap.Revision, true, false); err != nil { + t.Fatal(err) + } + action := SurfaceActionRecord{Owner: "alice", SurfaceID: string(snap.Identity.SurfaceID), ActionID: "builtin.help", RequestID: "req1", Revision: snap.Revision, State: "submitted"} + if _, first, err := st.SaveSurfaceActionIfAbsentAccountChecked("alice", action, false); err != nil || !first { + t.Fatalf("action insert: %v %v", first, err) + } + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + for name, write := range map[string]func() error{ + "activation": func() error { _, err := st.SetActivationAccountChecked("alice", "genui", false, false); return err }, + "snapshot": func() error { _, err := st.SaveSurfaceSnapshotAccountChecked("alice", snap, false); return err }, + "pending": func() error { + return st.SetSurfaceEventPendingAccountChecked("alice", snap.Identity.SurfaceID, snap.Revision, false, false) + }, + "action insert": func() error { + action.RequestID = "req2" + _, _, err := st.SaveSurfaceActionIfAbsentAccountChecked("alice", action, false) + return err + }, + "action final": func() error { + return st.UpdateSurfaceActionStateAccountChecked("alice", action.SurfaceID, action.ActionID, "req1", "committed", "{}", "", false) + }, + } { + if err := write(); !errors.Is(err, ErrAccountForbidden) { + t.Errorf("%s: got %v, want account forbidden", name, err) + } + } + got, err := st.GetSurfaceAction("alice", action.SurfaceID, action.ActionID, "req1") + if err != nil || got.State != "submitted" { + t.Fatalf("disabled actor changed action: %+v %v", got, err) + } + if _, err := st.GetSurfaceAction("alice", action.SurfaceID, action.ActionID, "req2"); !errors.Is(err, ErrNotFound) { + t.Fatalf("late action inserted: %v", err) + } +} + +func TestSurfaceAccountCheckedOwnerAndImplicitDefault(t *testing.T) { + st := openTestStore(t) + if _, err := st.SetActivationAccountChecked(OwnerDefault, "genui", true, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("implicit owner without allowance: %v", err) + } + if _, err := st.SetActivationAccountChecked(OwnerDefault, "genui", true, true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("admin", "", "", true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("alice", "", "", false); err != nil { + t.Fatal(err) + } + if _, err := st.SetActivationAccountChecked("alice", "genui", true, false); err != nil { + t.Fatal(err) + } + snap := core.SurfaceSnapshot{Identity: core.SurfaceIdentity{SurfaceID: "surf_alice", Owner: "alice", PluginID: "genui"}, Spec: core.SurfaceSpec{Title: "Owner"}} + if _, err := st.SaveSurfaceSnapshotAccountChecked("admin", snap, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("cross-owner snapshot: %v", err) + } + snap, err := st.SaveSurfaceSnapshotAccountChecked("alice", snap, false) + if err != nil { + t.Fatal(err) + } + if err := st.SetSurfaceEventPendingAccountChecked("admin", snap.Identity.SurfaceID, snap.Revision, true, false); !errors.Is(err, ErrNotFound) { + t.Fatalf("cross-owner pending: %v", err) + } + if _, _, err := st.SaveSurfaceActionIfAbsentAccountChecked("admin", SurfaceActionRecord{Owner: "admin", SurfaceID: string(snap.Identity.SurfaceID), ActionID: "builtin.help", RequestID: "foreign"}, false); !errors.Is(err, ErrNotFound) { + t.Fatalf("cross-owner action: %v", err) + } +} diff --git a/internal/store/surface_actions.go b/internal/store/surface_actions.go index 7125efd..e59875c 100644 --- a/internal/store/surface_actions.go +++ b/internal/store/surface_actions.go @@ -30,8 +30,39 @@ var ErrIdempotencyConflict = errors.New("surface action: request id reused with // SaveSurfaceActionIfAbsent 幂等写入:首个提交插入成功(first=true); // 已存在时返回既有行(first=false)。参数一致性由调用方比对 ParamsHash。 func (s *Store) SaveSurfaceActionIfAbsent(rec SurfaceActionRecord) (SurfaceActionRecord, bool, error) { + return s.saveSurfaceActionIfAbsentExec(s.db, rec) +} + +// SaveSurfaceActionIfAbsentAccountChecked fences the ledger insert and checks +// that the referenced surface still belongs to the actor in that transaction. +func (s *Store) SaveSurfaceActionIfAbsentAccountChecked(actor string, rec SurfaceActionRecord, allowImplicitDefault bool) (SurfaceActionRecord, bool, error) { + if actor != rec.Owner { + return rec, false, ErrAccountForbidden + } + var existing SurfaceActionRecord + var first bool + err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + var owner string + if err := tx.QueryRow(s.q(`SELECT owner FROM surfaces WHERE surface_id=? AND owner=?`), rec.SurfaceID, actor).Scan(&owner); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + var err error + existing, first, err = s.saveSurfaceActionIfAbsentExec(tx, rec) + return err + }) + return existing, first, err +} + +type surfaceActionDB interface { + Exec(string, ...any) (sql.Result, error) + QueryRow(string, ...any) *sql.Row +} + +func (s *Store) saveSurfaceActionIfAbsentExec(db surfaceActionDB, rec SurfaceActionRecord) (SurfaceActionRecord, bool, error) { rec.CreatedAt = time.Now().UTC().Format(time.RFC3339) - res, err := s.db.Exec(s.q(`INSERT INTO surface_actions(owner,surface_id,action_id,request_id,revision,params_hash,state,receipt,card_id,created_at) + res, err := db.Exec(s.q(`INSERT INTO surface_actions(owner,surface_id,action_id,request_id,revision,params_hash,state,receipt,card_id,created_at) VALUES(?,?,?,?,?,?,?,?,?,?) ON CONFLICT(owner, surface_id, action_id, request_id) DO NOTHING`), rec.Owner, rec.SurfaceID, rec.ActionID, rec.RequestID, rec.Revision, @@ -40,7 +71,7 @@ func (s *Store) SaveSurfaceActionIfAbsent(rec SurfaceActionRecord) (SurfaceActio return rec, false, err } n, _ := res.RowsAffected() - existing, err := s.GetSurfaceAction(rec.Owner, rec.SurfaceID, rec.ActionID, rec.RequestID) + existing, err := s.getSurfaceActionQuery(db, rec.Owner, rec.SurfaceID, rec.ActionID, rec.RequestID) if err != nil { return rec, false, err } @@ -49,8 +80,12 @@ func (s *Store) SaveSurfaceActionIfAbsent(rec SurfaceActionRecord) (SurfaceActio // GetSurfaceAction 读取台账行(不存在 → ErrNotFound)。 func (s *Store) GetSurfaceAction(owner, surfaceID, actionID, requestID string) (SurfaceActionRecord, error) { + return s.getSurfaceActionQuery(s.db, owner, surfaceID, actionID, requestID) +} + +func (s *Store) getSurfaceActionQuery(db surfaceActionDB, owner, surfaceID, actionID, requestID string) (SurfaceActionRecord, error) { var r SurfaceActionRecord - err := s.db.QueryRow(s.q(`SELECT owner,surface_id,action_id,request_id,revision,params_hash,state,receipt,card_id,created_at + err := db.QueryRow(s.q(`SELECT owner,surface_id,action_id,request_id,revision,params_hash,state,receipt,card_id,created_at FROM surface_actions WHERE owner=? AND surface_id=? AND action_id=? AND request_id=?`), owner, surfaceID, actionID, requestID). Scan(&r.Owner, &r.SurfaceID, &r.ActionID, &r.RequestID, &r.Revision, &r.ParamsHash, &r.State, &r.Receipt, &r.CardID, &r.CreatedAt) @@ -62,7 +97,19 @@ func (s *Store) GetSurfaceAction(owner, surfaceID, actionID, requestID string) ( // UpdateSurfaceActionState 回写分发结果(首个提交提交后的状态迁移)。 func (s *Store) UpdateSurfaceActionState(owner, surfaceID, actionID, requestID, state, receipt, cardID string) error { - res, err := s.db.Exec(s.q(`UPDATE surface_actions SET state=?, receipt=?, card_id=? + return s.updateSurfaceActionStateExec(s.db, owner, surfaceID, actionID, requestID, state, receipt, cardID) +} + +// UpdateSurfaceActionStateAccountChecked fences the final result write. A +// dispatch can have happened before disable, but its later local write cannot. +func (s *Store) UpdateSurfaceActionStateAccountChecked(actor, surfaceID, actionID, requestID, state, receipt, cardID string, allowImplicitDefault bool) error { + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + return s.updateSurfaceActionStateExec(tx, actor, surfaceID, actionID, requestID, state, receipt, cardID) + }) +} + +func (s *Store) updateSurfaceActionStateExec(db surfaceExecer, owner, surfaceID, actionID, requestID, state, receipt, cardID string) error { + res, err := db.Exec(s.q(`UPDATE surface_actions SET state=?, receipt=?, card_id=? WHERE owner=? AND surface_id=? AND action_id=? AND request_id=?`), state, receipt, cardID, owner, surfaceID, actionID, requestID) if err != nil { diff --git a/internal/store/surfaces.go b/internal/store/surfaces.go index 0ebf068..f75b799 100644 --- a/internal/store/surfaces.go +++ b/internal/store/surfaces.go @@ -80,9 +80,32 @@ func (s *Store) SetActivation(owner, pluginID string, enabled bool) (ActivationR return ActivationRow{}, err } defer tx.Rollback() + row, err := s.setActivationTx(tx, owner, pluginID, enabled) + if err != nil { + return ActivationRow{}, err + } + if err := tx.Commit(); err != nil { + return ActivationRow{}, err + } + return row, nil +} + +// SetActivationAccountChecked serializes the actor's effective state and the +// activation change in one final transaction. +func (s *Store) SetActivationAccountChecked(actor, pluginID string, enabled, allowImplicitDefault bool) (ActivationRow, error) { + var row ActivationRow + err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + var err error + row, err = s.setActivationTx(tx, actor, pluginID, enabled) + return err + }) + return row, err +} + +func (s *Store) setActivationTx(tx *sql.Tx, owner, pluginID string, enabled bool) (ActivationRow, error) { now := time.Now().UTC().Format(time.RFC3339) var epoch int64 - err = tx.QueryRow(s.q(`SELECT epoch FROM plugin_activation WHERE owner=? AND plugin_id=?`), owner, pluginID).Scan(&epoch) + err := tx.QueryRow(s.q(`SELECT epoch FROM plugin_activation WHERE owner=? AND plugin_id=?`), owner, pluginID).Scan(&epoch) if errors.Is(err, sql.ErrNoRows) { epoch = 0 } else if err != nil { @@ -98,9 +121,6 @@ func (s *Store) SetActivation(owner, pluginID string, enabled bool) (ActivationR owner, pluginID, flag, epoch, now); err != nil { return ActivationRow{}, err } - if err := tx.Commit(); err != nil { - return ActivationRow{}, err - } return ActivationRow{Owner: owner, PluginID: pluginID, Enabled: enabled, Epoch: epoch, UpdatedAt: now}, nil } @@ -113,6 +133,27 @@ func (s *Store) SaveSurfaceSnapshot(snap core.SurfaceSnapshot) (core.SurfaceSnap return snap, err } defer tx.Rollback() + snap, err = s.saveSurfaceSnapshotTx(tx, snap) + if err != nil { + return snap, err + } + return snap, tx.Commit() +} + +// SaveSurfaceSnapshotAccountChecked ties the revision CAS to the actor state. +func (s *Store) SaveSurfaceSnapshotAccountChecked(actor string, snap core.SurfaceSnapshot, allowImplicitDefault bool) (core.SurfaceSnapshot, error) { + if actor != snap.Identity.Owner { + return snap, ErrAccountForbidden + } + err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + var err error + snap, err = s.saveSurfaceSnapshotTx(tx, snap) + return err + }) + return snap, err +} + +func (s *Store) saveSurfaceSnapshotTx(tx *sql.Tx, snap core.SurfaceSnapshot) (core.SurfaceSnapshot, error) { now := time.Now().UTC().Format(time.RFC3339) specJSON, err := json.Marshal(snap.Spec) if err != nil { @@ -155,7 +196,7 @@ func (s *Store) SaveSurfaceSnapshot(snap core.SurfaceSnapshot) (core.SurfaceSnap snap.ActivationEpoch, string(specJSON), snap.SemanticText, 0, now); err != nil { return snap, err } - return snap, tx.Commit() + return snap, nil } func (s *Store) GetSurface(owner, surfaceID string) (SurfaceRecord, error) { @@ -255,11 +296,26 @@ func (s *Store) SurfaceOwner(surfaceID string) (string, error) { // SetSurfaceEventPending 标记/清除修订行的投影事件待恢复态(写快照后事件发布失败, // KTD「持久事件或重建快照恢复」;恢复扫描走 ListSurfaceEventsPending)。 func (s *Store) SetSurfaceEventPending(owner, surfaceID string, revision int, pending bool) error { + return s.setSurfaceEventPendingExec(s.db, owner, surfaceID, revision, pending) +} + +// SetSurfaceEventPendingAccountChecked fences the post-snapshot recovery mark. +func (s *Store) SetSurfaceEventPendingAccountChecked(actor, surfaceID string, revision int, pending, allowImplicitDefault bool) error { + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + return s.setSurfaceEventPendingExec(tx, actor, surfaceID, revision, pending) + }) +} + +type surfaceExecer interface { + Exec(string, ...any) (sql.Result, error) +} + +func (s *Store) setSurfaceEventPendingExec(db surfaceExecer, owner, surfaceID string, revision int, pending bool) error { flag := 0 if pending { flag = 1 } - res, err := s.db.Exec(s.q(`UPDATE surface_revisions SET event_pending=? + res, err := db.Exec(s.q(`UPDATE surface_revisions SET event_pending=? WHERE surface_id=? AND revision=? AND EXISTS(SELECT 1 FROM surfaces WHERE surface_id=? AND owner=?)`), flag, surfaceID, revision, surfaceID, owner) if err != nil { diff --git a/internal/store/task_deletion_test.go b/internal/store/task_deletion_test.go new file mode 100644 index 0000000..b08890d --- /dev/null +++ b/internal/store/task_deletion_test.go @@ -0,0 +1,55 @@ +package store + +import ( + core "agent-system-core" + "context" + "testing" +) + +func TestReviewDeletedArtifactMustLeaveKnowledge(t *testing.T) { + st := openTestStore(t) + a := core.Artifact{Owner: "o", ProjectID: "p", Name: "secret.txt"} + if err := st.CreateArtifact(&a, []byte("uniqueclassified")); err != nil { + t.Fatal(err) + } + if err := st.AddKnowledge(core.KnowledgeDoc{Owner: "o", ProjectID: "p", Source: "artifact", Ref: a.ID, Title: a.Name, Chunk: "uniqueclassified"}); err != nil { + t.Fatal(err) + } + if err := st.DeleteArtifact("o", a.ID); err != nil { + t.Fatal(err) + } + hits, err := st.SearchKnowledge("o", core.RetrievalQuery{Text: "uniqueclassified", ProjectID: "p"}) + if err != nil { + t.Fatal(err) + } + if len(hits) > 0 { + t.Fatalf("deleted source is still retrievable: %d hits", len(hits)) + } +} + +func TestDeletedArtifactClearsVectorRowsAndRetrieval(t *testing.T) { + st := openTestStore(t) + ctx := context.Background() + a := core.Artifact{Owner: "o", ProjectID: "p", Name: "source.txt"} + if e := st.CreateArtifact(&a, []byte("uniqueindexed")); e != nil { + t.Fatal(e) + } + d := core.KnowledgeDoc{ID: "indexed-doc", Owner: "o", ProjectID: "p", Ref: a.ID, Title: a.Name, Chunk: "uniqueindexed", Source: "artifact"} + if e := st.AddKnowledge(d); e != nil { + t.Fatal(e) + } + if e := st.UpsertKnowledgeVectorExt(ctx, "o", d.ID, a.ID, "m", "h", []float32{1, 0}); e != nil { + t.Fatal(e) + } + if e := st.DeleteArtifact("o", a.ID); e != nil { + t.Fatal(e) + } + var rows int + if e := st.DB().QueryRow(st.q(`SELECT COUNT(*) FROM knowledge_vectors WHERE owner=? AND ref=?`), "o", a.ID).Scan(&rows); e != nil || rows != 0 { + t.Fatal("deleted vector rows retained", rows, e) + } + hits, e := st.SearchKnowledgeVectorsModel(ctx, "o", "m", []float32{1, 0}, 5, core.RetrievalQuery{ProjectID: "p"}) + if e != nil || len(hits) != 0 { + t.Fatal("deleted source remains vector-retrievable", hits, e) + } +} diff --git a/internal/store/task_projection.go b/internal/store/task_projection.go new file mode 100644 index 0000000..d30f5ee --- /dev/null +++ b/internal/store/task_projection.go @@ -0,0 +1,343 @@ +package store + +import ( + core "agent-system-core" + "bytes" + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "sort" + "time" +) + +type ArtifactWitness struct { + ArtifactID string + UpdatedAt string +} +type TaskProjectionWrite struct { + SourceWitnesses []ArtifactWitness + Owner string + Card core.ActivityCard + Ref string + ExpectedCardVersion int + ExpectedTaskRevision int64 + Channels []string + Buffered []core.StreamEvent + // AccountActor, when set, commits the projection under that actor's final + // effective-account fence; it must equal Owner. + AccountActor string + AllowImplicitDefault bool +} +type CardsSnapshot struct { + Cards []core.ActivityCard `json:"cards"` + ReplaySeq int64 `json:"replaySeq"` +} + +func (s *Store) CommitTaskProjection(w TaskProjectionWrite) ([]core.StreamEvent, error) { + c := w.Card + if c.Task == nil || c.Task.CardID != c.ID || c.Task.ConversationID != c.ConversationID || c.Task.ProjectID != c.ProjectID { + return nil, fmt.Errorf("task.scope: card binding mismatch") + } + task := *c.Task + c.Task = &task + if err := task.Validate(); err != nil { + return nil, err + } + channels := append([]string(nil), w.Channels...) + if len(channels) == 0 { + channels = []string{c.ConversationID} + } + sort.Strings(channels) + unique := channels[:0] + for _, ch := range channels { + if ch != c.ConversationID && ch != "project:"+c.ProjectID { + return nil, fmt.Errorf("task.scope: foreign channel") + } + if len(unique) == 0 || unique[len(unique)-1] != ch { + unique = append(unique, ch) + } + } + channels = unique + foundConversation := false + for _, channel := range channels { + if channel == c.ConversationID { + foundConversation = true + } + } + if !foundConversation { + return nil, fmt.Errorf("task.scope: canonical stream required") + } + if w.AccountActor != "" { + if w.AccountActor != w.Owner { + return nil, ErrNotFound + } + var events []core.StreamEvent + err := s.withEffectiveAccountWrite(w.AccountActor, w.AllowImplicitDefault, func(tx *sql.Tx, _ Account) error { + if err := s.cardResourcesForWrite(tx, w.AccountActor, c.ProjectID, c.ConversationID); err != nil { + return err + } + var err error + events, err = s.commitTaskProjectionTx(tx, w, c, task, channels) + return err + }) + if err != nil { + return nil, err + } + return events, nil + } + tx, err := s.db.Begin() + if err != nil { + return nil, err + } + defer tx.Rollback() + events, err := s.commitTaskProjectionTx(tx, w, c, task, channels) + if err != nil { + return nil, err + } + if err = tx.Commit(); err != nil { + return nil, err + } + return events, nil +} + +func (s *Store) commitTaskProjectionTx(tx *sql.Tx, w TaskProjectionWrite, c core.ActivityCard, task core.TaskSnapshot, channels []string) ([]core.StreamEvent, error) { + c.Task = &task + var err error + for _, ch := range channels { + if err = s.dialect.lockConversation(tx, ch); err != nil { + return nil, err + } + } + var oldPayload, oldOwner string + var version int + err = tx.QueryRow(s.q(`SELECT payload,owner,version FROM activity_cards WHERE id=?`), c.ID).Scan(&oldPayload, &oldOwner, &version) + creating := errors.Is(err, sql.ErrNoRows) + viewOnly := false + if err != nil && !creating { + return nil, err + } + if creating { + if w.ExpectedCardVersion != 0 || w.ExpectedTaskRevision != 0 || task.Revision != 1 { + return nil, ErrConflict + } + } else { + if oldOwner != w.Owner { + return nil, ErrNotFound + } + var old core.ActivityCard + if err = json.Unmarshal([]byte(oldPayload), &old); err != nil { + return nil, err + } + if version != w.ExpectedCardVersion || old.Task == nil || old.Task.Revision != w.ExpectedTaskRevision || (task.Revision != old.Task.Revision+1 && task.Revision != old.Task.Revision) || old.Task.TaskID != task.TaskID || old.Task.AttemptID != task.AttemptID || old.ConversationID != c.ConversationID || old.ProjectID != c.ProjectID || old.Task.OwnerInstanceID != task.OwnerInstanceID { + return nil, ErrConflict + } + if task.Revision == old.Task.Revision { + a, _ := json.Marshal(old.Task) + b, _ := json.Marshal(task) + if !bytes.Equal(a, b) { + return nil, ErrConflict + } + viewOnly = true + if c.Status != old.Status && c.Status != core.CardDismissed && c.Status != core.CardResolved { + return nil, ErrConflict + } + } + if old.Task.Terminal() && old.Task.ExecutionState != task.ExecutionState { + return nil, ErrConflict + } + if old.Task.RunID != "" && old.Task.RunID != task.RunID { + return nil, ErrConflict + } + if task.ExecutionEpoch < old.Task.ExecutionEpoch { + return nil, ErrConflict + } + if task.InputRevision != old.Task.InputRevision || task.ParentRunID != old.Task.ParentRunID || task.SourceMessageID != old.Task.SourceMessageID { + return nil, ErrConflict + } + if !viewOnly { + c.Pinned = old.Pinned + if old.Status == core.CardDismissed || old.Status == core.CardResolved { + c.Status = old.Status + } + } + } + c.Body = task.SummaryBody() + c.SourceRunID = task.RunID + c.Title = task.Title + // Serialize source-version checks with the artifact overwrite transaction. + sort.Slice(w.SourceWitnesses, func(i, j int) bool { return w.SourceWitnesses[i].ArtifactID < w.SourceWitnesses[j].ArtifactID }) + for _, src := range w.SourceWitnesses { + if err = s.dialect.lockScope(tx, w.Owner, "artifact:"+src.ArtifactID); err != nil { + return nil, err + } + var project, version string + if err = tx.QueryRow(s.q(`SELECT project_id,updated_at FROM artifacts WHERE id=? AND owner=?`), src.ArtifactID, w.Owner).Scan(&project, &version); err != nil { + return nil, err + } + if project != c.ProjectID || version != src.UpdatedAt { + return nil, ErrConflict + } + } + var events []core.StreamEvent + if !viewOnly && task.ExecutionEpoch > 0 { + if err = s.lockAdmission(tx); err != nil { + return nil, err + } + var instance, until string + var epoch int64 + if err = tx.QueryRow(s.q(`SELECT instance_id,epoch,lease_until FROM conversation_executions WHERE conversation_id=?`), "task:"+task.TaskID).Scan(&instance, &epoch, &until); err != nil { + return nil, err + } + if instance != task.OwnerInstanceID || epoch != task.ExecutionEpoch || (!task.Terminal() && until <= nowISO()) { + return nil, ErrFenced + } + } + for _, ch := range channels { + var batch []core.StreamEvent + for _, e := range w.Buffered { + if e.ConversationID == ch { + batch = append(batch, e) + } + } + if len(batch) > 0 { + var base int64 + if base, err = s.appendEventsInTx(tx, ch, batch, "", ""); err != nil { + return nil, err + } + assignEventSequences(batch, base) + events = append(events, batch...) + } + } + if !viewOnly { + if err = tx.QueryRow(s.q(`SELECT COALESCE(MAX(seq),0)+1 FROM events WHERE conversation_id=?`), c.ConversationID).Scan(&task.SourceSeq); err != nil { + return nil, err + } + } + if err = task.Validate(); err != nil { + return nil, err + } + raw, err := json.Marshal(c) + if err != nil { + return nil, err + } + now := time.Now().UTC() + c.UpdatedAt = now + if c.CreatedAt.IsZero() { + c.CreatedAt = now + } + raw, err = json.Marshal(c) + if err != nil { + return nil, err + } + if creating { + _, err = tx.Exec(s.q(`INSERT INTO activity_cards(id,project_id,conversation_id,ref,owner,payload,status,pinned,created_at,updated_at,version) VALUES(?,?,?,?,?,?,?,?,?,?,1)`), c.ID, c.ProjectID, c.ConversationID, w.Ref, w.Owner, string(raw), c.Status, boolInt(c.Pinned), c.CreatedAt.Format(time.RFC3339Nano), c.UpdatedAt.Format(time.RFC3339Nano)) + } else { + var res sql.Result + res, err = tx.Exec(s.q(`UPDATE activity_cards SET payload=?,status=?,pinned=?,updated_at=?,version=version+1 WHERE id=? AND owner=? AND version=?`), string(raw), c.Status, boolInt(c.Pinned), c.UpdatedAt.Format(time.RFC3339Nano), c.ID, w.Owner, version) + if err == nil { + n, _ := res.RowsAffected() + if n != 1 { + return nil, ErrConflict + } + } + } + if err != nil { + if isUniqueViolation(err) { + return nil, ErrConflict + } + return nil, err + } + payload, err := json.Marshal(core.CardPayload{Card: c}) + if err != nil { + return nil, err + } + for _, ch := range channels { + e := core.StreamEvent{ConversationID: ch, RunID: task.RunID, TS: now.Format(time.RFC3339Nano), V: core.ProtocolVersion, Type: core.EvCardUpdated, Payload: payload} + if creating { + e.Type = core.EvCardCreated + } + if pid, ok := core.ProjectFromChannel(core.ID(ch)); ok { + e.ProjectID = pid + } + batch := []core.StreamEvent{e} + var base int64 + if base, err = s.appendEventsInTx(tx, ch, batch, "", ""); err != nil { + return nil, err + } + assignEventSequences(batch, base) + events = append(events, batch...) + } + return events, nil +} +func (s *Store) CardsAtCursor(owner, conversationID string) (CardsSnapshot, error) { + opts := &sql.TxOptions{} + if s.Dialect() == "postgres" { + opts.Isolation = sql.LevelRepeatableRead + opts.ReadOnly = true + } + tx, err := s.db.BeginTx(context.Background(), opts) + if err != nil { + return CardsSnapshot{}, err + } + defer tx.Rollback() + rows, err := tx.Query(s.q(`SELECT payload FROM activity_cards WHERE conversation_id=? AND owner=? ORDER BY updated_at DESC`), conversationID, owner) + if err != nil { + return CardsSnapshot{}, err + } + out := CardsSnapshot{Cards: []core.ActivityCard{}} + for rows.Next() { + var raw string + if err = rows.Scan(&raw); err != nil { + rows.Close() + return out, err + } + var c core.ActivityCard + if err = json.Unmarshal([]byte(raw), &c); err != nil { + rows.Close() + return out, err + } + out.Cards = append(out.Cards, c) + } + err = rows.Err() + rows.Close() + if err != nil { + return out, err + } + if err = tx.QueryRow(s.q(`SELECT COALESCE(MAX(seq),0) FROM events WHERE conversation_id=?`), conversationID).Scan(&out.ReplaySeq); err != nil { + return out, err + } + if err = tx.Commit(); err != nil { + return out, err + } + return out, nil +} + +type TaskCardRecord struct { + Owner string + Card core.ActivityCard +} + +func (s *Store) TaskCards() ([]TaskCardRecord, error) { + rows, err := s.db.Query(`SELECT owner,payload FROM activity_cards`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []TaskCardRecord + for rows.Next() { + var r TaskCardRecord + var raw string + if err = rows.Scan(&r.Owner, &raw); err != nil { + return nil, err + } + if err = json.Unmarshal([]byte(raw), &r.Card); err != nil { + return nil, err + } + if r.Card.Task != nil { + out = append(out, r) + } + } + return out, rows.Err() +} diff --git a/internal/store/task_projection_test.go b/internal/store/task_projection_test.go new file mode 100644 index 0000000..158cd3a --- /dev/null +++ b/internal/store/task_projection_test.go @@ -0,0 +1,158 @@ +package store + +import ( + core "agent-system-core" + "errors" + "testing" + "time" +) + +func projectionFixture(t *testing.T, st *Store) core.ActivityCard { + t.Helper() + p, e := st.CreateProject("o", "projection") + if e != nil { + t.Fatal(e) + } + conv, e := st.CreateConversation("o", p.ID, "projection", "") + if e != nil { + t.Fatal(e) + } + c := core.ActivityCard{ID: NewID("card"), ProjectID: p.ID, ConversationID: conv.ID, Kind: core.CardKindProgress, Title: "整理", Status: core.CardActive, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()} + c.Task = &core.TaskSnapshot{SchemaVersion: 1, TaskID: "t1", AttemptID: "a1", CardID: c.ID, ProjectID: p.ID, ConversationID: conv.ID, OwnerInstanceID: "i1", Revision: 1, StreamScope: "conversation:" + conv.ID, ExecutionState: "queued", OutcomeCompleteness: "none", DeliveryState: "pending", Title: "整理"} + c.Body = c.Task.SummaryBody() + return c +} +func TestTaskProjectionCommitsCardAndBothStreams(t *testing.T) { + st := openTestStore(t) + c := projectionFixture(t, st) + events, e := st.CommitTaskProjection(TaskProjectionWrite{Owner: "o", Card: c, Channels: []string{c.ConversationID, "project:" + c.ProjectID}}) + if e != nil || len(events) != 2 { + t.Fatal("missing atomic projection", events, e) + } + got, e := st.GetCard("o", c.ID) + if e != nil || got.Task == nil || got.Task.SourceSeq != 1 { + t.Fatal("card watermark not committed", e) + } + for _, channel := range []string{c.ConversationID, "project:" + c.ProjectID} { + rows, e := st.EventsAfter(channel, 0, 20) + if e != nil || len(rows) != 1 { + t.Fatal("event missing", e) + } + } + snapshot, e := st.CardsAtCursor("o", c.ConversationID) + if e != nil || snapshot.ReplaySeq != 1 || len(snapshot.Cards) != 1 { + t.Fatal("inconsistent snapshot", snapshot, e) + } +} +func TestTaskProjectionRejectsOwnerOldRevisionAndLateState(t *testing.T) { + st := openTestStore(t) + c := projectionFixture(t, st) + if _, e := st.CommitTaskProjection(TaskProjectionWrite{Owner: "o", Card: c}); e != nil { + t.Fatal(e) + } + next := c + copy := *c.Task + next.Task = © + copy.Revision = 2 + copy.ExecutionState = "completed" + next.Status = core.CardDone + if _, e := st.CommitTaskProjection(TaskProjectionWrite{Owner: "other", Card: next, ExpectedCardVersion: 1, ExpectedTaskRevision: 1}); e == nil { + t.Fatal("foreign write accepted") + } + if _, e := st.CommitTaskProjection(TaskProjectionWrite{Owner: "o", Card: next, ExpectedCardVersion: 1, ExpectedTaskRevision: 1}); e != nil { + t.Fatal(e) + } + copy.Revision = 3 + copy.ExecutionState = "running" + copy.RunID = "run1" + if _, e := st.CommitTaskProjection(TaskProjectionWrite{Owner: "o", Card: next, ExpectedCardVersion: 2, ExpectedTaskRevision: 2}); !errors.Is(e, ErrConflict) { + t.Fatal("terminal regressed", e) + } +} +func TestTaskProjectionEventFailureRollsBackCard(t *testing.T) { + st := openTestStore(t) + c := projectionFixture(t, st) + var injection = `CREATE TRIGGER reject_task_event BEFORE INSERT ON events BEGIN SELECT RAISE(ABORT,'event injection'); END` + if st.Dialect() == "postgres" { + if _, e := st.db.Exec(`CREATE FUNCTION reject_task_event_fn() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'event injection'; END $$`); e != nil { + t.Fatal(e) + } + injection = `CREATE TRIGGER reject_task_event BEFORE INSERT ON events FOR EACH ROW EXECUTE FUNCTION reject_task_event_fn()` + } + if _, e := st.db.Exec(injection); e != nil { + t.Fatal(e) + } + _, e := st.CommitTaskProjection(TaskProjectionWrite{Owner: "o", Card: c}) + if e == nil { + t.Fatal("injection did not fail") + } + if _, e = st.GetCard("o", c.ID); !errors.Is(e, ErrNotFound) { + t.Fatal("card survived rolled-back event", e) + } +} + +func TestTaskProjectionRejectsChangedSourceWitness(t *testing.T) { + st := openTestStore(t) + c := projectionFixture(t, st) + a := core.Artifact{Owner: "o", ProjectID: c.ProjectID, Name: "input.txt"} + if e := st.CreateArtifact(&a, []byte("old")); e != nil { + t.Fatal(e) + } + witness := ArtifactWitness{ArtifactID: a.ID, UpdatedAt: a.UpdatedAt.Format(time.RFC3339Nano)} + if e := st.UpdateArtifactContent("o", a.ID, []byte("new")); e != nil { + t.Fatal(e) + } + if _, e := st.CommitTaskProjection(TaskProjectionWrite{Owner: "o", Card: c, SourceWitnesses: []ArtifactWitness{witness}}); !errors.Is(e, ErrConflict) { + t.Fatal("stale source proof accepted", e) + } +} + +func TestTaskProjectionConcurrentCASHasOneWinner(t *testing.T) { + st := openTestStore(t) + c := projectionFixture(t, st) + if _, e := st.CommitTaskProjection(TaskProjectionWrite{Owner: "o", Card: c}); e != nil { + t.Fatal(e) + } + ch := make(chan error, 2) + for i := 0; i < 2; i++ { + go func() { + next := c + tcopy := *c.Task + tcopy.Revision = 2 + tcopy.Focus = "核对" + next.Task = &tcopy + _, e := st.CommitTaskProjection(TaskProjectionWrite{Owner: "o", Card: next, ExpectedTaskRevision: 1, ExpectedCardVersion: 1}) + ch <- e + }() + } + winners := 0 + for i := 0; i < 2; i++ { + if e := <-ch; e == nil { + winners++ + } else if !errors.Is(e, ErrConflict) { + t.Fatal(e) + } + } + if winners != 1 { + t.Fatal("CAS winners", winners) + } +} + +func TestTaskProjectionRequiresCanonicalStreamAndRejectsFalseCompletion(t *testing.T) { + st := openTestStore(t) + c := projectionFixture(t, st) + if _, e := st.CommitTaskProjection(TaskProjectionWrite{Owner: "o", Card: c, Channels: []string{"project:" + c.ProjectID}}); e == nil { + t.Fatal("projection has no canonical conversation event") + } + if _, e := st.CommitTaskProjection(TaskProjectionWrite{Owner: "o", Card: c}); e != nil { + t.Fatal(e) + } + current, ver, e := st.GetCardVersion("o", c.ID) + if e != nil { + t.Fatal(e) + } + current.Status = core.CardDone + if _, e = st.CommitTaskProjection(TaskProjectionWrite{Owner: "o", Card: current, ExpectedCardVersion: ver, ExpectedTaskRevision: current.Task.Revision}); e == nil { + t.Fatal("view update forged completion") + } +} diff --git a/internal/store/vectors.go b/internal/store/vectors.go index e98c544..ded73c0 100644 --- a/internal/store/vectors.go +++ b/internal/store/vectors.go @@ -2,7 +2,9 @@ package store import ( "context" + "database/sql" "encoding/binary" + "errors" "math" "sort" "strconv" @@ -84,6 +86,60 @@ func (s *Store) UpsertKnowledgeVectorExt(ctx context.Context, owner, docID, ref, return err } +func (s *Store) UpsertKnowledgeVectorAccountChecked(ctx context.Context, actor, docID, ref string, vec []float32, allowImplicitDefault bool) error { + return s.UpsertKnowledgeVectorExtAccountChecked(ctx, actor, docID, ref, "", "", vec, allowImplicitDefault) +} + +// UpsertKnowledgeVectorExtAccountChecked requires an authoritative knowledge +// row owned by actor with the same ref. Conflict updates cannot take over a +// vector row whose owner/ref differs, even if legacy data is inconsistent. +func (s *Store) UpsertKnowledgeVectorExtAccountChecked(ctx context.Context, actor, docID, ref, model, contentHash string, vec []float32, allowImplicitDefault bool) error { + if err := ctx.Err(); err != nil { + return err + } + vecPG := s.dialect.name() == "postgres" && s.ensureVecPG() + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + if err := ctx.Err(); err != nil { + return err + } + query := s.q(`SELECT owner,ref FROM knowledge WHERE id=? AND owner=? AND ref=?`) + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + var owner, storedRef string + if err := tx.QueryRowContext(ctx, query, docID, actor, ref).Scan(&owner, &storedRef); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + if owner != actor || storedRef != ref { + return ErrNotFound + } + var res sql.Result + var err error + if vecPG { + res, err = tx.ExecContext(ctx, s.q(`INSERT INTO knowledge_vectors(doc_id,owner,ref,dim,vec,model,content_hash,vec_pg,created_at) VALUES(?,?,?,?,?,?,?,?,?) + ON CONFLICT(doc_id) DO UPDATE SET owner=excluded.owner, ref=excluded.ref, dim=excluded.dim, vec=excluded.vec, model=excluded.model, content_hash=excluded.content_hash, vec_pg=excluded.vec_pg, created_at=excluded.created_at + WHERE knowledge_vectors.owner=excluded.owner AND knowledge_vectors.ref=excluded.ref`), + docID, actor, ref, len(vec), encodeVector(vec), model, contentHash, vectorLiteral(vec), nowISO()) + } else { + res, err = tx.ExecContext(ctx, s.q(`INSERT INTO knowledge_vectors(doc_id,owner,ref,dim,vec,model,content_hash,created_at) VALUES(?,?,?,?,?,?,?,?) + ON CONFLICT(doc_id) DO UPDATE SET owner=excluded.owner, ref=excluded.ref, dim=excluded.dim, vec=excluded.vec, model=excluded.model, content_hash=excluded.content_hash, created_at=excluded.created_at + WHERE knowledge_vectors.owner=excluded.owner AND knowledge_vectors.ref=excluded.ref`), + docID, actor, ref, len(vec), encodeVector(vec), model, contentHash, nowISO()) + } + if err != nil { + return err + } + if n, err := res.RowsAffected(); err != nil { + return err + } else if n != 1 { + return ErrNotFound + } + return nil + }) +} + // ensureVecPG 探测 vec_pg 列是否可用(pgvector 扩展在场;一次性缓存)。 func (s *Store) ensureVecPG() bool { s.vecPGOnce.Do(func() { @@ -100,6 +156,16 @@ func (s *Store) DeleteKnowledgeVectors(ctx context.Context, owner, ref string) e return err } +func (s *Store) DeleteKnowledgeVectorsAccountChecked(ctx context.Context, actor, ref string, allowImplicitDefault bool) error { + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + if err := ctx.Err(); err != nil { + return err + } + _, err := tx.ExecContext(ctx, s.q(`DELETE FROM knowledge_vectors WHERE ref=? AND owner=?`), ref, actor) + return err + }) +} + // SearchKnowledgeVectors 余弦扫描:候选 = 租户本人 + 共享(owner=”),dim 匹配, // 再按 RetrievalQuery 的项目/来源过滤。 // SearchKnowledgeVectorsShared EXT-8 余项:他人 owner 的向量行 + 谓词后置判定 @@ -213,11 +279,11 @@ func (s *Store) searchKnowledgeVectorsSharedAuthorizedScan(ctx context.Context, return nil, err } defer rows.Close() - type cand struct { - hit core.RetrievalHit - sim float64 + type rawVectorCandidate struct { + doc core.KnowledgeDoc + blob []byte } - var cands []cand + var rawCands []rawVectorCandidate for rows.Next() { var d core.KnowledgeDoc var own, ca string @@ -227,6 +293,20 @@ func (s *Store) searchKnowledgeVectorsSharedAuthorizedScan(ctx context.Context, } d.Owner = own d.CreatedAt, _ = parseTime(ca) + rawCands = append(rawCands, rawVectorCandidate{doc: d, blob: blob}) + } + if err := rows.Err(); err != nil { + return nil, err + } + _ = rows.Close() + + type cand struct { + hit core.RetrievalHit + sim float64 + } + var cands []cand + for _, raw := range rawCands { + d := raw.doc if d.ProjectID == "" || !allow(d) { continue } @@ -236,12 +316,9 @@ func (s *Store) searchKnowledgeVectorsSharedAuthorizedScan(ctx context.Context, if len(filter.Sources) > 0 && !containsStr(filter.Sources, d.Source) { continue } - sim := cosine(vec, decodeVector(blob)) + sim := cosine(vec, decodeVector(raw.blob)) cands = append(cands, cand{hit: core.RetrievalHit{Doc: d, Score: sim, Kind: "vector"}, sim: sim}) } - if err := rows.Err(); err != nil { - return nil, err - } sort.SliceStable(cands, func(i, j int) bool { return cands[i].sim > cands[j].sim }) out := []core.RetrievalHit{} for _, c := range cands { diff --git a/internal/store/work_dispatch.go b/internal/store/work_dispatch.go new file mode 100644 index 0000000..3044c24 --- /dev/null +++ b/internal/store/work_dispatch.go @@ -0,0 +1,31 @@ +package store + +import ( + core "agent-system-core" + "encoding/json" +) + +// WorkDispatches is an internal startup-recovery scan. Individual reads and +// commits still use the normal owner and effective-account fences. +func (s *Store) WorkDispatches() (map[string][]core.ActivityCard, error) { + rows, err := s.db.Query(`SELECT owner,payload FROM activity_cards`) + if err != nil { + return nil, err + } + defer rows.Close() + result := map[string][]core.ActivityCard{} + for rows.Next() { + var owner, raw string + if err := rows.Scan(&owner, &raw); err != nil { + return nil, err + } + var card core.ActivityCard + if json.Unmarshal([]byte(raw), &card) != nil { + continue + } + if card.Work != nil && card.Work.Decision != nil && card.Work.Decision.State == "dispatching" { + result[owner] = append(result[owner], card) + } + } + return result, rows.Err() +} diff --git a/internal/store/workflow_step_source.go b/internal/store/workflow_step_source.go index e5bbfad..7806c6b 100644 --- a/internal/store/workflow_step_source.go +++ b/internal/store/workflow_step_source.go @@ -40,17 +40,18 @@ func (s *Store) BindWorkflowStepSource(owner, runID, stepID, stepAttemptID, kind if err != nil || run.Owner != owner || run.ProjectID == "" { return WorkflowStepSource{}, ErrNotFound } - rows, err := s.db.Query(s.q(`SELECT created_at,manifest_json,resolve_target_id,scope_bound FROM context_manifests WHERE owner=? AND target_id=? AND delivery='responded'`), owner, runID) + rows, err := s.db.Query(s.q(`SELECT created_at,delivery,manifest_json,scope_bound FROM context_manifests WHERE owner=? AND target_id=?`), owner, runID) if err != nil { return WorkflowStepSource{}, err } defer rows.Close() var latest extension.ProjectContextManifest var latestAt time.Time + states := map[string]uint8{} for rows.Next() { - var atRaw, raw, resolveTarget string + var atRaw, delivery, raw string var scopeBound int - if err := rows.Scan(&atRaw, &raw, &resolveTarget, &scopeBound); err != nil { + if err := rows.Scan(&atRaw, &delivery, &raw, &scopeBound); err != nil { return WorkflowStepSource{}, err } at, err := time.Parse(time.RFC3339Nano, atRaw) @@ -67,7 +68,19 @@ func (s *Store) BindWorkflowStepSource(owner, runID, stepID, stepAttemptID, kind if m.WorkflowStepAttemptID != stepAttemptID { continue } - if scopeBound != 1 || resolveTarget != "" { + switch delivery { + case "attempted": + states[m.AttemptID] |= 1 + continue + case "unknown": + states[m.AttemptID] |= 4 + continue + case "responded": + states[m.AttemptID] |= 2 + default: + return WorkflowStepSource{}, errors.New("workflow model receipt has unknown delivery state") + } + if scopeBound != 1 { return WorkflowStepSource{}, errors.New("workflow step model receipt lacks trusted original Resolve scope") } if m.ProjectID != run.ProjectID || m.AuthorizationEpoch == "" || m.PublicationEpoch == "" || m.AttemptID == "" { @@ -83,6 +96,11 @@ func (s *Store) BindWorkflowStepSource(owner, runID, stepID, stepAttemptID, kind if err := rows.Err(); err != nil { return WorkflowStepSource{}, err } + for _, state := range states { + if state != 3 { + return WorkflowStepSource{}, errors.New("workflow step has an unsettled or unknown model attempt") + } + } if latestAt.IsZero() { return WorkflowStepSource{}, errors.New("workflow prompt output has no responded model receipt") } @@ -108,16 +126,17 @@ func (s *Store) RespondedWorkflowStepManifests(owner, runID, stepAttemptID strin if owner == "" || runID == "" || stepAttemptID == "" { return nil, errors.New("workflow receipt scope is incomplete") } - rows, err := s.db.Query(s.q(`SELECT manifest_json,resolve_goal,resolve_target_id,scope_bound FROM context_manifests WHERE owner=? AND target_id=? AND delivery='responded' ORDER BY created_at,id`), owner, runID) + rows, err := s.db.Query(s.q(`SELECT delivery,manifest_json,scope_bound FROM context_manifests WHERE owner=? AND target_id=? ORDER BY created_at,id`), owner, runID) if err != nil { return nil, err } defer rows.Close() var out []ScopedContextManifest + states := map[string]uint8{} for rows.Next() { - var raw, goal, target string + var delivery, raw string var bound int - if err := rows.Scan(&raw, &goal, &target, &bound); err != nil { + if err := rows.Scan(&delivery, &raw, &bound); err != nil { return nil, err } var manifest extension.ProjectContextManifest @@ -127,11 +146,28 @@ func (s *Store) RespondedWorkflowStepManifests(owner, runID, stepAttemptID strin if manifest.WorkflowStepAttemptID != stepAttemptID { continue } - out = append(out, ScopedContextManifest{Manifest: manifest, Scope: ContextManifestScope{Goal: goal, TargetID: target, Bound: bound == 1}}) + switch delivery { + case "attempted": + states[manifest.AttemptID] |= 1 + continue + case "unknown": + states[manifest.AttemptID] |= 4 + continue + case "responded": + states[manifest.AttemptID] |= 2 + default: + return nil, errors.New("workflow model receipt has unknown delivery state") + } + out = append(out, ScopedContextManifest{Manifest: manifest, Scope: ContextManifestScope{Bound: bound == 1}}) } if err := rows.Err(); err != nil { return nil, err } + for _, state := range states { + if state != 3 { + return nil, errors.New("workflow step has an unsettled or unknown model attempt") + } + } if len(out) == 0 { return nil, ErrNotFound } diff --git a/internal/store/workflow_step_source_test.go b/internal/store/workflow_step_source_test.go index f58d9f7..50eed31 100644 --- a/internal/store/workflow_step_source_test.go +++ b/internal/store/workflow_step_source_test.go @@ -30,7 +30,7 @@ func TestWorkflowStepSourceRequiresRespondedManifestAndExactRunStep(t *testing.T } started := time.Now().Add(-time.Second) m := extension.ProjectContextManifest{ProjectID: p.ID, AttemptID: "attempt-1", WorkflowStepAttemptID: "engine-run/draft/1", Delivery: "attempted", InputSHA256: "input", AuthorizationEpoch: "duty-1", PublicationEpoch: "pub-1", Watermark: "w1"} - scope := ContextManifestScope{Goal: "draft report", Bound: true} + scope := ContextManifestScope{Bound: true} if err := st.AppendContextManifestWithScope("alice", string(r.ID), m, scope); err != nil { t.Fatal(err) } @@ -79,6 +79,21 @@ func TestWorkflowStepSourceRequiresRespondedManifestAndExactRunStep(t *testing.T if _, err := st.BindWorkflowStepSource("alice", string(r.ID), "legacy", "engine-run/legacy/1", "prompt", started, []byte("legacy body")); err == nil { t.Fatal("model receipt without bound original Resolve scope became a workflow source") } + unknown := m + unknown.AttemptID, unknown.Delivery = "unknown-model-attempt", "attempted" + if err := st.AppendContextManifestWithScope("alice", string(r.ID), unknown, scope); err != nil { + t.Fatal(err) + } + unknown.Delivery = "unknown" + if err := st.AppendContextManifestWithScope("alice", string(r.ID), unknown, scope); err != nil { + t.Fatal(err) + } + if _, err := st.RespondedWorkflowStepManifests("alice", string(r.ID), "engine-run/draft/1"); err == nil { + t.Fatal("unknown model attempt hidden by earlier responded attempt") + } + if _, err := st.BindWorkflowStepSource("alice", string(r.ID), "draft", "engine-run/draft/1", "prompt", started, []byte("old body")); err == nil { + t.Fatal("unknown model attempt rebound completed step") + } if err := st.UnbindWorkflowStepSource("alice", string(r.ID), "draft"); err != nil { t.Fatal(err) } diff --git a/internal/surfacesvc/account_write_test.go b/internal/surfacesvc/account_write_test.go new file mode 100644 index 0000000..2f0f406 --- /dev/null +++ b/internal/surfacesvc/account_write_test.go @@ -0,0 +1,133 @@ +package surfacesvc + +import ( + "context" + "encoding/json" + "errors" + "path/filepath" + "testing" + + "agent-system-backend/internal/store" + core "agent-system-core" +) + +func TestAccountCheckedSurfaceActionDoesNotWriteStateAfterDisableDuringDispatch(t *testing.T) { + path := filepath.Join(t.TempDir(), "surface.db") + st, err := store.OpenSQLite(path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + other, err := store.OpenSQLite(path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { other.Close() }) + for _, a := range []struct { + owner string + admin bool + }{{"admin", true}, {"alice", false}} { + if err := st.CreateAccount(a.owner, "", "", a.admin); err != nil { + t.Fatal(err) + } + } + svc := New(st, nil) + ctx := context.Background() + if _, err := svc.SetActivationAccountChecked(ctx, "alice", "genui", true, false); err != nil { + t.Fatal(err) + } + snap, err := svc.SubmitAccountChecked(ctx, "alice", "genui", SubmitRequest{Spec: json.RawMessage(`{"title":"Form","nodes":[{"type":"form","id":"f1","fields":[{"key":"name","label":"Name","type":"text"}]}]}`)}, false) + if err != nil { + t.Fatal(err) + } + var calls int + svc.SetDispatcher(func(context.Context, ActionDispatch) (ActionOutcome, error) { + calls++ + account, err := other.GetAccountByOwner("alice") + if err != nil { + return ActionOutcome{}, err + } + if _, err := other.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + return ActionOutcome{}, err + } + return ActionOutcome{State: "committed"}, nil + }) + result, err := svc.SubmitActionAccountChecked(ctx, "alice", SubmitActionRequest{ + SurfaceID: snap.Identity.SurfaceID, ActionID: "builtin.help", RequestID: "disable-during-dispatch", Revision: snap.Revision, + }, false) + if err != nil || result.State != "committed" || calls != 1 { + t.Fatalf("dispatch result: %+v %v calls=%d", result, err, calls) + } + record, err := st.GetSurfaceAction("alice", string(snap.Identity.SurfaceID), "builtin.help", "disable-during-dispatch") + if err != nil || record.State != "submitted" { + t.Fatalf("late final write persisted: %+v %v", record, err) + } + if _, err := svc.SubmitActionAccountChecked(ctx, "alice", SubmitActionRequest{ + SurfaceID: snap.Identity.SurfaceID, ActionID: "builtin.help", RequestID: "after-disable", Revision: snap.Revision, + }, false); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("disabled dispatch admitted: %v", err) + } + if calls != 1 { + t.Fatalf("disabled action dispatched: calls=%d", calls) + } + if _, err := svc.SubmitAccountChecked(ctx, "alice", "genui", SubmitRequest{Spec: json.RawMessage(`{"title":"Late","nodes":[{"type":"form","id":"f1","fields":[{"key":"name","label":"Name","type":"text"}]}]}`)}, false); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("disabled snapshot admitted: %v", err) + } + if _, err := svc.SetActivationAccountChecked(ctx, "alice", "genui", false, false); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("disabled activation admitted: %v", err) + } +} + +func TestAccountCheckedSurfaceRecoveryWaitsForEffectiveOwner(t *testing.T) { + st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "surface.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + for _, a := range []struct { + owner string + admin bool + }{{"admin", true}, {"alice", false}} { + if err := st.CreateAccount(a.owner, "", "", a.admin); err != nil { + t.Fatal(err) + } + } + var calls int + svc := New(st, func(string, string, core.StreamEventType, any) error { + calls++ + return errors.New("offline") + }) + svc.SetAccountCheckedEmitter(func(string, string, string, core.StreamEventType, any) error { + calls++ + return errors.New("offline") + }) + ctx := context.Background() + if _, err := svc.SetActivationAccountChecked(ctx, "alice", "genui", true, false); err != nil { + t.Fatal(err) + } + _, err = svc.SubmitAccountChecked(ctx, "alice", "genui", SubmitRequest{ConversationID: "c1", Spec: json.RawMessage(`{"title":"Form","nodes":[{"type":"form","id":"f1","fields":[{"key":"name","label":"Name","type":"text"}]}]}`)}, false) + if err != nil { + t.Fatal(err) + } + pending, err := st.ListSurfaceEventsPending(10) + if err != nil || len(pending) != 1 { + t.Fatalf("expected pending snapshot: %v rows=%d", err, len(pending)) + } + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + if n, err := svc.RecoverPendingEventsAccountChecked(ctx, false); err != nil || n != 0 { + t.Fatalf("disabled recovery: n=%d err=%v", n, err) + } + if calls != 1 { + t.Fatalf("disabled owner emitted recovery event, calls=%d", calls) + } + pending, err = st.ListSurfaceEventsPending(10) + if err != nil || len(pending) != 1 { + t.Fatalf("disabled recovery cleared marker: %v rows=%d", err, len(pending)) + } +} diff --git a/internal/surfacesvc/surfacesvc.go b/internal/surfacesvc/surfacesvc.go index d0d1d38..1febf5e 100644 --- a/internal/surfacesvc/surfacesvc.go +++ b/internal/surfacesvc/surfacesvc.go @@ -19,6 +19,7 @@ import ( // Emitter 由宿主注入(与 cardsvc 同一事件管道):持久化、seq 排序、断线重放。 type Emitter func(conversationID, runID string, typ core.StreamEventType, payload any) error +type AccountCheckedEmitter func(actor, conversationID, runID string, typ core.StreamEventType, payload any) error var ( ErrNotFound = store.ErrNotFound @@ -47,11 +48,12 @@ type SubmitRequest struct { } type Service struct { - st *store.Store - emit Emitter - dispatch Dispatcher - now func() (t string) - logln func(format string, v ...any) + st *store.Store + emit Emitter + accountCheckedEmit AccountCheckedEmitter + dispatch Dispatcher + now func() (t string) + logln func(format string, v ...any) } func New(st *store.Store, emit Emitter) *Service { @@ -60,9 +62,24 @@ func New(st *store.Store, emit Emitter) *Service { logln: log.Printf} } +func (s *Service) SetAccountCheckedEmitter(emit AccountCheckedEmitter) { s.accountCheckedEmit = emit } + // Submit 是唯一的写入咽喉:严格解码 → 目录校验 → 启禁/epoch → CAS 保存 → 事件。 // 写入成功但事件发布失败时标记待恢复并照常返回快照(可恢复的最终一致)。 func (s *Service) Submit(ctx context.Context, owner, pluginID string, req SubmitRequest) (core.SurfaceSnapshot, error) { + return s.submit(ctx, owner, pluginID, req, false, false) +} + +// SubmitAccountChecked fences the snapshot and any pending-event marker at +// their final Store writes. The caller supplies the auth-off default allowance. +func (s *Service) SubmitAccountChecked(ctx context.Context, owner, pluginID string, req SubmitRequest, allowImplicitDefault bool) (core.SurfaceSnapshot, error) { + return s.submit(ctx, owner, pluginID, req, true, allowImplicitDefault) +} + +func (s *Service) submit(ctx context.Context, owner, pluginID string, req SubmitRequest, checked, allowImplicitDefault bool) (core.SurfaceSnapshot, error) { + if checked && s.emit != nil && s.accountCheckedEmit == nil { + return core.SurfaceSnapshot{}, ErrUnavailable + } if owner == "" || pluginID == "" { return core.SurfaceSnapshot{}, ErrUnavailable } @@ -107,11 +124,16 @@ func (s *Service) Submit(ctx context.Context, owner, pluginID string, req Submit Spec: spec, SemanticText: req.SemanticText, } - saved, err := s.st.SaveSurfaceSnapshot(snap) + var saved core.SurfaceSnapshot + if checked { + saved, err = s.st.SaveSurfaceSnapshotAccountChecked(owner, snap, allowImplicitDefault) + } else { + saved, err = s.st.SaveSurfaceSnapshot(snap) + } if err != nil { return saved, err } - s.publish(saved, false) + s.publish(saved, false, checked, allowImplicitDefault) return saved, nil } @@ -133,7 +155,21 @@ func (s *Service) Activation(ctx context.Context, owner, pluginID string) (store // SetActivation 启/禁 Surface 能力:先持久化并递增 epoch,再返回(禁用返回即 // 准入截止点);审计留痕。新插件默认关闭(无行=disabled)。 func (s *Service) SetActivation(ctx context.Context, owner, pluginID string, enabled bool) (store.ActivationRow, error) { - row, err := s.st.SetActivation(owner, pluginID, enabled) + return s.setActivation(ctx, owner, pluginID, enabled, false, false) +} + +func (s *Service) SetActivationAccountChecked(ctx context.Context, owner, pluginID string, enabled, allowImplicitDefault bool) (store.ActivationRow, error) { + return s.setActivation(ctx, owner, pluginID, enabled, true, allowImplicitDefault) +} + +func (s *Service) setActivation(ctx context.Context, owner, pluginID string, enabled, checked, allowImplicitDefault bool) (store.ActivationRow, error) { + var row store.ActivationRow + var err error + if checked { + row, err = s.st.SetActivationAccountChecked(owner, pluginID, enabled, allowImplicitDefault) + } else { + row, err = s.st.SetActivation(owner, pluginID, enabled) + } if err != nil { return row, err } @@ -141,22 +177,51 @@ func (s *Service) SetActivation(ctx context.Context, owner, pluginID string, ena if enabled { action = "surface.enabled" } - if err := s.st.AppendAuditEvent(store.AuditEvent{ + event := store.AuditEvent{ Owner: owner, Actor: "host", Action: action, Target: pluginID, Evidence: fmt.Sprintf("epoch=%d", row.Epoch), - }); err != nil { + } + if checked { + err = s.st.AppendAuditEventAccountChecked(owner, event, allowImplicitDefault) + } else { + err = s.st.AppendAuditEvent(event) + } + if err != nil { return row, err } return row, nil } // publish 发布投影事件;失败则标记待恢复(快照已落盘,不回滚写入)。 -func (s *Service) publish(snap core.SurfaceSnapshot, recovery bool) { - if s.emit == nil || snap.Identity.ConversationID == "" { +func (s *Service) publish(snap core.SurfaceSnapshot, recovery, checked, allowImplicitDefault bool) { + if snap.Identity.ConversationID == "" || (checked && s.accountCheckedEmit == nil) || (!checked && s.emit == nil) { return } - if err := s.emit(string(snap.Identity.ConversationID), string(snap.Identity.SourceRunID), core.EvSurfaceUpdated, snap); err != nil { - if markErr := s.st.SetSurfaceEventPending(snap.Identity.Owner, string(snap.Identity.SurfaceID), snap.Revision, true); markErr != nil { + // The callback may write to a separate event sink. This is the last local + // effective-state check; a cross-sink check-to-effect race remains. + if checked { + if err := s.st.CheckEffectiveAccountForExternalEffect(snap.Identity.Owner, allowImplicitDefault); err != nil { + s.logln("surface: event publication skipped: %v", err) + return + } + } + var emitErr error + if checked { + if s.accountCheckedEmit == nil { + return + } + emitErr = s.accountCheckedEmit(snap.Identity.Owner, string(snap.Identity.ConversationID), string(snap.Identity.SourceRunID), core.EvSurfaceUpdated, snap) + } else { + emitErr = s.emit(string(snap.Identity.ConversationID), string(snap.Identity.SourceRunID), core.EvSurfaceUpdated, snap) + } + if err := emitErr; err != nil { + var markErr error + if checked { + markErr = s.st.SetSurfaceEventPendingAccountChecked(snap.Identity.Owner, string(snap.Identity.SurfaceID), snap.Revision, true, allowImplicitDefault) + } else { + markErr = s.st.SetSurfaceEventPending(snap.Identity.Owner, string(snap.Identity.SurfaceID), snap.Revision, true) + } + if markErr != nil { s.logln("surface: event publish failed (%v) and pending-mark failed (%v) rev=%d", err, markErr, snap.Revision) return } @@ -182,7 +247,7 @@ func (s *Service) RecoverPendingEvents(ctx context.Context) (int, error) { if err != nil { continue } - s.publish(snap, true) + s.publish(snap, true, false, false) if err := s.st.SetSurfaceEventPending(owner, row.SurfaceID, row.Revision, false); err != nil { continue } @@ -191,6 +256,43 @@ func (s *Service) RecoverPendingEvents(ctx context.Context) (int, error) { return n, nil } +// RecoverPendingEventsAccountChecked retries only while the snapshot owner is +// effective. The final pending clear has its own account-guarded transaction. +func (s *Service) RecoverPendingEventsAccountChecked(ctx context.Context, allowImplicitDefault bool) (int, error) { + rows, err := s.st.ListSurfaceEventsPending(100) + if err != nil { + return 0, err + } + n := 0 + for _, row := range rows { + if err := ctx.Err(); err != nil { + return n, err + } + owner, err := s.st.SurfaceOwner(row.SurfaceID) + if err != nil { + continue + } + snap, err := s.st.GetSurfaceSnapshot(owner, row.SurfaceID, row.Revision) + if err != nil { + continue + } + if s.accountCheckedEmit == nil || snap.Identity.ConversationID == "" { + continue + } + if err := s.st.CheckEffectiveAccountForExternalEffect(owner, allowImplicitDefault); err != nil { + continue + } + if err := s.accountCheckedEmit(owner, string(snap.Identity.ConversationID), string(snap.Identity.SourceRunID), core.EvSurfaceUpdated, snap); err != nil { + continue + } + if err := s.st.SetSurfaceEventPendingAccountChecked(owner, row.SurfaceID, row.Revision, false, allowImplicitDefault); err != nil { + continue + } + n++ + } + return n, nil +} + // decodeSpecStrict 严格解码:未知字段拒绝(DisallowUnknownFields),并限制体积。 func decodeSpecStrict(raw json.RawMessage) (core.SurfaceSpec, error) { if len(raw) == 0 { @@ -270,6 +372,17 @@ func (s *Service) SetDispatcher(d Dispatcher) { s.dispatch = d } // 本方法不接收也不透传 Confirmed:高危确认只能经既有确认卡回道置位, // 模型或客户端伪造 Confirmed 无入口(红测断言)。 func (s *Service) SubmitAction(ctx context.Context, owner string, req SubmitActionRequest) (SurfaceActionResult, error) { + return s.submitAction(ctx, owner, req, false, false) +} + +// SubmitActionAccountChecked guards both ledger writes and performs a fresh +// state check immediately before dispatch. Dispatch is an external effect, so +// this check does not make it atomic with a subsequent account disable. +func (s *Service) SubmitActionAccountChecked(ctx context.Context, owner string, req SubmitActionRequest, allowImplicitDefault bool) (SurfaceActionResult, error) { + return s.submitAction(ctx, owner, req, true, allowImplicitDefault) +} + +func (s *Service) submitAction(ctx context.Context, owner string, req SubmitActionRequest, checked, allowImplicitDefault bool) (SurfaceActionResult, error) { cmd, ok := AllowedActions[req.ActionID] if !ok { return SurfaceActionResult{}, ErrUnknownAction @@ -294,10 +407,17 @@ func (s *Service) SubmitAction(ctx context.Context, owner string, req SubmitActi } raw, _ := json.Marshal(req.Data) hash := fmt.Sprintf("%x", sha256.Sum256(raw)) - existing, first, err := s.st.SaveSurfaceActionIfAbsent(store.SurfaceActionRecord{ + entry := store.SurfaceActionRecord{ Owner: owner, SurfaceID: string(req.SurfaceID), ActionID: req.ActionID, RequestID: req.RequestID, Revision: req.Revision, ParamsHash: hash, State: "submitted", - }) + } + var existing store.SurfaceActionRecord + var first bool + if checked { + existing, first, err = s.st.SaveSurfaceActionIfAbsentAccountChecked(owner, entry, allowImplicitDefault) + } else { + existing, first, err = s.st.SaveSurfaceActionIfAbsent(entry) + } if err != nil { return SurfaceActionResult{}, err } @@ -324,6 +444,11 @@ func (s *Service) SubmitAction(ctx context.Context, owner string, req SubmitActi } params[k] = v } + if checked { + if err := s.st.CheckEffectiveAccountForExternalEffect(owner, allowImplicitDefault); err != nil { + return SurfaceActionResult{}, err + } + } out, err := s.dispatch(ctx, ActionDispatch{ CommandID: cmd, Owner: owner, ConversationID: rec.ConversationID, Params: params, }) @@ -332,8 +457,15 @@ func (s *Service) SubmitAction(ctx context.Context, owner string, req SubmitActi state, cardID = "rejected", "" } receiptJSON, _ := json.Marshal(out.Receipt) - if uerr := s.st.UpdateSurfaceActionState(owner, string(req.SurfaceID), req.ActionID, req.RequestID, - state, string(receiptJSON), cardID); uerr != nil { + var uerr error + if checked { + uerr = s.st.UpdateSurfaceActionStateAccountChecked(owner, string(req.SurfaceID), req.ActionID, req.RequestID, + state, string(receiptJSON), cardID, allowImplicitDefault) + } else { + uerr = s.st.UpdateSurfaceActionState(owner, string(req.SurfaceID), req.ActionID, req.RequestID, + state, string(receiptJSON), cardID) + } + if uerr != nil { s.logln("surface: action state update failed: %v", uerr) } if err != nil { diff --git a/internal/tools/codeorch_tool.go b/internal/tools/codeorch_tool.go index c578900..e49d86a 100644 --- a/internal/tools/codeorch_tool.go +++ b/internal/tools/codeorch_tool.go @@ -54,11 +54,17 @@ func execCodeScript(rc ToolExecCtx, args json.RawMessage) (string, error) { if root == "" { return "", fmt.Errorf("代码编排没有会话目录") } + if err := checkToolExternalEffect(rc); err != nil { + return "", err + } lim := codeorch.Limits{FullDir: root} if req.Timeout > 0 { lim.Timeout = time.Duration(req.Timeout) * time.Second } - out := codeorch.Run(context.Background(), root, req.Script, lim, nil, func(_ context.Context, call codeorch.Call) (string, error) { + out := codeorch.Run(execParentCtx(rc), root, req.Script, lim, nil, func(callCtx context.Context, call codeorch.Call) (string, error) { + if err := checkExternalEffect(callCtx, func() error { return checkToolExternalEffect(rc) }); err != nil { + return "", err + } if call.Op != "tool.call" { return "", fmt.Errorf("不能调用 %s", call.Op) } diff --git a/internal/tools/document_renderer.go b/internal/tools/document_renderer.go new file mode 100644 index 0000000..6f747a7 --- /dev/null +++ b/internal/tools/document_renderer.go @@ -0,0 +1,101 @@ +package tools + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "strings" + + "agent-document-renderer/render" + "agent-system-backend/internal/docgen" + core "agent-system-core" +) + +func registerDocumentRenderer(r *Registry) error { + return r.Register(core.ToolDesc{ + Name: "render_document", Source: core.SourceBuiltin, Edits: true, Risk: "medium", + Description: "把普通工作区 Markdown 通过统一文档渲染器生成 Markdown、DOCX、manifest 和可下载归档。只允许非受控工作区交付;项目受控文档必须交给 RDM。公司默认规则已发布(负责人 2026-10-16 批准),但只用于普通工作区的 working/preview 交付,评审状态会写入 manifest。", + Parameters: json.RawMessage(`{"type":"object","additionalProperties":false,"properties":{` + + `"operationId":{"type":"string"},"name":{"type":"string"},"documentId":{"type":"string"},"documentType":{"type":"string"},` + + `"language":{"type":"string"},"markdown":{"type":"string"},` + + `"mode":{"type":"string","enum":["working","preview"]},` + + `"controlled":{"type":"boolean"},"projectDocument":{"type":"boolean"}` + + `},"required":["operationId","name","documentId","markdown","controlled","projectDocument"]}`), + }, executeDocumentRenderer) +} + +type documentRendererArgs struct { + OperationID string `json:"operationId"` + Name string `json:"name"` + DocumentID string `json:"documentId"` + DocumentType string `json:"documentType"` + Language string `json:"language"` + Markdown string `json:"markdown"` + Mode string `json:"mode"` + Controlled *bool `json:"controlled"` + ProjectDocument *bool `json:"projectDocument"` +} + +type documentDeliveryHost interface { + ExecuteDocumentDelivery(context.Context, docgen.OperationRequest) (docgen.OperationReceipt, error) +} + +func executeDocumentRenderer(rc ToolExecCtx, raw json.RawMessage) (string, error) { + var args documentRendererArgs + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&args); err != nil { + return "", fmt.Errorf("render_document: bad args: %w", err) + } + var extra any + if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { + return "", errors.New("render_document: arguments must contain one JSON object") + } + if strings.TrimSpace(args.OperationID) == "" || strings.TrimSpace(args.Name) == "" || strings.TrimSpace(args.DocumentID) == "" || strings.TrimSpace(args.Markdown) == "" { + return "", errors.New("render_document: operationId, name, documentId, and markdown are required") + } + if args.Controlled == nil || args.ProjectDocument == nil { + return "", errors.New("render_document: controlled and projectDocument must be explicit") + } + if *args.Controlled || *args.ProjectDocument { + return "", docgen.ErrRDMRequired + } + if strings.TrimSpace(rc.OwnerID()) == "" { + return "", errors.New("render_document: trusted requester identity is required") + } + mode := render.Mode(args.Mode) + if mode == "" { + mode = render.ModePreview + } + if mode != render.ModeWorking && mode != render.ModePreview { + return "", fmt.Errorf("render_document: mode %q is unavailable for an ordinary workspace document", mode) + } + documentType := strings.TrimSpace(args.DocumentType) + if documentType == "" { + documentType = "general" + } + language := strings.TrimSpace(args.Language) + if language == "" { + language = "zh-CN" + } + host, ok := rc.(documentDeliveryHost) + if !ok { + return "", errors.New("render_document: trusted delivery host is unavailable") + } + receipt, err := host.ExecuteDocumentDelivery(execParentCtx(rc), docgen.OperationRequest{ + OperationID: args.OperationID, DocumentID: args.DocumentID, Title: args.Name, + DocumentType: documentType, Language: language, Markdown: []byte(args.Markdown), Mode: mode, + Controlled: *args.Controlled, ProjectDocument: *args.ProjectDocument, + }) + encoded, marshalErr := json.Marshal(receipt) + if marshalErr != nil { + return "", marshalErr + } + if err != nil { + return "", fmt.Errorf("render_document: %s", encoded) + } + return string(encoded), nil +} diff --git a/internal/tools/document_renderer_test.go b/internal/tools/document_renderer_test.go new file mode 100644 index 0000000..4e63ea5 --- /dev/null +++ b/internal/tools/document_renderer_test.go @@ -0,0 +1,205 @@ +package tools + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" + + "agent-system-backend/internal/docgen" + core "agent-system-core" +) + +type capturedArtifact struct { + metadata core.Artifact + content []byte +} + +type documentRendererCtx struct { + ctx context.Context + artifacts []capturedArtifact + receipt docgen.OperationReceipt + err error +} + +func (c *documentRendererCtx) ProjectID() string { return "conversation-project" } +func (c *documentRendererCtx) ConversationID() string { return "conversation" } +func (c *documentRendererCtx) OwnerID() string { return "alice" } +func (c *documentRendererCtx) WorkspaceRoot() string { return "" } +func (c *documentRendererCtx) SettingBool(string, bool) bool { + return false +} +func (c *documentRendererCtx) Context() context.Context { + if c.ctx != nil { + return c.ctx + } + return context.Background() +} +func (c *documentRendererCtx) Retrieve(core.RetrievalQuery) ([]core.RetrievalHit, error) { + return nil, nil +} +func (c *documentRendererCtx) PeerConversations() ([]core.Conversation, error) { return nil, nil } +func (c *documentRendererCtx) PeerConversationMessages(string, int) ([]core.Message, error) { + return nil, nil +} +func (c *documentRendererCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error) { + artifact := core.Artifact{ID: core.ID("art-" + name), ProjectID: "conversation-project", Owner: "alice", Name: name, Kind: kind, MIME: mime, Size: int64(len(content))} + c.artifacts = append(c.artifacts, capturedArtifact{metadata: artifact, content: append([]byte(nil), content...)}) + return artifact, nil +} +func (c *documentRendererCtx) ExecuteDocumentDelivery(context.Context, docgen.OperationRequest) (docgen.OperationReceipt, error) { + return c.receipt, c.err +} + +func TestRenderDocumentToolWritesMarkdownFirstAndReportsPackageHashes(t *testing.T) { + registry := NewRegistry() + if err := registry.RegisterBuiltin(); err != nil { + t.Fatal(err) + } + arguments, _ := json.Marshal(map[string]any{ + "operationId": "operation-1", "name": "研发纪要", "documentId": "workspace-minutes", "documentType": "general", "language": "zh-CN", + "markdown": "# 研发纪要\n\n正文。\n", "mode": "preview", + "controlled": false, "projectDocument": false, + }) + ctx := &documentRendererCtx{receipt: docgen.OperationReceipt{ + OperationID: "operation-1", Outcome: "complete", State: "complete", Renderer: docgen.RendererIdentity, + FrozenAt: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC), + Artifacts: []docgen.ReceiptArtifact{ + {Role: "markdown", ID: "md", Name: "研发纪要.md", SHA256: strings.Repeat("a", 64)}, + {Role: "docx", ID: "docx", Name: "研发纪要.docx", SHA256: strings.Repeat("b", 64)}, + {Role: "manifest", ID: "manifest", Name: "研发纪要.manifest.json", SHA256: strings.Repeat("c", 64)}, + {Role: "archive", ID: "archive", Name: "研发纪要.zip", SHA256: strings.Repeat("d", 64)}, + }, + }} + result := registry.Execute(ctx, core.ToolCallRequest{CallID: "render-1", Name: "render_document", Arguments: arguments}) + if result.Status != "ok" { + t.Fatalf("render_document: %s", result.Output) + } + var receipt docgen.OperationReceipt + if err := json.Unmarshal([]byte(result.Output), &receipt); err != nil { + t.Fatal(err) + } + if receipt.OperationID != "operation-1" || receipt.Outcome != "complete" || len(receipt.Artifacts) != 4 || receipt.FrozenAt.IsZero() { + t.Fatalf("durable receipt = %#v", receipt) + } + for _, artifact := range receipt.Artifacts { + if !strings.Contains(result.Output, artifact.SHA256) { + t.Errorf("tool output omitted %s hash %s", artifact.Role, artifact.SHA256) + } + } +} + +func TestRenderDocumentToolDescriptionReportsPublishedWorkspaceDefault(t *testing.T) { + registry := NewRegistry() + if err := registry.RegisterBuiltin(); err != nil { + t.Fatal(err) + } + description, _, found := registry.Get("render_document") + if !found { + t.Fatal("render_document is not registered") + } + for _, required := range []string{"已发布", "RDM", "非受控工作区"} { + if !strings.Contains(description.Description, required) { + t.Errorf("render_document description missing %q: %s", required, description.Description) + } + } + if strings.Contains(description.Description, "候选") { + t.Errorf("render_document description still calls the organization default a candidate: %s", description.Description) + } +} + +func TestRenderDocumentToolSchemaIsClosedAndUsesOnlyOperationIdentity(t *testing.T) { + registry := NewRegistry() + if err := registry.RegisterBuiltin(); err != nil { + t.Fatal(err) + } + description, _, found := registry.Get("render_document") + if !found { + t.Fatal("render_document is not registered") + } + var schema struct { + AdditionalProperties *bool `json:"additionalProperties"` + Properties map[string]json.RawMessage `json:"properties"` + Required []string `json:"required"` + } + if err := json.Unmarshal(description.Parameters, &schema); err != nil { + t.Fatal(err) + } + if schema.AdditionalProperties == nil || *schema.AdditionalProperties { + t.Fatal("render_document schema is not closed") + } + if _, found := schema.Properties["operationId"]; !found { + t.Fatal("render_document schema omitted operationId") + } + if _, found := schema.Properties["frozenAt"]; found { + t.Fatal("render_document still accepts model-supplied frozenAt") + } + wantRequired := map[string]bool{"operationId": true, "name": true, "documentId": true, "markdown": true, "controlled": true, "projectDocument": true} + for _, name := range schema.Required { + delete(wantRequired, name) + } + if len(wantRequired) != 0 { + t.Fatalf("render_document required fields missing: %#v", wantRequired) + } + arguments := json.RawMessage(`{"operationId":"closed-schema","name":"Report","documentId":"report","markdown":"# Report\n","controlled":false,"projectDocument":false,"frozenAt":"2000-01-01T00:00:00Z"}`) + result := registry.Execute(&documentRendererCtx{}, core.ToolCallRequest{CallID: "closed-schema", Name: "render_document", Arguments: arguments}) + if result.Status != "error" || !strings.Contains(result.Output, `unknown field "frozenAt"`) { + t.Fatalf("model-supplied frozenAt was accepted: %#v", result) + } +} + +func TestPythonToolDescriptionDoesNotAdvertiseWordBypass(t *testing.T) { + registry := NewRegistry() + if err := RegisterInterpreterTools(registry); err != nil { + t.Fatal(err) + } + description, _, found := registry.Get("run_python") + if !found { + t.Fatal("run_python is not registered") + } + for _, forbidden := range []string{"python-docx", "生成文档", "docx_kit"} { + if strings.Contains(description.Description, forbidden) { + t.Fatalf("run_python description advertises Word bypass %q: %s", forbidden, description.Description) + } + } +} + +func TestRenderDocumentToolRequiresExplicitRouteDeclaration(t *testing.T) { + registry := NewRegistry() + if err := registry.RegisterBuiltin(); err != nil { + t.Fatal(err) + } + arguments, _ := json.Marshal(map[string]any{ + "operationId": "operation-ambiguous", "name": "未声明路由", "documentId": "ambiguous", "markdown": "# 文档\n", + "mode": "preview", + }) + ctx := &documentRendererCtx{} + result := registry.Execute(ctx, core.ToolCallRequest{CallID: "render-3", Name: "render_document", Arguments: arguments}) + if result.Status != "error" || !strings.Contains(result.Output, "controlled and projectDocument must be explicit") { + t.Fatalf("ambiguous route result = %#v", result) + } + if len(ctx.artifacts) != 0 { + t.Fatalf("ambiguous route wrote %d artifacts", len(ctx.artifacts)) + } +} + +func TestRenderDocumentToolRejectsControlledRouteWithoutArtifacts(t *testing.T) { + registry := NewRegistry() + if err := registry.RegisterBuiltin(); err != nil { + t.Fatal(err) + } + arguments, _ := json.Marshal(map[string]any{ + "operationId": "operation-controlled", "name": "受控文档", "documentId": "controlled", "documentType": "dhf", "language": "zh-CN", + "markdown": "# 受控文档\n", "mode": "preview", + "controlled": true, "projectDocument": false, + }) + ctx := &documentRendererCtx{} + result := registry.Execute(ctx, core.ToolCallRequest{CallID: "render-2", Name: "render_document", Arguments: arguments}) + if result.Status != "error" || !strings.Contains(result.Output, "RDM") { + t.Fatalf("controlled render result = %#v", result) + } + if len(ctx.artifacts) != 0 { + t.Fatalf("controlled fallback wrote %d artifacts", len(ctx.artifacts)) + } +} diff --git a/internal/tools/docx_e2e_test.go b/internal/tools/docx_e2e_test.go new file mode 100644 index 0000000..354a7fe --- /dev/null +++ b/internal/tools/docx_e2e_test.go @@ -0,0 +1,40 @@ +package tools + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestLegacyHousestyleBuildCannotBeEnabledByEnvironment(t *testing.T) { + py, err := exec.LookPath("python3") + if err != nil { + t.Skip("python3 not available") + } + wd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + skill := filepath.Join(wd, "..", "skills", "recipes", "house-style") + if _, err := os.Stat(filepath.Join(skill, "housestyle", "build.py")); err != nil { + t.Skip("house-style skill is not in this checkout") + } + dir := t.TempDir() + src := filepath.Join(dir, "summary.md") + body := "# 摘要\n\n## 结论\n\n- 沙箱里可以直接生成 docx\n" + if err := os.WriteFile(src, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + cmd := exec.Command(py, "-m", "housestyle.build", src) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "PYTHONPATH="+skill, "AGENT_SYSTEM_LEGACY_WORD_COMPARISON=1") + if out, err := cmd.CombinedOutput(); err == nil || !strings.Contains(string(out), "disabled") { + t.Fatalf("housestyle.build environment bypass: %v\n%s", err, out) + } + docx := filepath.Join(dir, "summary.docx") + if _, err := os.Stat(docx); !os.IsNotExist(err) { + t.Fatalf("disabled builder wrote %s: %v", docx, err) + } +} diff --git a/internal/tools/effect_guard.go b/internal/tools/effect_guard.go new file mode 100644 index 0000000..b224af3 --- /dev/null +++ b/internal/tools/effect_guard.go @@ -0,0 +1,36 @@ +package tools + +import "context" + +// externalEffectChecker is supplied by the runtime context for account and +// resource admission immediately before an effect. Older embedded tool +// contexts can omit it; the runtime is responsible for wiring the real check. +type externalEffectChecker interface { + CheckExternalEffect() error +} + +func checkToolExternalEffect(rc ToolExecCtx) error { + return checkExternalEffect(execParentCtx(rc), func() error { + if checker, ok := rc.(externalEffectChecker); ok { + return checker.CheckExternalEffect() + } + return nil + }) +} + +func checkExternalEffect(ctx context.Context, check func() error) error { + if ctx != nil { + if err := ctx.Err(); err != nil { + return err + } + } + if check != nil { + if err := check(); err != nil { + return err + } + } + if ctx != nil { + return ctx.Err() + } + return nil +} diff --git a/internal/tools/exec.go b/internal/tools/exec.go index 5a42cf0..3784bb2 100644 --- a/internal/tools/exec.go +++ b/internal/tools/exec.go @@ -1,6 +1,6 @@ // 执行原语工具族(agent-native 交付,2026-10-01 用户定调):文档/表格/演示稿 -// 等产物生成是 Agent 的能力——系统只提供原语与登记桥,生成方法沉淀在提示词库 -// (base.deliverables 配方),不提供 write_docx 这类领域工具。 +// 等产物生成是 Agent 的能力。系统提供执行原语、登记桥,以及经过安全验证的 +// render_document 统一 Word 入口;不提供可绕过 renderer 的 write_docx 工具。 // // 信任模型:本地单用户工作台(单二进制自托管)。run_shell/run_python 的脚本 // 一律先落盘为会话工作区内的文件再交给白名单解释器(bash/python3)执行, @@ -10,6 +10,7 @@ package tools import ( "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -27,6 +28,8 @@ const ( execSetting = "runtime.exec" ) +var errDirectWordRegistration = errors.New("register_artifact: direct Word registration is forbidden; use render_document so Markdown, renderer provenance, manifest, and delivery receipt are preserved") + // execHardOff 启动级硬关闭(组合根依启动参数设置)。runtime.exec 是用户可写的 // 业务设置,多用户部署时任何人都能把它改回 true;硬关闭不读设置,设置无法覆盖。 var execHardOff atomic.Bool @@ -75,6 +78,9 @@ func execSandbox(rc ToolExecCtx) (string, error) { if err != nil { return "", err } + if err := checkToolExternalEffect(rc); err != nil { + return "", err + } if err := os.MkdirAll(sandbox, 0o755); err != nil { return "", fmt.Errorf("exec tools: create sandbox: %w", err) } @@ -87,7 +93,7 @@ func execSandbox(rc ToolExecCtx) (string, error) { func RegisterExecTools(r *Registry) error { return r.Register(core.ToolDesc{ Name: "register_artifact", Source: core.SourceBuiltin, - Description: "把会话工作区内已生成的文件登记为交付物工件(docx/xlsx/pptx/pdf/图片/数据等):登记后文件出现在对话交付卡片与工件面板,可预览、下载。二进制文件不能用 fs_write_file(只收文本)——先由 run_python/run_shell 生成到工作区,再用本工具登记。", + Description: "把会话工作区内已生成的文件登记为交付物工件(xlsx/pptx/pdf/图片/数据及迁移对照文件):登记后可预览、下载。普通 Word 使用 render_document,项目或受控 Word 交给 RDM;二进制文件不能用 fs_write_file。", Parameters: json.RawMessage(`{"type":"object","properties":{` + `"path":{"type":"string","description":"工作区内相对路径(如 报告.docx)"}}` + `,"required":["path"]}`), @@ -117,6 +123,9 @@ func execRegisterArtifact(rc ToolExecCtx, args json.RawMessage) (string, error) if err != nil { return "", fmt.Errorf("register_artifact: %w", err) } + if isWordBinaryName(abs) { + return "", errDirectWordRegistration + } st, err := os.Stat(abs) if err != nil { return "", fmt.Errorf("register_artifact: %s: %v", a.Path, err) @@ -131,8 +140,17 @@ func execRegisterArtifact(rc ToolExecCtx, args json.RawMessage) (string, error) if err != nil { return "", err } + if isWordprocessingMLPackage(b) { + return "", errDirectWordRegistration + } + if err := ooxmlStructuralCheck(filepath.Base(abs), b); err != nil { + return "", fmt.Errorf("register_artifact: %s 结构自检未通过:%v。请用 housestyle kit 重建(见 $AGENT_SKILLS_DIR/house-style/SKILL.md),不要用 zipfile 手工拼 OOXML", a.Path, err) + } name := filepath.Base(abs) mime := mimeOfExt(name) + if err := checkToolExternalEffect(rc); err != nil { + return "", err + } art, err := rc.WriteArtifact(name, artifactKindOf(name, mime), mime, b) if err != nil { return "", err diff --git a/internal/tools/exec_backend.go b/internal/tools/exec_backend.go index 4d11ac6..3d11eaa 100644 --- a/internal/tools/exec_backend.go +++ b/internal/tools/exec_backend.go @@ -34,12 +34,19 @@ type ExecSpec struct { // ~/.local 下会被 --tmpfs /home 遮蔽,只读挂回其原路径并并入 PYTHONPATH, // 交付物生成链路(F3)在隔离档下才可用。 DepsDir string + // LayerDirs 是本次执行要只读挂载的 site-packages(基础层与扩展层,或单独的隔离层)。 + // 路径若位于 /home 或 /tmp,必须在对应 tmpfs 之后挂载。 + LayerDirs []string + // PythonPrefix / ShimDir 同样可能落在被 tmpfs 遮住的路径上,需要挂回。 + PythonPrefix string + ShimDir string // S0 工作区 overlay(sandbox 档;三者须同批非空):冻结基底(--overlay-src)、 // 私有 upper(--overlay RWSRC)、overlayfs work 目录。空 = EXT-16 现状直写。 - WorkspaceFrozen string - WorkspaceUpper string - WorkspaceWork string - Timeout time.Duration // 硬超时(调用方 ctx.WithTimeout 之前传入,仅记录用) + WorkspaceFrozen string + WorkspaceUpper string + WorkspaceWork string + Timeout time.Duration // 硬超时(调用方 ctx.WithTimeout 之前传入,仅记录用) + CheckExternalEffect func() error // 启动与 overlay 写回前重验账号;nil 为旧内部调用 } // ExecProcess 后端启动的执行体句柄:Wait 等待退出;Kill 杀整个执行体 @@ -106,6 +113,9 @@ func (directBackend) Start(ctx context.Context, spec ExecSpec, out io.Writer) (E } cmd.Stdout = out cmd.Stderr = out + if err := checkExternalEffect(ctx, spec.CheckExternalEffect); err != nil { + return nil, err + } if err := cmd.Start(); err != nil { return nil, err } @@ -158,6 +168,9 @@ func (sandboxBackend) Start(ctx context.Context, spec ExecSpec, out io.Writer) ( } cmd.Stdout = out cmd.Stderr = out + if err := checkExternalEffect(ctx, spec.CheckExternalEffect); err != nil { + return nil, err + } if err := cmd.Start(); err != nil { return nil, err } @@ -192,6 +205,18 @@ func sandboxArgv(spec ExecSpec) []string { // 必须后于 --tmpfs /home:先遮蔽再只读挂回依赖层(挂载顺序敏感)。 argv = append(argv, "--ro-bind", spec.DepsDir, spec.DepsDir) } + for _, dir := range spec.LayerDirs { + if dir == "" || dir == spec.DepsDir { + continue + } + argv = append(argv, "--ro-bind", dir, dir) + } + if spec.PythonPrefix != "" { + argv = append(argv, "--ro-bind", spec.PythonPrefix, spec.PythonPrefix) + } + if spec.ShimDir != "" && spec.ShimDir != spec.PythonPrefix { + argv = append(argv, "--ro-bind", spec.ShimDir, spec.ShimDir) + } argv = append(argv, "--chdir", spec.Dir, "--clearenv") for _, kv := range spec.Env { if i := strings.IndexByte(kv, '='); i > 0 { diff --git a/internal/tools/exec_backend_test.go b/internal/tools/exec_backend_test.go index 41006f3..601785c 100644 --- a/internal/tools/exec_backend_test.go +++ b/internal/tools/exec_backend_test.go @@ -88,7 +88,11 @@ func requireBwrap(t *testing.T) { // sandboxRun 经 sandbox 后端执行一段 bash 脚本,返回合并输出与退出描述。 func sandboxRun(t *testing.T, script string, timeout time.Duration) (string, string) { t.Helper() - dir := t.TempDir() + return sandboxRunInDir(t, t.TempDir(), script, timeout) +} + +func sandboxRunInDir(t *testing.T, dir, script string, timeout time.Duration) (string, string) { + t.Helper() p := filepath.Join(dir, "s.sh") if err := os.WriteFile(p, []byte(script), 0o600); err != nil { t.Fatal(err) @@ -207,17 +211,21 @@ func TestSandboxForkBombContained(t *testing.T) { func TestSandboxOrphansDieWithNamespace(t *testing.T) { requireBwrap(t) - out, state := sandboxRun(t, ":(){ :|:& };:; echo gone", 30*time.Second) + dir := t.TempDir() + out, state := sandboxRunInDir(t, dir, ":(){ :|:& };:; echo gone", 30*time.Second) if state != "exit=0" || !strings.Contains(out, "gone") { t.Fatalf("state=%s out=%q", state, out) } time.Sleep(500 * time.Millisecond) // 给孤儿进程留存活窗口 - hb, err := exec.Command("bash", "-c", "ps -eo args | grep -c '[s]\\.sh' || true").Output() + hb, err := exec.Command("ps", "-eo", "args").Output() if err != nil { t.Skipf("host ps probe failed: %v", err) } - if strings.TrimSpace(string(hb)) != "0" { - t.Fatalf("orphaned bomb survived outside the namespace: %s", hb) + // Other tests/services may run their own s.sh; inspect only this namespace's script. + for _, args := range strings.Split(string(hb), "\n") { + if strings.Contains(args, filepath.Join(dir, "s.sh")) { + t.Fatalf("orphaned bomb survived outside the namespace: %s", args) + } } } @@ -270,7 +278,7 @@ func TestDirectBackendRunInterpreterUnchanged(t *testing.T) { if err := os.WriteFile(p, []byte("echo hi; exit 7\n"), 0o600); err != nil { t.Fatal(err) } - got, err := runInterpreter(context.Background(), "bash", p, dir, 30, nil) + got, err := runInterpreter(context.Background(), "bash", p, dir, "", 30, nil) if err != nil || !strings.HasPrefix(got, "exit=7\n") || !strings.Contains(got, "hi") { t.Fatalf("direct regression: err=%v got=%q", err, got) } @@ -278,7 +286,7 @@ func TestDirectBackendRunInterpreterUnchanged(t *testing.T) { if err := os.WriteFile(p2, []byte("sleep 60\n"), 0o600); err != nil { t.Fatal(err) } - got, err = runInterpreter(context.Background(), "bash", p2, dir, 2, nil) + got, err = runInterpreter(context.Background(), "bash", p2, dir, "", 2, nil) if err != nil || !strings.HasPrefix(got, "exit=timeout(2s)") { t.Fatalf("direct timeout regression: err=%v got=%q", err, got) } @@ -303,6 +311,35 @@ func TestSandboxArgvDepsLayer(t *testing.T) { } } +func TestSandboxArgvLayersAndPath(t *testing.T) { + argv := sandboxArgv(ExecSpec{ + Bin: "/usr/bin/python3", Args: []string{"/data/s/x.py"}, + Dir: "/data/s", + LayerDirs: []string{"/opt/base/site-packages", "/opt/ext/site-packages"}, + PythonPrefix: "/opt/py", + ShimDir: "/opt/env/bin", + Env: []string{"PATH=/opt/py/bin:/opt/env/bin:/usr/bin:/bin", "PIP_NO_INDEX=1"}, + }) + joined := strings.Join(argv, "\x00") + for _, m := range []string{ + "--ro-bind\x00/opt/base/site-packages\x00/opt/base/site-packages", + "--ro-bind\x00/opt/ext/site-packages\x00/opt/ext/site-packages", + "--ro-bind\x00/opt/py\x00/opt/py", + "--ro-bind\x00/opt/env/bin\x00/opt/env/bin", + "--setenv\x00PATH\x00/opt/py/bin:/opt/env/bin:/usr/bin:/bin", + "--setenv\x00PIP_NO_INDEX\x001", + } { + if !strings.Contains(joined, m) { + t.Fatalf("sandbox argv missing %q: %v", m, argv) + } + } + baseAt := strings.Index(joined, "/opt/base/site-packages") + extAt := strings.Index(joined, "/opt/ext/site-packages") + if baseAt < 0 || extAt < baseAt { + t.Fatalf("extension layer must mount after the base layer: %v", argv) + } +} + // TestExecEnvJoinsPythonPath F3:依赖层与技能根合成一条 PYTHONPATH, // 不允许重复键互相覆盖(否则只挂载了依赖层却 import 不到)。 func TestExecEnvJoinsPythonPath(t *testing.T) { diff --git a/internal/tools/exec_env_tool.go b/internal/tools/exec_env_tool.go new file mode 100644 index 0000000..5db5ea2 --- /dev/null +++ b/internal/tools/exec_env_tool.go @@ -0,0 +1,124 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "path/filepath" + "strings" + "time" + + "agent-system-backend/internal/execenv" + core "agent-system-core" +) + +// envBroker 由组合根注入。未注入时 python_env 明确报错,不假装能 pip install。 +var envBroker *execenv.Broker + +// envApproval 在申请需要人批时写一张选择卡。空则只把 pending 文本回给模型。 +var envApproval func(owner, conversationID, requestID, detail string) error + +// SetEnvBroker 注入分层环境代理。 +func SetEnvBroker(b *execenv.Broker) { envBroker = b } + +// SetEnvApproval 注入审批卡写入。 +func SetEnvApproval(fn func(owner, conversationID, requestID, detail string) error) { + envApproval = fn +} + +func registerPythonEnvTool(r *Registry) error { + return r.Register(core.ToolDesc{ + Name: "python_env", Source: core.SourceBuiltin, Risk: "medium", + Description: "向系统申请 Python 包,或查看本会话已经能用的环境。沙箱不能联网安装。action=status 查看基础层与本会话的层;action=request 申请包(packages)或登记工作区内的 wheel;白名单内的包会自动装好并在下一次 run_python 生效,其余会进入审批。action=wait 等待 requestId 结束。不要用 pip install。", + Parameters: json.RawMessage(`{"type":"object","properties":{` + + `"action":{"type":"string","enum":["status","request","wait"],"description":"status | request | wait"},` + + `"packages":{"type":"array","items":{"type":"string"},"description":"包规格,如 pdfplumber 或 numpy==2.2.0"},` + + `"reason":{"type":"string","description":"为什么需要这些包"},` + + `"wheel":{"type":"string","description":"工作区内的 .whl 相对路径,上传的包需要审批"},` + + `"request_id":{"type":"string","description":"wait 时的申请 ID"},` + + `"timeout_sec":{"type":"integer","description":"wait 的秒数,默认 120"}}` + + `,"required":["action"]}`), + }, execPythonEnv) +} + +func execPythonEnv(rc ToolExecCtx, args json.RawMessage) (string, error) { + if err := execGate(rc); err != nil { + return "", err + } + if envBroker == nil { + return "", fmt.Errorf("python_env: 环境代理未配置(启动参数 execEnvRoot)") + } + var a struct { + Action string `json:"action"` + Packages []string `json:"packages"` + Reason string `json:"reason"` + Wheel string `json:"wheel"` + RequestID string `json:"request_id"` + TimeoutS int `json:"timeout_sec"` + } + if len(args) > 0 { + if err := json.Unmarshal(args, &a); err != nil { + return "", fmt.Errorf("python_env: bad args: %w", err) + } + } + switch a.Action { + case "status": + return envBroker.StatusText(rc.ConversationID()) + case "wait": + if strings.TrimSpace(a.RequestID) == "" { + return "", fmt.Errorf("python_env: request_id required") + } + timeout := time.Duration(a.TimeoutS) * time.Second + out, err := envBroker.Wait(execParentCtx(rc), a.RequestID, timeout) + if err != nil { + return "", err + } + raw, _ := json.Marshal(out) + return string(raw), nil + case "request": + wheel := "" + if strings.TrimSpace(a.Wheel) != "" { + sandbox, err := execSandbox(rc) + if err != nil { + return "", err + } + wheel, err = filepath.Abs(filepath.Join(sandbox, a.Wheel)) + if err != nil { + return "", err + } + if !strings.HasPrefix(wheel, sandbox+string(filepath.Separator)) { + return "", fmt.Errorf("python_env: wheel must stay inside the workspace") + } + } + out, err := envBroker.RequestPackages(execParentCtx(rc), rc.ConversationID(), rc.OwnerID(), a.Reason, a.Packages, wheel) + if err != nil { + return "", err + } + if out.Status == "pending_approval" && envApproval != nil { + if err := envApproval(rc.OwnerID(), rc.ConversationID(), out.RequestID, out.Detail); err != nil { + out.Detail += ";审批卡未写入:" + err.Error() + } + } + raw, _ := json.Marshal(out) + return string(raw), nil + default: + return "", fmt.Errorf("python_env: unknown action %q", a.Action) + } +} + +// ExecDoctor 用当前执行后端探针导入基础交付物库。依赖层已配置但导入失败时返回错误。 +func ExecDoctor() error { + bin, err := resolveInterpreter("python3") + if err != nil { + return err + } + dirs, _ := pythonPathFor("") + mounts := append([]string{}, dirs...) + if execPythonPrefix != "" { + mounts = append(mounts, execPythonPrefix) + } + if execShimDir != "" { + mounts = append(mounts, execShimDir) + } + return execenv.Doctor(context.Background(), bin, dirs, nil, ExecBackendName() == "sandbox", mounts) +} diff --git a/internal/tools/exec_interpreter.go b/internal/tools/exec_interpreter.go index 55538b1..6064bfc 100644 --- a/internal/tools/exec_interpreter.go +++ b/internal/tools/exec_interpreter.go @@ -31,7 +31,7 @@ import ( func RegisterInterpreterTools(r *Registry) error { if err := r.Register(core.ToolDesc{ Name: "run_shell", Source: core.SourceBuiltin, Risk: "medium", - Description: "在会话工作区目录内执行 bash 脚本:检查环境、安装依赖(pip install --user …)、处理文件等。脚本原样落盘执行;返回退出码与合并输出(stdout+stderr,超限头尾截断)。以当前用户身份运行。", + Description: "在会话工作区目录内执行 bash 脚本:检查环境、处理文件、运行 Excel/PPT 的 housestyle lint。普通 Word 使用 render_document。脚本原样落盘执行;返回退出码与合并输出(stdout+stderr,超限头尾截断)。沙箱断网且不继承宿主家目录,不要用 pip/apt 安装;缺的 Python 包用 python_env 向系统申请。", Parameters: json.RawMessage(`{"type":"object","properties":{` + `"script":{"type":"string","description":"bash 脚本(可多行)"},` + `"timeout_sec":{"type":"integer","description":"超时秒数(默认 600,可自行设更长)"}}` + @@ -39,9 +39,12 @@ func RegisterInterpreterTools(r *Registry) error { }, execShell); err != nil { return err } + if err := registerPythonEnvTool(r); err != nil { + return err + } return r.Register(core.ToolDesc{ Name: "run_python", Source: core.SourceBuiltin, Risk: "medium", - Description: "执行 Python 脚本(python3,工作目录为会话工作区):生成文档/表格/演示稿/数据处理的首选路径。脚本原样落盘执行;stdout/stderr 合并返回,超限头尾截断。python-docx/openpyxl/python-pptx 缺失时可先 pip install --user 安装。交付物用 import housestyle(技能目录在环境变量 AGENT_SKILLS_DIR,house-style 已在 PYTHONPATH)。", + Description: "执行 Python 脚本(python3,工作目录为会话工作区):用于数据处理以及 Excel、PowerPoint、图片生成。普通 Word 必须使用 render_document;register_artifact 会拒绝 Python 或其他任意来源生成的 DOC/DOCX。脚本原样落盘执行;stdout/stderr 合并返回,超限头尾截断。基础层提供 openpyxl、python-pptx 等;缺的包用 python_env 申请,不要 pip install。", Parameters: json.RawMessage(`{"type":"object","properties":{` + `"script":{"type":"string","description":"完整 Python 脚本(utf-8)"},` + `"timeout_sec":{"type":"integer","description":"超时秒数(默认 600,可自行设更长)"}}` + @@ -55,39 +58,95 @@ var skillsRoot string // SetSkillsRoot 让 run_python / run_shell 能 import housestyle,并看到技能目录。 func SetSkillsRoot(dir string) { skillsRoot = dir } -// execDepsDir 是 sandbox 档的只读依赖层(site-packages 等);组合根启动时注入。 +// execDepsDir 是 sandbox 档的只读基础依赖层(site-packages);组合根启动时注入。 var execDepsDir string -// SetExecDepsDir 配置 sandbox 档依赖层目录(空 = 不挂载,现状)。 +// SetExecDepsDir 配置 sandbox 档基础依赖层目录(空 = 不挂载,现状)。 func SetExecDepsDir(dir string) { execDepsDir = dir } -// execEnv 最小环境:PATH/HOME/LANG/TZ/TERM 继承(pip --user、字体、中文 locale), -// 其余变量不带入子进程。技能根配置后追加 AGENT_SKILLS_DIR 与 PYTHONPATH。 -func execEnv() []string { +// 托管解释器与 pip 垫片。空表示尚未配置,python3 回落到 /usr/bin/python3, +// 不再使用 PATH 里可能指向 /home 的解释器(沙箱会把 /home 遮掉)。 +var ( + execPythonBin string + execPythonPrefix string + execShimDir string + execLayers func(conversationID string) (extra []string, isolated string) +) + +// SetExecPython 配置托管解释器。prefix 是需要在沙箱里重新挂载的目录。 +func SetExecPython(bin, prefix string) { + execPythonBin = bin + execPythonPrefix = prefix +} + +// SetExecShimDir 把 pip 垫片目录放到 PATH 最前。 +func SetExecShimDir(dir string) { execShimDir = dir } + +// SetExecLayers 按会话返回扩展层或隔离层的 site-packages 目录。 +func SetExecLayers(fn func(conversationID string) (extra []string, isolated string)) { + execLayers = fn +} + +// execEnv 最小环境。PATH 固定,不继承宿主 PATH:宿主 PATH 里的 linuxbrew +// 位于 /home 下,沙箱 tmpfs 会把它遮掉,解析到那里的 python3 无法启动。 +func execEnv() []string { return execEnvFor("") } + +func execEnvFor(conv string) []string { var out []string for _, kv := range os.Environ() { - for _, keep := range []string{"PATH=", "HOME=", "LANG=", "LC_", "TZ=", "TERM="} { + for _, keep := range []string{"HOME=", "LANG=", "LC_", "TZ=", "TERM="} { if strings.HasPrefix(kv, keep) { out = append(out, kv) break } } } - out = append(out, "PYTHONDONTWRITEBYTECODE=1") - var pypaths []string - if execDepsDir != "" { - pypaths = append(pypaths, execDepsDir) - } + out = append(out, + "PATH="+execPATH(), + "PYTHONDONTWRITEBYTECODE=1", + "PIP_NO_INDEX=1", + ) + dirs, _ := pythonPathFor(conv) if skillsRoot != "" { out = append(out, "AGENT_SKILLS_DIR="+skillsRoot) - pypaths = append(pypaths, filepath.Join(skillsRoot, "house-style")) + dirs = append(dirs, filepath.Join(skillsRoot, "house-style")) } - if len(pypaths) > 0 { // 合成一条,避免重复键互相覆盖 - out = append(out, "PYTHONPATH="+strings.Join(pypaths, string(os.PathListSeparator))) + if len(dirs) > 0 { + out = append(out, "PYTHONPATH="+strings.Join(dirs, string(os.PathListSeparator))) } return out } +func execPATH() string { + var parts []string + if execPythonBin != "" { + parts = append(parts, filepath.Dir(execPythonBin)) + } + if execShimDir != "" { + parts = append(parts, execShimDir) + } + parts = append(parts, "/usr/local/bin", "/usr/bin", "/bin") + return strings.Join(parts, ":") +} + +// pythonPathFor 返回要放进 PYTHONPATH、并且在沙箱里只读挂载的目录。 +// 隔离层替换基础层,不与基础层叠放。 +func pythonPathFor(conv string) (dirs []string, isolated bool) { + var extra []string + var iso string + if execLayers != nil && conv != "" { + extra, iso = execLayers(conv) + } + if iso != "" { + return []string{iso}, true + } + if execDepsDir != "" { + dirs = append(dirs, execDepsDir) + } + dirs = append(dirs, extra...) + return dirs, false +} + // truncateOutput 头 2/3 + 尾 1/3 保留,中部标记省略。 func truncateOutput(b []byte) string { if len(b) <= execMaxOutput { @@ -101,10 +160,16 @@ func truncateOutput(b []byte) string { // writeScript 把模型脚本落盘到工作区 .agent/ 下(字节精确,执行后保留),返回绝对路径。 func writeScript(rc ToolExecCtx, sandbox, prefix, ext, content string) (string, error) { dir := filepath.Join(sandbox, execScriptDir) + if err := checkToolExternalEffect(rc); err != nil { + return "", err + } if err := os.MkdirAll(dir, 0o755); err != nil { return "", err } p := filepath.Join(dir, fmt.Sprintf("%s-%s-%d%s", prefix, rc.ConversationID(), time.Now().UnixNano(), ext)) + if err := checkToolExternalEffect(rc); err != nil { + return "", err + } if err := os.WriteFile(p, []byte(content), 0o600); err != nil { return "", err } @@ -124,10 +189,33 @@ func execParentCtx(rc ToolExecCtx) context.Context { return context.Background() } -// resolveInterpreter 把白名单解释器名钉成绝对路径,避免 PATH 在子进程里被换掉。 +// resolveInterpreter 把白名单解释器名钉成绝对路径。 +// python3 优先用托管解释器,其次 /usr/bin/python3。不用 LookPath: +// 服务进程的 PATH 常把 /home 下的 linuxbrew 放在前面,那个路径在沙箱里不存在。 func resolveInterpreter(name string) (string, error) { switch name { - case "bash", "python3": + case "bash": + for _, p := range []string{"/bin/bash", "/usr/bin/bash"} { + if st, err := os.Stat(p); err == nil && !st.IsDir() { + return p, nil + } + } + case "python3": + if execPythonBin != "" { + if st, err := os.Stat(execPythonBin); err == nil && !st.IsDir() { + // current/bin/python3 是符号链接。沙箱用 tmpfs 盖住 /home 后只挂真实前缀, + // 执行未解析的 current 路径会找不到文件。 + resolved, err := filepath.EvalSymlinks(execPythonBin) + if err != nil { + return "", err + } + return resolved, nil + } + return "", fmt.Errorf("python3: configured interpreter missing: %s", execPythonBin) + } + if st, err := os.Stat("/usr/bin/python3"); err == nil && !st.IsDir() { + return "/usr/bin/python3", nil + } default: return "", fmt.Errorf("interpreter %s is not allowlisted", name) } @@ -138,13 +226,16 @@ func resolveInterpreter(name string) (string, error) { if !filepath.IsAbs(p) { return "", fmt.Errorf("%s: resolved path is not absolute: %s", name, p) } + if strings.HasPrefix(p, "/home/") || strings.HasPrefix(p, "/root/") { + return "", fmt.Errorf("%s: refusing %s because the sandbox hides that path", name, p) + } return p, nil } // runInterpreter 启动白名单解释器执行落盘脚本(脚本路径为唯一参数)。 // 进程启动经 EXT-16 执行后端(direct=现状直启 / sandbox=bwrap 隔离档)。 // 非零退出、超时与请求取消都是正常反馈,以文本返回;仅启动失败算错误。 -func runInterpreter(parent context.Context, interpreter, scriptPath, dir string, timeoutS int, sink func(stream, text string)) (string, error) { +func runInterpreter(parent context.Context, interpreter, scriptPath, dir, convID string, timeoutS int, sink func(stream, text string), checks ...func() error) (string, error) { if parent == nil { parent = context.Background() } @@ -157,9 +248,22 @@ func runInterpreter(parent context.Context, interpreter, scriptPath, dir string, } ctx, cancel := context.WithTimeout(parent, time.Duration(timeoutS)*time.Second) defer cancel() + var check func() error + if len(checks) > 0 { + check = checks[0] + } spec := ExecSpec{ - Bin: bin, Args: []string{scriptPath}, Dir: dir, Env: execEnv(), + Bin: bin, Args: []string{scriptPath}, Dir: dir, Env: execEnvFor(convID), SkillsDir: skillsRoot, DepsDir: execDepsDir, Timeout: time.Duration(timeoutS) * time.Second, + PythonPrefix: execPythonPrefix, ShimDir: execShimDir, + CheckExternalEffect: check, + } + if dirs, isolated := pythonPathFor(convID); isolated { + spec.DepsDir = "" + spec.LayerDirs = dirs + } else if len(dirs) > 1 || (len(dirs) == 1 && dirs[0] != execDepsDir) { + spec.LayerDirs = dirs + spec.DepsDir = "" } pr, pw := io.Pipe() var buf bytes.Buffer @@ -245,7 +349,7 @@ func execShell(rc ToolExecCtx, args json.RawMessage) (string, error) { if err != nil { return "", err } - return runInterpreter(execParentCtx(rc), "bash", p, sandbox, firstPos(a.TimeoutS, a.TimeoutSec), toolOutputSink(rc)) + return runInterpreter(execParentCtx(rc), "bash", p, sandbox, rc.ConversationID(), firstPos(a.TimeoutS, a.TimeoutSec), toolOutputSink(rc), func() error { return checkToolExternalEffect(rc) }) } // ScriptCommand 准备一条以当前用户身份、工作目录为会话沙箱的 python3 命令。 @@ -260,7 +364,7 @@ func ScriptCommand(ctx context.Context, sandbox, path string) (*exec.Cmd, error) } cmd := exec.CommandContext(ctx, bin, path) cmd.Dir = sandbox - cmd.Env = execEnv() + cmd.Env = execEnvFor("") cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} cmd.WaitDelay = 2 * time.Second cmd.Cancel = func() error { @@ -301,7 +405,7 @@ func execPython(rc ToolExecCtx, args json.RawMessage) (string, error) { if err != nil { return "", err } - return runInterpreter(execParentCtx(rc), "python3", p, sandbox, firstPos(a.TimeoutS, a.TimeoutSec), toolOutputSink(rc)) + return runInterpreter(execParentCtx(rc), "python3", p, sandbox, rc.ConversationID(), firstPos(a.TimeoutS, a.TimeoutSec), toolOutputSink(rc), func() error { return checkToolExternalEffect(rc) }) } // toolOutputSink 在执行上下文实现了 EmitToolOutput 时,把 stdout 增量交出去。 @@ -332,7 +436,7 @@ func InterpreterExecConfig() *agenttools.ExecConfig { if ExecDisabled() { return nil } - cfg := &agenttools.ExecConfig{Env: execEnv()} + cfg := &agenttools.ExecConfig{Env: execEnvFor("")} if p, err := exec.LookPath("bash"); err == nil { cfg.Bash = p } diff --git a/internal/tools/exec_interpreter_test.go b/internal/tools/exec_interpreter_test.go index 41d28ee..90b4364 100644 --- a/internal/tools/exec_interpreter_test.go +++ b/internal/tools/exec_interpreter_test.go @@ -213,6 +213,28 @@ func TestExecEnvIncludesSkillsRoot(t *testing.T) { } } +func TestResolveInterpreterFollowsManagedSymlink(t *testing.T) { + prev := execPythonBin + t.Cleanup(func() { execPythonBin = prev }) + dir := t.TempDir() + link := filepath.Join(dir, "python3") + if err := os.Symlink("/usr/bin/python3", link); err != nil { + t.Fatal(err) + } + execPythonBin = link + got, err := resolveInterpreter("python3") + if err != nil { + t.Fatal(err) + } + want, err := filepath.EvalSymlinks("/usr/bin/python3") + if err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("resolved %q, want %q", got, want) + } +} + func TestInterpreterExecConfigForKernel(t *testing.T) { cfg := InterpreterExecConfig() if cfg == nil { @@ -227,7 +249,7 @@ func TestInterpreterExecConfigForKernel(t *testing.T) { for _, kv := range cfg.Env { if strings.HasPrefix(kv, "PATH=") || strings.HasPrefix(kv, "HOME=") || strings.HasPrefix(kv, "LANG=") || strings.HasPrefix(kv, "LC_") || strings.HasPrefix(kv, "TZ=") || strings.HasPrefix(kv, "TERM=") || - kv == "PYTHONDONTWRITEBYTECODE=1" || strings.HasPrefix(kv, "AGENT_SKILLS_DIR=") || strings.HasPrefix(kv, "PYTHONPATH=") { + kv == "PYTHONDONTWRITEBYTECODE=1" || kv == "PIP_NO_INDEX=1" || strings.HasPrefix(kv, "AGENT_SKILLS_DIR=") || strings.HasPrefix(kv, "PYTHONPATH=") { continue } t.Fatalf("env whitelist violated: %q", kv) diff --git a/internal/tools/exec_workspace.go b/internal/tools/exec_workspace.go index 765f2e1..4d8a995 100644 --- a/internal/tools/exec_workspace.go +++ b/internal/tools/exec_workspace.go @@ -52,11 +52,18 @@ func upperDir(sessionDir, id string) string { // freezeBase 硬链接克隆 dir 到 frozen(已存在则报错——每个 id 只冻结一次), // 返回基线清单 {相对路径: sha256}。符号链接原样保留(链接本身少见内容变更)。 func freezeBase(dir, frozen string) (map[string]string, error) { + return freezeBaseChecked(context.Background(), nil, dir, frozen) +} + +func freezeBaseChecked(ctx context.Context, check func() error, dir, frozen string) (map[string]string, error) { if _, err := os.Lstat(frozen); err == nil { return nil, fmt.Errorf("freeze id reused: %s", frozen) } else if !errors.Is(err, os.ErrNotExist) { return nil, err } + if err := checkExternalEffect(ctx, check); err != nil { + return nil, err + } if err := os.MkdirAll(frozen, 0o755); err != nil { return nil, err } @@ -81,6 +88,9 @@ func freezeBase(dir, frozen string) (map[string]string, error) { if ierr != nil { return ierr } + if err := checkExternalEffect(ctx, check); err != nil { + return err + } switch { case d.IsDir(): return os.Mkdir(target, info.Mode().Perm()) @@ -101,7 +111,7 @@ func freezeBase(dir, frozen string) (map[string]string, error) { } else if !errors.Is(err, syscall.EXDEV) && !errors.Is(err, os.ErrPermission) { return err } - return copyFile(p, target, info.Mode().Perm()) // 受限场景退化复制 + return copyFileChecked(ctx, check, p, target, info.Mode().Perm()) // 受限场景退化复制 default: return nil // 套接字/设备等:会话工作区不出现,跳过 } @@ -125,7 +135,14 @@ type Change struct { // whiteout(overlayfs 删除标记:字符设备 0/0)解释为删除。 // 返回变更清单(按路径排序)与错误(目录级失败即停——宁可少改不乱改)。 func applyUpper(upper, dir string, manifest map[string]string) ([]Change, error) { + return applyUpperChecked(context.Background(), nil, upper, dir, manifest) +} + +func applyUpperChecked(ctx context.Context, check func() error, upper, dir string, manifest map[string]string) ([]Change, error) { var changes []Change + if err := checkExternalEffect(ctx, check); err != nil { + return changes, err + } err := filepath.WalkDir(upper, func(p string, d os.DirEntry, err error) error { if err != nil { return err @@ -146,6 +163,9 @@ func applyUpper(upper, dir string, manifest map[string]string) ([]Change, error) case d.IsDir(): // 目录本身不构成变更;缺失时补建(rename 文件需要父目录在位)。 if _, serr := os.Lstat(target); serr != nil { + if err := checkExternalEffect(ctx, check); err != nil { + return err + } return os.MkdirAll(target, info.Mode().Perm()) } return nil @@ -166,6 +186,9 @@ func applyUpper(upper, dir string, manifest map[string]string) ([]Change, error) changes = append(changes, Change{Rel: rel, Op: "del", Applied: false, Conflict: true}) return nil // 被外部改过:不删,报告冲突 default: + if err := checkExternalEffect(ctx, check); err != nil { + return err + } if rmErr := os.Remove(target); rmErr != nil && !errors.Is(rmErr, os.ErrNotExist) { return rmErr } @@ -185,14 +208,17 @@ func applyUpper(upper, dir string, manifest map[string]string) ([]Change, error) } conflict := targetExists && curSum != baseSum // 外部已改(或基底没有却被外部新建) if conflict { - if cpErr := copyFile(p, target+".conflict-"+time.Now().Format("0102-150405"), info.Mode().Perm()); cpErr != nil { + if cpErr := copyFileChecked(ctx, check, p, target+".conflict-"+time.Now().Format("0102-150405"), info.Mode().Perm()); cpErr != nil { return cpErr } changes = append(changes, Change{Rel: rel, Op: changeOp(hadBase, targetExists), Applied: false, Conflict: true}) return nil } + if err := checkExternalEffect(ctx, check); err != nil { + return err + } if mvErr := os.Rename(p, target); mvErr != nil { // 同 fs 原子替换 - if cpErr := copyFile(p, target, info.Mode().Perm()); cpErr != nil { // 跨设备兜底 + if cpErr := copyFileChecked(ctx, check, p, target, info.Mode().Perm()); cpErr != nil { // 跨设备兜底 return cpErr } } @@ -244,14 +270,24 @@ func fileSHA256(path string) (string, error) { } func copyFile(src, dst string, perm os.FileMode) error { + return copyFileChecked(context.Background(), nil, src, dst, perm) +} + +func copyFileChecked(ctx context.Context, check func() error, src, dst string, perm os.FileMode) error { in, err := os.Open(src) if err != nil { return err } defer in.Close() + if err := checkExternalEffect(ctx, check); err != nil { + return err + } if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { return err } + if err := checkExternalEffect(ctx, check); err != nil { + return err + } out, err := os.OpenFile(dst+".tmp-s0", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm) if err != nil { return err @@ -264,6 +300,10 @@ func copyFile(src, dst string, perm os.FileMode) error { if err := out.Close(); err != nil { return err } + if err := checkExternalEffect(ctx, check); err != nil { + _ = os.Remove(dst + ".tmp-s0") + return err + } return os.Rename(dst+".tmp-s0", dst) // 复制路径也走原子替换 } @@ -311,12 +351,16 @@ func startOverlaySandbox(ctx context.Context, spec ExecSpec, bin string, out io. upper := filepath.Join(wsRoot, "upper") work := filepath.Join(wsRoot, "work") for _, d := range []string{upper, work} { // frozen 由 freezeBase 自建(重用检查依赖不存在) + if err := checkExternalEffect(ctx, spec.CheckExternalEffect); err != nil { + os.RemoveAll(wsRoot) + return nil, err + } if err := os.MkdirAll(d, 0o755); err != nil { os.RemoveAll(wsRoot) return nil, fmt.Errorf("workspace overlay: %w", err) } } - manifest, err := freezeBase(spec.Dir, frozen) + manifest, err := freezeBaseChecked(ctx, spec.CheckExternalEffect, spec.Dir, frozen) if err != nil { os.RemoveAll(wsRoot) return nil, fmt.Errorf("workspace freeze: %w", err) @@ -339,11 +383,15 @@ func startOverlaySandbox(ctx context.Context, spec ExecSpec, bin string, out io. } cmd.Stdout = out cmd.Stderr = out + if err := checkExternalEffect(ctx, spec.CheckExternalEffect); err != nil { + os.RemoveAll(wsRoot) + return nil, err + } if err := cmd.Start(); err != nil { os.RemoveAll(wsRoot) return nil, err } - return &overlayProcess{inner: cmdProcess{cmd: cmd}, ctx: ctx, wsRoot: wsRoot, dir: spec.Dir, manifest: manifest}, nil + return &overlayProcess{inner: cmdProcess{cmd: cmd}, ctx: ctx, check: spec.CheckExternalEffect, wsRoot: wsRoot, dir: spec.Dir, manifest: manifest}, nil } // overlayProcess 在进程退出时回收工作区:ctx 已取消(超时/中止)→ 整体丢弃; @@ -351,6 +399,7 @@ func startOverlaySandbox(ctx context.Context, spec ExecSpec, bin string, out io. type overlayProcess struct { inner ExecProcess ctx context.Context + check func() error wsRoot string dir string manifest map[string]string @@ -364,9 +413,10 @@ func (p *overlayProcess) Wait() error { p.summary = "\n[工作区变更:已丢弃——执行超时或被中止,本次改动未落地]" return err } - changes, aerr := applyUpper(filepath.Join(p.wsRoot, "upper"), p.dir, p.manifest) + changes, aerr := applyUpperChecked(p.ctx, p.check, filepath.Join(p.wsRoot, "upper"), p.dir, p.manifest) if aerr != nil { log.Printf("[exec] workspace apply partial failure (stopping at first dir error): %v", aerr) + return aerr } if s := workspaceChangeSummary(changes); s != "" { p.summary = "\n" + s diff --git a/internal/tools/external_effect_test.go b/internal/tools/external_effect_test.go new file mode 100644 index 0000000..023a86c --- /dev/null +++ b/internal/tools/external_effect_test.go @@ -0,0 +1,189 @@ +package tools + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + agenttools "agent-native/runtime/tools" + core "agent-system-core" +) + +var errToolActorDisabled = errors.New("tool actor disabled") + +type guardedToolContext struct { + ToolExecCtx + ctx context.Context + check func() error +} + +func (c guardedToolContext) Context() context.Context { + if c.ctx != nil { + return c.ctx + } + return context.Background() +} + +func (c guardedToolContext) CheckExternalEffect() error { + if c.check != nil { + return c.check() + } + return nil +} + +func TestHTTPToolChecksActorAndPropagatesCancellation(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + <-r.Context().Done() + })) + defer srv.Close() + exec := HTTPExecutor(core.HTTPToolSpec{Method: "POST", URL: srv.URL}, nil) + denied := guardedToolContext{ToolExecCtx: dummyCtx{}, check: func() error { return errToolActorDisabled }} + if _, err := exec(denied, nil); !errors.Is(err, errToolActorDisabled) || calls.Load() != 0 { + t.Fatalf("disabled HTTP effect: %v, calls=%d", err, calls.Load()) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := exec(guardedToolContext{ToolExecCtx: dummyCtx{}, ctx: ctx}, nil); !errors.Is(err, context.Canceled) || calls.Load() != 0 { + t.Fatalf("canceled HTTP effect: %v, calls=%d", err, calls.Load()) + } +} + +func TestHTTPToolCancelsInFlightRequest(t *testing.T) { + started := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + close(started) + <-r.Context().Done() + })) + defer srv.Close() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + rc := guardedToolContext{ToolExecCtx: dummyCtx{}, ctx: ctx} + done := make(chan error, 1) + go func() { + _, err := HTTPExecutor(core.HTTPToolSpec{Method: "GET", URL: srv.URL}, nil)(rc, nil) + done <- err + }() + select { + case <-started: + case <-time.After(3 * time.Second): + t.Fatal("HTTP request did not reach server") + } + cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("in-flight request cancellation: %v", err) + } + case <-time.After(3 * time.Second): + t.Fatal("in-flight HTTP request ignored cancellation") + } +} + +type observedSearchProvider struct { + calls int + ctx context.Context +} + +func (p *observedSearchProvider) Search(ctx context.Context, _ agenttools.SearchRequest) (agenttools.SearchResponse, error) { + p.calls++ + p.ctx = ctx + return agenttools.SearchResponse{}, nil +} + +func TestSearchToolChecksActorAndPassesRunContext(t *testing.T) { + p := &observedSearchProvider{} + r := NewRegistry() + if err := RegisterSearchTools(r, p); err != nil { + t.Fatal(err) + } + denied := guardedToolContext{ToolExecCtx: dummyCtx{}, check: func() error { return errToolActorDisabled }} + if res := r.Execute(denied, core.ToolCallRequest{Name: "web_search", Arguments: json.RawMessage(`{"query":"q"}`)}); res.Status != "error" || !strings.Contains(res.Output, errToolActorDisabled.Error()) || p.calls != 0 { + t.Fatalf("disabled search effect: %+v, calls=%d", res, p.calls) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if res := r.Execute(guardedToolContext{ToolExecCtx: dummyCtx{}, ctx: ctx}, core.ToolCallRequest{Name: "web_search", Arguments: json.RawMessage(`{"query":"q"}`)}); res.Status != "error" || p.calls != 0 { + t.Fatalf("canceled search effect: %+v, calls=%d", res, p.calls) + } + if res := r.Execute(guardedToolContext{ToolExecCtx: dummyCtx{}}, core.ToolCallRequest{Name: "web_search", Arguments: json.RawMessage(`{"query":"q"}`)}); res.Status != "ok" || p.calls != 1 || p.ctx == nil { + t.Fatalf("search context: %+v, calls=%d, context=%v", res, p.calls, p.ctx) + } +} + +func TestFSAndInterpreterCheckBeforeFilesystemEffects(t *testing.T) { + root := t.TempDir() + fsCtx := &fakeFSCtx{root: root, owner: "o", conv: "c", fsToolsOn: true} + deniedFS := guardedToolContext{ToolExecCtx: fsCtx, check: func() error { return errToolActorDisabled }} + if _, err := execFSWrite(deniedFS, json.RawMessage(`{"path":"note.md","content":"x"}`)); !errors.Is(err, errToolActorDisabled) { + t.Fatalf("disabled fs write: %v", err) + } + if _, err := os.Stat(filepath.Join(root, "o", "c")); !os.IsNotExist(err) { + t.Fatalf("disabled fs write created sandbox: %v", err) + } + execCtx := &execFake{root: root, owner: "o", conv: "c", execOn: true} + deniedExec := guardedToolContext{ToolExecCtx: execCtx, check: func() error { return errToolActorDisabled }} + if _, err := execShell(deniedExec, json.RawMessage(`{"script":"echo x"}`)); !errors.Is(err, errToolActorDisabled) { + t.Fatalf("disabled shell write/start: %v", err) + } + if _, err := os.Stat(filepath.Join(root, "o", "c")); !os.IsNotExist(err) { + t.Fatalf("disabled shell created sandbox: %v", err) + } +} + +func TestInterpreterRechecksBeforeProcessStart(t *testing.T) { + root := t.TempDir() + mark := filepath.Join(t.TempDir(), "executed") + base := &execFake{root: root, owner: "o", conv: "c", execOn: true} + checks := 0 + rc := guardedToolContext{ToolExecCtx: base, check: func() error { + checks++ + if checks >= 4 { + return errToolActorDisabled + } + return nil + }} + _, err := execShell(rc, json.RawMessage(`{"script":"touch `+mark+`"}`)) + if !errors.Is(err, errToolActorDisabled) || checks < 4 { + t.Fatalf("process start recheck: %v, checks=%d", err, checks) + } + if _, err := os.Stat(mark); !os.IsNotExist(err) { + t.Fatalf("script ran after actor disable: %v", err) + } +} + +type guardedCodeContext struct{ guardedToolContext } + +func (guardedCodeContext) ExecuteTool(core.ToolCallRequest) core.ToolCallResult { + return core.ToolCallResult{Status: "ok"} +} + +func TestCodeOrchestrationChecksBeforeStarting(t *testing.T) { + root := t.TempDir() + base := &execFake{root: root, owner: "o", conv: "c", settings: map[string]bool{"runtime.codeOrchestration": true}} + rc := guardedCodeContext{guardedToolContext{ToolExecCtx: base, check: func() error { return errToolActorDisabled }}} + if _, err := execCodeScript(rc, json.RawMessage(`{"script":"print('hi')"}`)); !errors.Is(err, errToolActorDisabled) { + t.Fatalf("disabled code orchestration: %v", err) + } +} + +func TestApplyUpperCheckedStopsBeforeWorkspaceWrite(t *testing.T) { + dir := t.TempDir() + upper := t.TempDir() + write(t, filepath.Join(upper, "new.txt"), "new") + if _, err := applyUpperChecked(context.Background(), func() error { return errToolActorDisabled }, upper, dir, map[string]string{}); !errors.Is(err, errToolActorDisabled) { + t.Fatalf("disabled workspace apply: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "new.txt")); !os.IsNotExist(err) { + t.Fatalf("disabled workspace apply wrote file: %v", err) + } +} diff --git a/internal/tools/fs.go b/internal/tools/fs.go index 913c93a..8f84d7d 100644 --- a/internal/tools/fs.go +++ b/internal/tools/fs.go @@ -100,6 +100,9 @@ func execFSRead(rc ToolExecCtx, args json.RawMessage) (string, error) { return "", err } if stamps, ok := rc.(fileStamps); ok { + if err := checkToolExternalEffect(rc); err != nil { + return "", err + } _ = stamps.NoteFileRead(abs) } return string(b), nil @@ -135,6 +138,9 @@ func execFSWrite(rc ToolExecCtx, args json.RawMessage) (string, error) { if err != nil { return "", err } + if err := checkToolExternalEffect(rc); err != nil { + return "", err + } if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { return "", err } @@ -152,16 +158,25 @@ func execFSWrite(rc ToolExecCtx, args json.RawMessage) (string, error) { } } before, existed := fsBefore(abs) + if err := checkToolExternalEffect(rc); err != nil { + return "", err + } if err := os.WriteFile(abs, []byte(a.Content), 0o644); err != nil { return "", err } if cp, ok := rc.(interface { NoteFileCheckpoint(rel string, before []byte, existed bool, after []byte) error }); ok { + if err := checkToolExternalEffect(rc); err != nil { + return "", err + } _ = cp.NoteFileCheckpoint(a.Path, before, existed, []byte(a.Content)) } // 不变式:工作区文件 = 工件面板可见(内容同步入对象库,工件自包含可下载)。 base := filepath.Base(abs) + if err := checkToolExternalEffect(rc); err != nil { + return "", err + } art, err := rc.WriteArtifact(base, "file", mimeOfExt(base), []byte(a.Content)) if err != nil { return "", fmt.Errorf("fs_write_file: written %s but artifact registration failed: %w", abs, err) @@ -252,12 +267,23 @@ func mimeOfExt(name string) string { } // rejectBinaryOfficeWrite 拒绝把字符串直接写成伪二进制 Office/PDF。 -// 带排版的文件由 run_python 生成后再 register_artifact 登记。 +// 普通 Word 由 render_document 生成;其他带排版二进制由受控生成器产出后登记。 func rejectBinaryOfficeWrite(name string) error { switch strings.ToLower(filepath.Ext(name)) { - case ".doc", ".docx", ".xls", ".xlsx", ".xlsm", ".ppt", ".pptx", ".pdf": - return fmt.Errorf("%s 是二进制文档扩展名,不能把字符串直接写成该文件。请用 run_python 生成到工作区后,用 register_artifact 登记", filepath.Ext(name)) + case ".doc", ".docx", ".docm", ".dotx", ".dotm": + return fmt.Errorf("%s 是 Word 二进制扩展名,不能写入字符串;普通 Word 请用 render_document,项目或受控 Word 交给 RDM", filepath.Ext(name)) + case ".xls", ".xlsx", ".xlsm", ".ppt", ".pptx", ".pdf": + return fmt.Errorf("%s 是二进制文档扩展名,不能把字符串直接写成该文件;请用受控生成器产出后再登记", filepath.Ext(name)) default: return nil } } + +func isWordBinaryName(name string) bool { + switch strings.ToLower(filepath.Ext(name)) { + case ".doc", ".docx", ".docm", ".dotx", ".dotm": + return true + default: + return false + } +} diff --git a/internal/tools/fs_test.go b/internal/tools/fs_test.go index 0853014..6890b10 100644 --- a/internal/tools/fs_test.go +++ b/internal/tools/fs_test.go @@ -165,13 +165,13 @@ func TestFSUnconfiguredRoot(t *testing.T) { func TestBinaryOfficeWriteRejected(t *testing.T) { r := newFSRegistry(t) rc := &fakeFSCtx{root: t.TempDir(), owner: "o1", conv: "c1", fsToolsOn: true} - for _, args := range []struct{ name, body string }{ - {"fs_write_file", `{"path":"a.docx","content":"plain"}`}, - {"fs_write_file", `{"path":"a.pdf","content":"plain"}`}, - {"write_artifact", `{"name":"a.xlsx","content":"plain"}`}, + for _, args := range []struct{ name, body, guidance string }{ + {"fs_write_file", `{"path":"a.docx","content":"plain"}`, "render_document"}, + {"fs_write_file", `{"path":"a.pdf","content":"plain"}`, "受控生成器"}, + {"write_artifact", `{"name":"a.xlsx","content":"plain"}`, "受控生成器"}, } { res := call(t, r, rc, args.name, args.body) - if res.Status != "error" || !strings.Contains(res.Output, "run_python") { + if res.Status != "error" || !strings.Contains(res.Output, args.guidance) { t.Errorf("%s %s: %s %s", args.name, args.body, res.Status, res.Output) } } diff --git a/internal/tools/http.go b/internal/tools/http.go index dce8bc9..0483cd1 100644 --- a/internal/tools/http.go +++ b/internal/tools/http.go @@ -31,7 +31,8 @@ func HTTPExecutor(spec core.HTTPToolSpec, settingsLookup func(key string) string if spec.BodyTemplate != "" && (method == http.MethodPost || method == http.MethodPut || method == http.MethodPatch) { body = bytes.NewReader([]byte(expandTemplate(spec.BodyTemplate, params))) } - req, err := http.NewRequest(method, urlStr, body) + ctx := execParentCtx(rc) + req, err := http.NewRequestWithContext(ctx, method, urlStr, body) if err != nil { return "", err } @@ -56,6 +57,9 @@ func HTTPExecutor(spec core.HTTPToolSpec, settingsLookup func(key string) string timeout = 15 * time.Second } client := &http.Client{Timeout: timeout} + if err := checkToolExternalEffect(rc); err != nil { + return "", err + } resp, err := client.Do(req) if err != nil { return "", fmt.Errorf("http tool: %w", err) diff --git a/internal/tools/ooxmlgate.go b/internal/tools/ooxmlgate.go new file mode 100644 index 0000000..5e647b5 --- /dev/null +++ b/internal/tools/ooxmlgate.go @@ -0,0 +1,910 @@ +// OOXML 交付物结构自检(登记口 L1):纯 Go 静态校验,零外部依赖。 +// 背景:Agent 绕开 housestyle kit 用 zipfile 手拼 OOXML 会产出 Office 拒开的 +// 残缺包。已知的两类事故形态:①部件整体缺失(缺母版/主题/docProps);②部件 +// 齐全但引用断裂(根元素漏声明 xmlns:r、sldIdLst 与关系表不一致、 +// [Content_Types] 缺 Override)——②能骗过"部件存在性"检查,Office 依旧打不开。 +// 这里按「容器合法 + 必备部件 + 引用链完整 + 引用一致 + 内容类型覆盖」在登记前 +// 拦截,失败信息直接指向 kit 重建路径。结构级评分(housestyle.lint / 终轮门禁) +// 不在本层:本层只保证「Office 打得开」这条底线。 +package tools + +import ( + "archive/zip" + "bytes" + "encoding/xml" + "fmt" + "io" + "net/url" + "path" + "path/filepath" + "strings" +) + +const ( + nsPackageRel = "http://schemas.openxmlformats.org/package/2006/relationships" + nsContentTypes = "http://schemas.openxmlformats.org/package/2006/content-types" + nsPresentationML = "http://schemas.openxmlformats.org/presentationml/2006/main" + nsOfficeDocRel = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" + relOfficeDoc = nsOfficeDocRel + "/officeDocument" + relStrictOffice = "http://purl.oclc.org/ooxml/officeDocument/relationships/officeDocument" + ctWordDocument = "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml" + ctWordTemplate = "application/vnd.openxmlformats-officedocument.wordprocessingml.template.main+xml" + ctWordMacroDoc = "application/vnd.ms-word.document.macroEnabled.main+xml" + ctWordMacroTpl = "application/vnd.ms-word.template.macroEnabledTemplate.main+xml" + ctPresentation = "application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml" + ctSlide = "application/vnd.openxmlformats-officedocument.presentationml.slide+xml" + ctSlideLayout = "application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml" + ctSlideMaster = "application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml" + ctTheme = "application/vnd.openxmlformats-officedocument.theme+xml" +) + +// ooxmlStructuralCheck 校验 name 指向的 Office 交付物是否具备可被 Office 打开 +// 的最小 OPC 结构。不在管控扩展名内的文件直接放行。 +func ooxmlStructuralCheck(name string, content []byte) error { + switch strings.ToLower(filepath.Ext(name)) { + case ".docx": + return ooxmlCheckParts(content, "word/document.xml") + case ".xlsx": + return ooxmlCheckParts(content, "xl/workbook.xml") + case ".pptx": + return ooxmlCheckPptx(content) + default: + return nil + } +} + +// isWordprocessingMLPackage identifies Word OPC packages from the package-level +// officeDocument relationship and the resolved main part's content type. A +// purported officeDocument package with malformed, ambiguous, or unsafe +// metadata is treated as Word so the registration boundary fails closed. +func isWordprocessingMLPackage(content []byte) bool { + zr, err := zip.NewReader(bytes.NewReader(content), int64(len(content))) + if err != nil { + return false + } + isWord, purportsOfficeDocument, err := ooxmlClassifyWordPackage(zr) + if err != nil { + return purportsOfficeDocument + } + return isWord +} + +func ooxmlClassifyWordPackage(zr *zip.Reader) (isWord, purportsOfficeDocument bool, err error) { + relsFile, found, err := ooxmlUniquePart(zr, "_rels/.rels") + if err != nil { + return false, true, err + } + if !found { + return false, false, nil + } + rels, purportsOfficeDocument, err := ooxmlPackageRelationships(relsFile) + if err != nil { + return false, purportsOfficeDocument, err + } + + var officeRels []ooxmlRel + for _, rel := range rels { + if rel.typ == relOfficeDoc || rel.typ == relStrictOffice { + officeRels = append(officeRels, rel) + } + } + if len(officeRels) == 0 { + return false, false, nil + } + if len(officeRels) != 1 { + return false, true, fmt.Errorf("package relationships contain %d officeDocument relationships", len(officeRels)) + } + + mainPart, err := ooxmlSafePackageTarget(officeRels[0].target, officeRels[0].targetMode) + if err != nil { + return false, true, err + } + if _, found, err := ooxmlUniquePartFold(zr, mainPart); err != nil || !found { + if err != nil { + return false, true, err + } + return false, true, fmt.Errorf("officeDocument target %q is missing", mainPart) + } + + contentTypesFile, found, err := ooxmlUniquePart(zr, "[Content_Types].xml") + if err != nil { + return false, true, err + } + if !found { + return false, true, fmt.Errorf("officeDocument package is missing [Content_Types].xml") + } + contentTypes, err := ooxmlParseContentTypes(contentTypesFile) + if err != nil { + return false, true, err + } + contentType, found := contentTypes.contentType(mainPart) + if !found { + return false, true, fmt.Errorf("officeDocument target %q has no content type", mainPart) + } + return ooxmlIsWordMainContentType(contentType), true, nil +} + +type ooxmlContentTypeTable struct { + defaults map[string]string + overrides map[string]string +} + +func (t ooxmlContentTypeTable) contentType(part string) (string, bool) { + if contentType, ok := t.overrides[strings.ToLower(part)]; ok { + return contentType, true + } + extension := strings.TrimPrefix(path.Ext(part), ".") + contentType, ok := t.defaults[strings.ToLower(extension)] + return contentType, ok +} + +func ooxmlIsWordMainContentType(contentType string) bool { + switch contentType { + case ctWordDocument, ctWordTemplate, ctWordMacroDoc, ctWordMacroTpl: + return true + default: + return false + } +} + +func ooxmlPackageRelationships(file *zip.File) ([]ooxmlRel, bool, error) { + rc, err := file.Open() + if err != nil { + return nil, false, fmt.Errorf("_rels/.rels cannot be read: %w", err) + } + defer rc.Close() + + dec := xml.NewDecoder(rc) + var rels []ooxmlRel + seenIDs := map[string]bool{} + rootSeen := false + rootValid := false + depth := 0 + purportsOfficeDocument := false + var metadataErr error + for { + tok, err := dec.Token() + if err == io.EOF { + break + } + if err != nil { + return nil, purportsOfficeDocument, fmt.Errorf("_rels/.rels is not valid XML: %w", err) + } + switch node := tok.(type) { + case xml.EndElement: + depth-- + continue + case xml.StartElement: + depth++ + if !rootSeen { + rootSeen = true + rootValid = node.Name.Space == nsPackageRel && node.Name.Local == "Relationships" + if !rootValid { + metadataErr = fmt.Errorf("_rels/.rels has an invalid Relationships root") + } + continue + } + + var rel ooxmlRel + seenAttrs := map[string]bool{} + for _, attr := range node.Attr { + if attr.Name.Space != "" { + continue + } + if seenAttrs[attr.Name.Local] { + metadataErr = fmt.Errorf("_rels/.rels contains duplicate %s attributes", attr.Name.Local) + } + seenAttrs[attr.Name.Local] = true + switch attr.Name.Local { + case "Id": + rel.id = attr.Value + case "Type": + rel.typ = attr.Value + case "Target": + rel.target = attr.Value + case "TargetMode": + rel.targetMode = attr.Value + } + } + if rel.typ == relOfficeDoc || rel.typ == relStrictOffice { + purportsOfficeDocument = true + } + if depth != 2 || node.Name.Space != nsPackageRel || node.Name.Local != "Relationship" { + metadataErr = fmt.Errorf("_rels/.rels contains an invalid relationship element") + continue + } + if rel.id == "" || rel.typ == "" || rel.target == "" { + metadataErr = fmt.Errorf("_rels/.rels contains an incomplete Relationship") + continue + } + if seenIDs[rel.id] { + metadataErr = fmt.Errorf("_rels/.rels contains duplicate relationship Id %q", rel.id) + continue + } + seenIDs[rel.id] = true + rels = append(rels, rel) + } + } + if !rootSeen { + return nil, false, fmt.Errorf("_rels/.rels is empty") + } + if !rootValid || metadataErr != nil { + return nil, purportsOfficeDocument, metadataErr + } + return rels, purportsOfficeDocument, nil +} + +func ooxmlParseContentTypes(file *zip.File) (ooxmlContentTypeTable, error) { + table := ooxmlContentTypeTable{ + defaults: map[string]string{}, + overrides: map[string]string{}, + } + rc, err := file.Open() + if err != nil { + return table, fmt.Errorf("[Content_Types].xml cannot be read: %w", err) + } + defer rc.Close() + + dec := xml.NewDecoder(rc) + rootSeen := false + for { + tok, err := dec.Token() + if err == io.EOF { + break + } + if err != nil { + return table, fmt.Errorf("[Content_Types].xml is not valid XML: %w", err) + } + se, ok := tok.(xml.StartElement) + if !ok { + continue + } + if !rootSeen { + rootSeen = true + if se.Name.Space != nsContentTypes || se.Name.Local != "Types" { + return table, fmt.Errorf("[Content_Types].xml has an invalid Types root") + } + continue + } + if se.Name.Space != nsContentTypes { + continue + } + attrs := map[string]string{} + for _, attr := range se.Attr { + if attr.Name.Space == "" { + attrs[attr.Name.Local] = attr.Value + } + } + switch se.Name.Local { + case "Default": + extension := strings.ToLower(attrs["Extension"]) + contentType := attrs["ContentType"] + if extension == "" || strings.ContainsAny(extension, ".\\/ \t\r\n") || contentType == "" { + return table, fmt.Errorf("[Content_Types].xml contains an invalid Default") + } + if _, exists := table.defaults[extension]; exists { + return table, fmt.Errorf("[Content_Types].xml contains duplicate Default for %q", extension) + } + table.defaults[extension] = contentType + case "Override": + partName, err := ooxmlSafePartName(attrs["PartName"]) + if err != nil || attrs["ContentType"] == "" { + return table, fmt.Errorf("[Content_Types].xml contains an invalid Override") + } + key := strings.ToLower(partName) + if _, exists := table.overrides[key]; exists { + return table, fmt.Errorf("[Content_Types].xml contains duplicate Override for %q", partName) + } + table.overrides[key] = attrs["ContentType"] + } + } + if !rootSeen { + return table, fmt.Errorf("[Content_Types].xml is empty") + } + return table, nil +} + +func ooxmlSafePackageTarget(target, targetMode string) (string, error) { + if targetMode != "" && !strings.EqualFold(targetMode, "Internal") { + return "", fmt.Errorf("officeDocument target must be internal") + } + return ooxmlSafePartPath(target, strings.HasPrefix(target, "/")) +} + +func ooxmlSafePartName(partName string) (string, error) { + if !strings.HasPrefix(partName, "/") { + return "", fmt.Errorf("OPC part name must be absolute") + } + return ooxmlSafePartPath(partName, true) +} + +func ooxmlSafePartPath(raw string, absolute bool) (string, error) { + if raw == "" || strings.ContainsAny(raw, "\\\x00") { + return "", fmt.Errorf("unsafe OPC part path %q", raw) + } + parsed, err := url.Parse(raw) + if err != nil || parsed.IsAbs() || parsed.Host != "" || parsed.Opaque != "" || parsed.RawQuery != "" || parsed.Fragment != "" { + return "", fmt.Errorf("unsafe OPC part path %q", raw) + } + decoded, err := url.PathUnescape(parsed.EscapedPath()) + if err != nil || strings.ContainsAny(decoded, "\\\x00") { + return "", fmt.Errorf("unsafe OPC part path %q", raw) + } + if absolute != strings.HasPrefix(decoded, "/") { + return "", fmt.Errorf("unsafe OPC part path %q", raw) + } + decoded = strings.TrimPrefix(decoded, "/") + if decoded == "" || strings.HasSuffix(decoded, "/") { + return "", fmt.Errorf("unsafe OPC part path %q", raw) + } + for _, segment := range strings.Split(decoded, "/") { + if segment == "" || segment == "." || segment == ".." { + return "", fmt.Errorf("unsafe OPC part path %q", raw) + } + } + return decoded, nil +} + +func ooxmlUniquePart(zr *zip.Reader, name string) (*zip.File, bool, error) { + var found *zip.File + for _, file := range zr.File { + if file.Name != name { + continue + } + if found != nil { + return nil, false, fmt.Errorf("duplicate OPC metadata part %s", name) + } + found = file + } + return found, found != nil, nil +} + +func ooxmlUniquePartFold(zr *zip.Reader, name string) (*zip.File, bool, error) { + var found *zip.File + for _, file := range zr.File { + if !strings.EqualFold(file.Name, name) { + continue + } + if found != nil { + return nil, false, fmt.Errorf("duplicate OPC part %s", name) + } + found = file + } + return found, found != nil, nil +} + +// ooxmlCheckParts 校验容器与必备正文部件存在、XML 合法、命名空间前缀齐备 +// (docx/xlsx 底线)。 +func ooxmlCheckParts(content []byte, parts ...string) error { + zr, err := zip.NewReader(bytes.NewReader(content), int64(len(content))) + if err != nil { + return fmt.Errorf("不是有效的 OOXML 容器(%v)——Office 交付物必须由 housestyle kit 生成", err) + } + if err := ooxmlWellFormed(zr, "[Content_Types].xml"); err != nil { + return err + } + for _, part := range parts { + if err := ooxmlWellFormed(zr, part); err != nil { + return err + } + } + return ooxmlNamespaceStrict(zr) +} + +// ooxmlCheckPptx 校验演示稿:引用链完整(slide→layout→master→theme)、 +// sldIdLst/sldMasterIdLst 与关系表一致、[Content_Types] 覆盖全部结构部件。 +func ooxmlCheckPptx(content []byte) error { + zr, err := zip.NewReader(bytes.NewReader(content), int64(len(content))) + if err != nil { + return fmt.Errorf("不是有效的 OOXML 容器(%v)——Office 交付物必须由 housestyle kit 生成", err) + } + for _, part := range []string{"[Content_Types].xml", "ppt/presentation.xml"} { + if err := ooxmlWellFormed(zr, part); err != nil { + return err + } + } + if err := ooxmlRequireElement(zr, "ppt/presentation.xml", "sldMasterIdLst"); err != nil { + return fmt.Errorf("presentation.xml 缺少 (幻灯片母版列表)——母版与主题是必备结构") + } + // 严格命名空间先行:后续的 r:id 属性解析依赖前缀正确声明。 + if err := ooxmlNamespaceStrict(zr); err != nil { + return err + } + presRels, err := ooxmlRels(zr, "ppt/presentation.xml") + if err != nil { + return err + } + // sldIdLst / sldMasterIdLst 是幻灯片与母版的权威清单,必须与关系表一致。 + masterIDs, err := ooxmlIdListOf(zr, "ppt/presentation.xml", "sldMasterIdLst", "sldMasterId") + if err != nil { + return err + } + slideIDs, err := ooxmlIdListOf(zr, "ppt/presentation.xml", "sldIdLst", "sldId") + if err != nil { + return err + } + relsByType := func(suffix string) map[string]string { + out := map[string]string{} + for _, rel := range presRels { + if strings.HasSuffix(rel.typ, suffix) { + out[rel.id] = ooxmlResolve("ppt/presentation.xml", rel.target) + } + } + return out + } + masterTargets, err := ooxmlConsistent(masterIDs, relsByType("/slideMaster"), "sldMasterIdLst", "slideMaster") + if err != nil { + return err + } + slideTargets, err := ooxmlConsistent(slideIDs, relsByType("/slide"), "sldIdLst", "slide") + if err != nil { + return err + } + if len(masterTargets) == 0 { + return fmt.Errorf("presentation 未引用任何幻灯片母版(slideMaster)——母版与主题是必备结构") + } + if len(slideTargets) == 0 { + return fmt.Errorf("presentation 未引用任何幻灯片") + } + themes := map[string]bool{} + for _, master := range masterTargets { + if err := ooxmlMasterChain(zr, master, themes); err != nil { + return err + } + } + masterSet := map[string]bool{} + for _, m := range masterTargets { + masterSet[m] = true + } + layouts := map[string]bool{} + for _, slide := range slideTargets { + if err := ooxmlWellFormed(zr, slide); err != nil { + return err + } + rels, err := ooxmlRels(zr, slide) + if err != nil { + return fmt.Errorf("幻灯片 %s:%v", slide, err) + } + layout := "" + for _, rel := range rels { + if strings.HasSuffix(rel.typ, "/slideLayout") { + layout = ooxmlResolve(slide, rel.target) + break + } + } + if layout == "" { + return fmt.Errorf("幻灯片 %s 没有指向 slideLayout 的关系——每张 slide 必须经关系表引用一个版式", slide) + } + if layouts[layout] { + continue + } + layouts[layout] = true + if err := ooxmlWellFormed(zr, layout); err != nil { + return err + } + if err := ooxmlLayoutMaster(zr, layout, masterSet); err != nil { + return err + } + } + // [Content_Types].xml 必须对每个结构部件给出 Override;缺了会被 Office + // 当成未知类型拒开(真实事故:只写了 slide1 的 Override)。 + overrides, err := ooxmlCTOverrides(zr) + if err != nil { + return err + } + want := map[string]string{"ppt/presentation.xml": ctPresentation} + for _, t := range masterTargets { + want[t] = ctSlideMaster + } + for _, t := range slideTargets { + want[t] = ctSlide + } + for t := range layouts { + want[t] = ctSlideLayout + } + for t := range themes { + want[t] = ctTheme + } + for part, ct := range want { + got, ok := overrides["/"+part] + if !ok { + return fmt.Errorf("[Content_Types].xml 缺少 %s 的 Override(%s)——手拼包常见缺陷,请用 housestyle kit 重建", part, ct) + } + if got != ct { + return fmt.Errorf("[Content_Types].xml 中 %s 的内容类型不符:got %s want %s", part, got, ct) + } + } + return nil +} + +// ooxmlMasterChain 校验母版存在、声明版式列表且与关系表一致、引用存在的 theme。 +func ooxmlMasterChain(zr *zip.Reader, master string, themes map[string]bool) error { + if err := ooxmlWellFormed(zr, master); err != nil { + return err + } + rels, err := ooxmlRels(zr, master) + if err != nil { + return err + } + if err := ooxmlRequireElement(zr, master, "sldLayoutIdLst"); err != nil { + return fmt.Errorf("母版 %s 缺少 (版式列表)——母版必须声明至少一个版式", master) + } + layoutIDs, err := ooxmlIdListOf(zr, master, "sldLayoutIdLst", "sldLayoutId") + if err != nil { + return err + } + layoutTargets := map[string]string{} + for _, rel := range rels { + if strings.HasSuffix(rel.typ, "/slideLayout") { + layoutTargets[rel.id] = ooxmlResolve(master, rel.target) + } + } + resolved, err := ooxmlConsistent(layoutIDs, layoutTargets, "sldLayoutIdLst", "slideLayout") + if err != nil { + return err + } + for _, target := range resolved { + if err := ooxmlWellFormed(zr, target); err != nil { + return err + } + } + for _, rel := range rels { + if strings.HasSuffix(rel.typ, "/theme") { + theme := ooxmlResolve(master, rel.target) + if err := ooxmlWellFormed(zr, theme); err != nil { + return err + } + themes[theme] = true + return nil + } + } + return fmt.Errorf("母版 %s 没有指向 theme 的关系", master) +} + +// ooxmlLayoutMaster 校验版式的关系表引用了存在的、且被 presentation 列出的 +// slideMaster。 +func ooxmlLayoutMaster(zr *zip.Reader, layout string, masters map[string]bool) error { + rels, err := ooxmlRels(zr, layout) + if err != nil { + return err + } + for _, rel := range rels { + if strings.HasSuffix(rel.typ, "/slideMaster") { + master := ooxmlResolve(layout, rel.target) + if !masters[master] { + return fmt.Errorf("版式 %s 指向的母版 %s 未在 presentation 的 sldMasterIdLst 中声明", layout, master) + } + return nil + } + } + return fmt.Errorf("版式 %s 没有指向 slideMaster 的关系", layout) +} + +// ooxmlIdListOf 提取 part 中 列表内每个 的 r:id 属性。 +func ooxmlIdListOf(zr *zip.Reader, part, listLocal, itemLocal string) ([]string, error) { + f, err := ooxmlPart(zr, part) + if err != nil { + return nil, err + } + rc, err := f.Open() + if err != nil { + return nil, fmt.Errorf("部件 %s 无法读取(%v)", part, err) + } + defer rc.Close() + dec := xml.NewDecoder(rc) + var ids []string + inList := false + for { + tok, err := dec.Token() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("部件 %s 不是合法 XML(%v)", part, err) + } + switch se := tok.(type) { + case xml.StartElement: + if se.Name.Space == nsPresentationML && se.Name.Local == listLocal { + inList = true + continue + } + if inList && se.Name.Space == nsPresentationML && se.Name.Local == itemLocal { + if rid, ok := ooxmlAttrNS(se, nsOfficeDocRel, "id"); ok { + ids = append(ids, rid) + } else { + return nil, fmt.Errorf("%s 的 <%s> 缺少 r:id 属性——引用链断裂,请用 housestyle kit 重建", listLocal, itemLocal) + } + } + case xml.EndElement: + if inList && se.Name.Space == nsPresentationML && se.Name.Local == listLocal { + inList = false + } + } + } + return ids, nil +} + +// ooxmlConsistent 校验 ID 清单与关系表双向一致:清单里每个 r:id 都要命中 +// 同类型关系,关系表也不得多出或缺失。返回按清单顺序解析出的目标部件。 +func ooxmlConsistent(ids []string, targets map[string]string, listName, what string) ([]string, error) { + seen := map[string]bool{} + var out []string + for _, id := range ids { + if seen[id] { + return nil, fmt.Errorf("%s 重复引用 %s", listName, id) + } + seen[id] = true + target, ok := targets[id] + if !ok { + return nil, fmt.Errorf("%s 引用的 %s 在关系表中不存在(rId=%s 断链)——请用 housestyle kit 重建", listName, what, id) + } + out = append(out, target) + } + if len(targets) != len(ids) { + return nil, fmt.Errorf("%s 与关系表数量不一致(清单 %d 条,关系表 %d 条)——存在未引用或缺失的 %s 关系,请用 housestyle kit 重建", listName, len(ids), len(targets), what) + } + return out, nil +} + +// ooxmlNamespaceStrict 扫描包内全部 XML 部件:元素或属性使用了未声明的 +// 命名空间前缀即报错。判定基于声明集合而非 URI 形态(Go 解码器对未声明 +// 前缀保留原始前缀名作为 Space;已声明则解析为完整 URI,urn: 等 scheme +// 同样正确解析),避免对非 http URI 的误伤。 +func ooxmlNamespaceStrict(zr *zip.Reader) error { + for _, f := range zr.File { + if !strings.HasSuffix(f.Name, ".xml") && !strings.HasSuffix(f.Name, ".rels") { + continue + } + declared, err := ooxmlDeclaredNamespaces(f) + if err != nil { + return err + } + rc, err := f.Open() + if err != nil { + return fmt.Errorf("部件 %s 无法读取(%v)", f.Name, err) + } + dec := xml.NewDecoder(rc) + var scanErr error + scan: + for { + tok, err := dec.Token() + if err == io.EOF { + break + } + if err != nil { + scanErr = fmt.Errorf("部件 %s 不是合法 XML(%v)", f.Name, err) + break + } + se, ok := tok.(xml.StartElement) + if !ok { + continue + } + if sp := ooxmlUnboundPrefix(se.Name.Space, declared); sp != "" { + scanErr = fmt.Errorf("部件 %s 使用了未声明的命名空间前缀 xmlns:%s(元素 <%s:%s>)——根元素补 xmlns:%s 声明或用 housestyle kit 重建", f.Name, sp, sp, se.Name.Local, sp) + break + } + for _, a := range se.Attr { + if sp := ooxmlUnboundPrefix(a.Name.Space, declared); sp != "" { + scanErr = fmt.Errorf("部件 %s 使用了未声明的命名空间前缀 xmlns:%s(属性 %s:%s)——根元素补 xmlns:%s 声明或用 housestyle kit 重建", f.Name, sp, sp, a.Name.Local, sp) + break scan + } + } + } + rc.Close() + if scanErr != nil { + return scanErr + } + } + return nil +} + +// ooxmlDeclaredNamespaces 收集部件内全部 xmlns 声明(含默认命名空间与任意 +// 位置的限定声明)。前缀 xmlns 为保留名:Go 把 xmlns:r 声明报为 +// Space="xmlns"、Local="r";默认声明 xmlns="…" 报为 Space=""、Local="xmlns"。 +func ooxmlDeclaredNamespaces(f *zip.File) (map[string]bool, error) { + rc, err := f.Open() + if err != nil { + return nil, fmt.Errorf("部件 %s 无法读取(%v)", f.Name, err) + } + defer rc.Close() + // xml 前缀由 XML 规范预绑定。Go 解码器把它展开成 URI,而不是留下前缀名。 + out := map[string]bool{"xml": true, "xmlns": true, "http://www.w3.org/XML/1998/namespace": true} + dec := xml.NewDecoder(rc) + for { + tok, err := dec.Token() + if err == io.EOF { + return out, nil + } + if err != nil { + return nil, fmt.Errorf("部件 %s 不是合法 XML(%v)", f.Name, err) + } + se, ok := tok.(xml.StartElement) + if !ok { + continue + } + for _, a := range se.Attr { + switch { + case a.Name.Space == "xmlns": + out[a.Value] = true + case a.Name.Space == "" && a.Name.Local == "xmlns": + out[a.Value] = true + } + } + } +} + +// ooxmlUnboundPrefix 返回未绑定的前缀名;已声明、无前缀与内建 xml 前缀返回空串。 +func ooxmlUnboundPrefix(space string, declared map[string]bool) string { + if space == "" || declared[space] { + return "" + } + return space +} + +// ooxmlCTOverrides 解析 [Content_Types].xml 的 Override 清单(部件路径 → 内容类型)。 +func ooxmlCTOverrides(zr *zip.Reader) (map[string]string, error) { + f, err := ooxmlPart(zr, "[Content_Types].xml") + if err != nil { + return nil, err + } + rc, err := f.Open() + if err != nil { + return nil, fmt.Errorf("[Content_Types].xml 无法读取(%v)", err) + } + defer rc.Close() + out := map[string]string{} + dec := xml.NewDecoder(rc) + for { + tok, err := dec.Token() + if err == io.EOF { + return out, nil + } + if err != nil { + return nil, fmt.Errorf("[Content_Types].xml 不是合法 XML(%v)", err) + } + se, ok := tok.(xml.StartElement) + if !ok || se.Name.Local != "Override" { + continue + } + partName, contentType := "", "" + for _, a := range se.Attr { + switch a.Name.Local { + case "PartName": + partName = a.Value + case "ContentType": + contentType = a.Value + } + } + out[partName] = contentType + } +} + +// ooxmlWellFormed 确认部件存在且可被 XML 解析器完整读取。 +func ooxmlWellFormed(zr *zip.Reader, part string) error { + f, err := ooxmlPart(zr, part) + if err != nil { + return err + } + rc, err := f.Open() + if err != nil { + return fmt.Errorf("部件 %s 无法读取(%v)", part, err) + } + defer rc.Close() + dec := xml.NewDecoder(rc) + for { + _, err := dec.Token() + if err == io.EOF { + return nil + } + if err != nil { + return fmt.Errorf("部件 %s 不是合法 XML(%v)", part, err) + } + } +} + +// ooxmlRequireElement 确认部件内出现指定局部名的元素(不校验位置与属性)。 +func ooxmlRequireElement(zr *zip.Reader, part, local string) error { + f, err := ooxmlPart(zr, part) + if err != nil { + return err + } + rc, err := f.Open() + if err != nil { + return fmt.Errorf("部件 %s 无法读取(%v)", part, err) + } + defer rc.Close() + dec := xml.NewDecoder(rc) + for { + tok, err := dec.Token() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("部件 %s 不是合法 XML(%v)", part, err) + } + if se, ok := tok.(xml.StartElement); ok && se.Name.Local == local { + return nil + } + } + return fmt.Errorf("部件 %s 缺少 <%s> 声明", part, local) +} + +// ooxmlAttrNS 取指定命名空间下的属性值。 +func ooxmlAttrNS(se xml.StartElement, space, local string) (string, bool) { + for _, a := range se.Attr { + if a.Name.Space == space && a.Name.Local == local { + return a.Value, true + } + } + return "", false +} + +// ooxmlPart 按部件路径精确查找(ZIP 内无目录条目,直接比对名字)。 +func ooxmlPart(zr *zip.Reader, name string) (*zip.File, error) { + for _, f := range zr.File { + if f.Name == name { + return f, nil + } + } + return nil, fmt.Errorf("缺少必备部件 %s", name) +} + +// ooxmlRel 是 rels 部件里的一条关系:Id、类型 URI 与目标(原样保留相对形式)。 +type ooxmlRel struct { + id string + typ string + target string + targetMode string +} + +// ooxmlRels 解析 part 的关系表(/_rels/.rels)。 +func ooxmlRels(zr *zip.Reader, part string) ([]ooxmlRel, error) { + dir, base := path.Split(part) + relsPart := path.Join(dir, "_rels", base+".rels") + f, err := ooxmlPart(zr, relsPart) + if err != nil { + return nil, fmt.Errorf("%s 的关系表缺失(%v)", part, err) + } + rc, err := f.Open() + if err != nil { + return nil, fmt.Errorf("%s 无法读取(%v)", relsPart, err) + } + defer rc.Close() + var rels []ooxmlRel + dec := xml.NewDecoder(rc) + for { + tok, err := dec.Token() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("%s 不是合法 XML(%v)", relsPart, err) + } + se, ok := tok.(xml.StartElement) + if !ok || se.Name.Local != "Relationship" { + continue + } + var rel ooxmlRel + for _, attr := range se.Attr { + switch attr.Name.Local { + case "Id": + rel.id = attr.Value + case "Type": + rel.typ = attr.Value + case "Target": + rel.target = attr.Value + } + } + rels = append(rels, rel) + } + return rels, nil +} + +// ooxmlResolve 把关系目标解析为包内部件路径:绝对目标去掉前导 /, +// 相对目标基于源部件目录解析(path.Join 自带 .. 清理)。 +func ooxmlResolve(srcPart, target string) string { + if strings.HasPrefix(target, "/") { + return strings.TrimPrefix(target, "/") + } + return path.Join(path.Dir(srcPart), target) +} diff --git a/internal/tools/ooxmlgate_test.go b/internal/tools/ooxmlgate_test.go new file mode 100644 index 0000000..227775b --- /dev/null +++ b/internal/tools/ooxmlgate_test.go @@ -0,0 +1,552 @@ +package tools + +import ( + "archive/zip" + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// zipOf 按序打包字符串部件(保持插入序便于断言)。 +func zipOf(t *testing.T, files map[string]string) []byte { + t.Helper() + buf := new(bytes.Buffer) + w := zip.NewWriter(buf) + for name, body := range files { + f, err := w.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err := f.Write([]byte(body)); err != nil { + t.Fatal(err) + } + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +const ( + gatePackageRelNS = "http://schemas.openxmlformats.org/package/2006/relationships" + gateOfficeRel = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" + gateStrictOffice = "http://purl.oclc.org/ooxml/officeDocument/relationships/officeDocument" + gateContentNS = "http://schemas.openxmlformats.org/package/2006/content-types" + gateCTXML = `` + gateSlide = `` + gateLayout = `` + gateMaster = `` + gateTheme = `` +) + +func relXML(rels ...string) string { + return `` + + strings.Join(rels, "") + `` +} + +func gateOfficePackage(t *testing.T, rels, contentTypes, mainPart string) []byte { + t.Helper() + files := map[string]string{ + "_rels/.rels": rels, + "[Content_Types].xml": contentTypes, + } + if mainPart != "" { + files[strings.TrimPrefix(mainPart, "/")] = `` + } + return zipOf(t, files) +} + +func gateOfficeRelXML(relType, target, targetMode string) string { + mode := "" + if targetMode != "" { + mode = ` TargetMode="` + targetMode + `"` + } + return relXML(``) +} + +func gateOverrideCT(part, contentType string) string { + return `` +} + +const ( + relSlide = `` + relMaster = `` + relLayout = `` + relToMaster = `` + relTheme = `` +) + +// gateCT 内容类型表:传入要 Override 的结构部件(缺了的用例自己构造残缺形态)。 +func gateCT(parts ...string) string { + cts := map[string]string{ + "ppt/presentation.xml": ctPresentation, + "ppt/slides/slide1.xml": ctSlide, + "ppt/slides/slide2.xml": ctSlide, + "ppt/slides/slide3.xml": ctSlide, + "ppt/slideLayouts/slideLayout1.xml": ctSlideLayout, + "ppt/slideMasters/slideMaster1.xml": ctSlideMaster, + "ppt/theme/theme1.xml": ctTheme, + } + out := `` + for _, p := range parts { + out += `` + } + return out + `` +} + +var gateAllParts = []string{ + "ppt/presentation.xml", "ppt/slides/slide1.xml", + "ppt/slideLayouts/slideLayout1.xml", "ppt/slideMasters/slideMaster1.xml", "ppt/theme/theme1.xml", +} + +// gateValidPptx 是结构合法的最小演示稿:r:id 引用、ID 清单与关系表一致、 +// 内容类型全量覆盖——与 python-pptx 产物的关键形态一致。 +func gateValidPptx(t *testing.T) []byte { + t.Helper() + return zipOf(t, map[string]string{ + "[Content_Types].xml": gateCT(gateAllParts...), + "_rels/.rels": relXML(), + "ppt/presentation.xml": ``, + "ppt/_rels/presentation.xml.rels": relXML(relSlide, relMaster), + "ppt/slides/slide1.xml": gateSlide, + "ppt/slides/_rels/slide1.xml.rels": relXML(relLayout), + "ppt/slideLayouts/slideLayout1.xml": gateLayout, + "ppt/slideLayouts/_rels/slideLayout1.xml.rels": relXML(relToMaster), + "ppt/slideMasters/slideMaster1.xml": gateMaster, + "ppt/slideMasters/_rels/slideMaster1.xml.rels": relXML(relLayout, relTheme), + "ppt/theme/theme1.xml": gateTheme, + }) +} + +// gateIncident2Pptx 复刻第二起事故(部件齐全但引用断裂,上版门禁放行的形态): +// presentation/母版根元素漏 xmlns:r;sldIdLst 声明 3 页但关系表只有 rId3→slide1; +// [Content_Types] 缺 slide2/3 的 Override。三个 fix 开关逐项修复以隔离断言。 +func gateIncident2Pptx(t *testing.T, fixNS, fixRels, fixCT bool) []byte { + t.Helper() + nsDecl := ` xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"` + pns, mns := "", "" + if fixNS { + pns, mns = nsDecl, nsDecl + } + pres := `` + master := `` + rels := `` + if fixRels { + rels = relXML( + ``, + ``, + ``, + ``) + } + ctParts := []string{"ppt/presentation.xml", "ppt/slideMasters/slideMaster1.xml", + "ppt/slideLayouts/slideLayout1.xml", "ppt/theme/theme1.xml", "ppt/slides/slide1.xml"} + if fixCT { + ctParts = append(ctParts, "ppt/slides/slide2.xml", "ppt/slides/slide3.xml") + } + return zipOf(t, map[string]string{ + "[Content_Types].xml": gateCT(ctParts...), + "_rels/.rels": relXML(), + "ppt/presentation.xml": pres, + "ppt/_rels/presentation.xml.rels": rels, + "ppt/slides/slide1.xml": gateSlide, + "ppt/slides/slide2.xml": gateSlide, + "ppt/slides/slide3.xml": gateSlide, + "ppt/slides/_rels/slide1.xml.rels": relXML(relLayout), + "ppt/slides/_rels/slide2.xml.rels": relXML(relLayout), + "ppt/slides/_rels/slide3.xml.rels": relXML(relLayout), + "ppt/slideLayouts/slideLayout1.xml": gateLayout, + "ppt/slideLayouts/_rels/slideLayout1.xml.rels": relXML(relToMaster), + "ppt/slideMasters/slideMaster1.xml": master, + "ppt/slideMasters/_rels/slideMaster1.xml.rels": relXML(relLayout, relTheme), + "ppt/theme/theme1.xml": gateTheme, + }) +} + +func TestOoxmlGateValidPptx(t *testing.T) { + if err := ooxmlStructuralCheck("交付物.pptx", gateValidPptx(t)); err != nil { + t.Fatalf("valid pptx must pass: %v", err) + } +} + +func TestOoxmlGateIncident1PartsMissing(t *testing.T) { + // 第一类事故:部件整体缺失(无母版/主题/docProps、slide 无关系表)。 + broken := zipOf(t, map[string]string{ + "[Content_Types].xml": gateCTXML, + "_rels/.rels": relXML(), + "ppt/presentation.xml": ``, + "ppt/_rels/presentation.xml.rels": relXML(relSlide), + "ppt/slides/slide1.xml": gateSlide, + "ppt/slideLayouts/slideLayout1.xml": gateLayout, + }) + err := ooxmlStructuralCheck("事故一.pptx", broken) + if err == nil { + t.Fatal("incident-shaped pptx must be rejected") + } + for _, want := range []string{"母版", "sldMasterIdLst"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("error %q must mention %q", err, want) + } + } +} + +// 第二类事故:部件齐全但引用断裂——上一版门禁放行、Office 拒开的形态。 +func TestOoxmlGateIncident2ReferenceBreaks(t *testing.T) { + cases := []struct { + name string + fixNS, fixRels, fixCT bool + want string + }{ + {"undeclared-ns", false, true, true, "未声明的命名空间前缀 xmlns:r"}, + {"sldid-dangling-rid", true, false, true, "sldIdLst 引用的 slide 在关系表中不存在"}, + {"ct-missing-slides", true, true, false, "Content_Types"}, + } + for _, tc := range cases { + err := ooxmlStructuralCheck("事故二.pptx", gateIncident2Pptx(t, tc.fixNS, tc.fixRels, tc.fixCT)) + if err == nil { + t.Fatalf("%s: must be rejected", tc.name) + } + if !strings.Contains(err.Error(), tc.want) { + t.Fatalf("%s: error %q must mention %q", tc.name, err, tc.want) + } + } + // 三处缺陷全部修复后必须放行(对应 P4 修复产物的形态)。 + if err := ooxmlStructuralCheck("修复后.pptx", gateIncident2Pptx(t, true, true, true)); err != nil { + t.Fatalf("fully repaired incident-2 package must pass: %v", err) + } +} + +func TestOoxmlGatePptxChainBreaks(t *testing.T) { + base := func(t *testing.T) map[string]string { + t.Helper() + valid := gateValidPptx(t) + zr, err := zip.NewReader(bytes.NewReader(valid), int64(len(valid))) + if err != nil { + t.Fatal(err) + } + m := map[string]string{} + for _, f := range zr.File { + b, err := readZipFile(f) + if err != nil { + t.Fatal(err) + } + m[f.Name] = string(b) + } + return m + } + cases := []struct { + name string + mut func(m map[string]string) + want string + }{ + {"slide-no-rels", func(m map[string]string) { + delete(m, "ppt/slides/_rels/slide1.xml.rels") + }, "关系表缺失"}, + {"slide-no-layout-rel", func(m map[string]string) { + m["ppt/slides/_rels/slide1.xml.rels"] = relXML() + }, "没有指向 slideLayout"}, + {"layout-missing", func(m map[string]string) { + delete(m, "ppt/slideLayouts/slideLayout1.xml") + }, "缺少必备部件"}, + {"layout-no-master-rel", func(m map[string]string) { + m["ppt/slideLayouts/_rels/slideLayout1.xml.rels"] = relXML() + }, "没有指向 slideMaster"}, + {"layout-master-not-listed", func(m map[string]string) { + m["ppt/slideLayouts/_rels/slideLayout1.xml.rels"] = relXML(``) + m["ppt/slideMasters/slideMaster2.xml"] = gateMaster + m["ppt/slideMasters/_rels/slideMaster2.xml.rels"] = relXML(relLayout, relTheme) + }, "未在 presentation 的 sldMasterIdLst 中声明"}, + {"master-no-theme-rel", func(m map[string]string) { + m["ppt/slideMasters/_rels/slideMaster1.xml.rels"] = relXML(relLayout) + }, "没有指向 theme"}, + {"theme-missing", func(m map[string]string) { + delete(m, "ppt/theme/theme1.xml") + }, "缺少必备部件"}, + {"master-no-layout-idlst", func(m map[string]string) { + m["ppt/slideMasters/slideMaster1.xml"] = strings.Replace(gateMaster, ``, "", 1) + }, "缺少 "}, + {"pres-no-master-idlst", func(m map[string]string) { + m["ppt/presentation.xml"] = strings.Replace( + m["ppt/presentation.xml"], + ``, "", 1) + }, "sldMasterIdLst"}, + {"pres-slide-rel-dangling", func(m map[string]string) { + m["ppt/_rels/presentation.xml.rels"] = relXML(relMaster) + }, "sldIdLst 引用的 slide 在关系表中不存在"}, + {"pres-extra-slide-rel", func(m map[string]string) { + m["ppt/_rels/presentation.xml.rels"] = relXML(relSlide, relMaster, + ``) + m["ppt/slides/slide2.xml"] = gateSlide + m["ppt/slides/_rels/slide2.xml.rels"] = relXML(relLayout) + }, "数量不一致"}, + {"xml-malformed", func(m map[string]string) { + m["ppt/slides/slide1.xml"] = `` + }, "不是合法 XML"}, + {"ct-slide-mismatch", func(m map[string]string) { + m["[Content_Types].xml"] = strings.Replace(gateCT(gateAllParts...), ctSlide, "application/xml", 1) + }, "内容类型不符"}, + } + for _, tc := range cases { + m := base(t) + tc.mut(m) + err := ooxmlStructuralCheck("deck.pptx", zipOf(t, m)) + if err == nil { + t.Fatalf("%s: must be rejected", tc.name) + } + if !strings.Contains(err.Error(), tc.want) { + t.Fatalf("%s: error %q must mention %q", tc.name, err, tc.want) + } + } +} + +func readZipFile(f *zip.File) ([]byte, error) { + rc, err := f.Open() + if err != nil { + return nil, err + } + defer rc.Close() + var buf bytes.Buffer + if _, err := buf.ReadFrom(rc); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func TestOoxmlGateDocxXlsxAndPassthrough(t *testing.T) { + validDocx := zipOf(t, map[string]string{ + "[Content_Types].xml": gateCTXML, + "word/document.xml": ``, + }) + if err := ooxmlStructuralCheck("方案.docx", validDocx); err != nil { + t.Fatalf("valid docx must pass: %v", err) + } + if err := ooxmlStructuralCheck("方案.docx", zipOf(t, map[string]string{"[Content_Types].xml": gateCTXML})); err == nil { + t.Fatal("docx without document.xml must be rejected") + } + // 命名空间严格化同样覆盖 docx:正文用了未声明前缀要拦。 + badDocx := zipOf(t, map[string]string{ + "[Content_Types].xml": gateCTXML, + "word/document.xml": ``, + }) + err := ooxmlStructuralCheck("方案.docx", badDocx) + if err == nil || !strings.Contains(err.Error(), "未声明") { + t.Fatalf("docx with undeclared prefix must be rejected: %v", err) + } + validXlsx := zipOf(t, map[string]string{ + "[Content_Types].xml": gateCTXML, + "xl/workbook.xml": ``, + }) + if err := ooxmlStructuralCheck("数据.xlsx", validXlsx); err != nil { + t.Fatalf("valid xlsx must pass: %v", err) + } + if err := ooxmlStructuralCheck("备注.md", []byte("# 标题\n正文")); err != nil { + t.Fatalf("non-office files must pass through: %v", err) + } + if err := ooxmlStructuralCheck("坏.pptx", []byte("这是一段文本,不是 zip")); err == nil { + t.Fatal("text masquerading as pptx must be rejected") + } +} + +// 登记口集成:残缺包在 register_artifact 处被拒并给出重建指引;合法包照常登记。 +func TestRegisterArtifactStructuralGate(t *testing.T) { + r := NewRegistry() + if err := RegisterExecTools(r); err != nil { + t.Fatal(err) + } + root := t.TempDir() + rc := &execFake{root: root, owner: "o1", conv: "c1", execOn: true} + sandbox := filepath.Join(root, "o1", "c1") + if err := os.MkdirAll(sandbox, 0o755); err != nil { + t.Fatal(err) + } + broken := filepath.Join(sandbox, "坏.pptx") + if err := os.WriteFile(broken, gateIncident2Pptx(t, false, false, false), 0o644); err != nil { + t.Fatal(err) + } + res := interpCall(t, r, rc, "register_artifact", `{"path":"坏.pptx"}`) + if res.Status != "error" || !strings.Contains(res.Output, "结构自检未通过") || !strings.Contains(res.Output, "housestyle") { + t.Fatalf("broken pptx must be refused with rebuild hint: %s %s", res.Status, res.Output) + } + good := filepath.Join(sandbox, "好.pptx") + if err := os.WriteFile(good, gateValidPptx(t), 0o644); err != nil { + t.Fatal(err) + } + res = interpCall(t, r, rc, "register_artifact", `{"path":"好.pptx"}`) + if res.Status != "ok" || !strings.Contains(res.Output, "已登记交付物") { + t.Fatalf("valid pptx must register: %s %s", res.Status, res.Output) + } +} + +func TestRegisterArtifactRejectsRenamedWordprocessingMLPackages(t *testing.T) { + r := NewRegistry() + if err := RegisterExecTools(r); err != nil { + t.Fatal(err) + } + root := t.TempDir() + rc := &execFake{root: root, owner: "o1", conv: "c1", execOn: true} + sandbox := filepath.Join(root, "o1", "c1") + if err := os.MkdirAll(sandbox, 0o755); err != nil { + t.Fatal(err) + } + + wordTypes := []struct { + name string + mainPart string + relType string + contentType string + }{ + {"report.zip", "custom/main.xml", gateOfficeRel, "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"}, + {"macro.bin", "word/document.xml", gateOfficeRel, "application/vnd.ms-word.document.macroEnabled.main+xml"}, + {"template.data", "templates/body.xml", gateStrictOffice, "application/vnd.openxmlformats-officedocument.wordprocessingml.template.main+xml"}, + {"macro-template.payload", "package/document.xml", gateOfficeRel, "application/vnd.ms-word.template.macroEnabledTemplate.main+xml"}, + } + for _, test := range wordTypes { + t.Run(test.name, func(t *testing.T) { + pkg := gateOfficePackage(t, + gateOfficeRelXML(test.relType, test.mainPart, ""), + gateOverrideCT(test.mainPart, test.contentType), + test.mainPart, + ) + if err := os.WriteFile(filepath.Join(sandbox, test.name), pkg, 0o644); err != nil { + t.Fatal(err) + } + res := interpCall(t, r, rc, "register_artifact", `{"path":`+mustJSON(t, test.name)+`}`) + if res.Status != "error" || !strings.Contains(res.Output, "direct Word registration is forbidden") { + t.Fatalf("renamed WordprocessingML package must be refused: %s %s", res.Status, res.Output) + } + }) + } + + defaultTyped := gateOfficePackage(t, + gateOfficeRelXML(gateOfficeRel, "alternate/main.wml", ""), + ``, + "alternate/main.wml", + ) + if err := os.WriteFile(filepath.Join(sandbox, "default-typed.pkg"), defaultTyped, 0o644); err != nil { + t.Fatal(err) + } + res := interpCall(t, r, rc, "register_artifact", `{"path":"default-typed.pkg"}`) + if res.Status != "error" || !strings.Contains(res.Output, "direct Word registration is forbidden") { + t.Fatalf("default-typed WordprocessingML package must be refused: %s %s", res.Status, res.Output) + } + + fakeWordPart := zipOf(t, map[string]string{ + "word/document.xml": ``, + "data/payload.json": `{"ok":true}`, + }) + if err := os.WriteFile(filepath.Join(sandbox, "same-name.zip"), fakeWordPart, 0o644); err != nil { + t.Fatal(err) + } + res = interpCall(t, r, rc, "register_artifact", `{"path":"same-name.zip"}`) + if res.Status != "ok" || !strings.Contains(res.Output, "已登记交付物") { + t.Fatalf("ordinary ZIP with a same-name part must register: %s %s", res.Status, res.Output) + } + + spreadsheet := gateOfficePackage(t, + gateOfficeRelXML(gateOfficeRel, "xl/workbook.xml", ""), + gateOverrideCT("xl/workbook.xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"), + "xl/workbook.xml", + ) + + allowed := map[string][]byte{ + "dataset.zip": zipOf(t, map[string]string{"data/payload.json": `{"ok":true}`}), + "spreadsheet.pkg": spreadsheet, + "payload.bin": {0x00, 0x01, 0x02, 0xff}, + } + for name, content := range allowed { + if err := os.WriteFile(filepath.Join(sandbox, name), content, 0o644); err != nil { + t.Fatal(err) + } + res := interpCall(t, r, rc, "register_artifact", `{"path":`+mustJSON(t, name)+`}`) + if res.Status != "ok" || !strings.Contains(res.Output, "已登记交付物") { + t.Fatalf("non-Word artifact %s must register: %s %s", name, res.Status, res.Output) + } + } +} + +func TestRegisterArtifactRejectsMalformedAmbiguousOrUnsafeOfficeDocumentMetadata(t *testing.T) { + r := NewRegistry() + if err := RegisterExecTools(r); err != nil { + t.Fatal(err) + } + root := t.TempDir() + rc := &execFake{root: root, owner: "o1", conv: "c1", execOn: true} + sandbox := filepath.Join(root, "o1", "c1") + if err := os.MkdirAll(sandbox, 0o755); err != nil { + t.Fatal(err) + } + + const wordType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml" + validCT := gateOverrideCT("custom/main.xml", wordType) + validRel := gateOfficeRelXML(gateOfficeRel, "custom/main.xml", "") + tests := []struct { + name string + rels string + contentTypes string + }{ + { + name: "malformed-relationships.pkg", + rels: ``, + contentTypes: validCT, + }, + { + name: "duplicate-office-relationships.pkg", + rels: relXML( + ``, + ``, + ), + contentTypes: validCT, + }, + { + name: "malformed-content-types.pkg", + rels: validRel, + contentTypes: ``, + }, + { + name: "duplicate-content-type.pkg", + rels: validRel, + contentTypes: `` + + `` + + ``, + }, + { + name: "missing-main-content-type.pkg", + rels: validRel, + contentTypes: ``, + }, + { + name: "traversal-target.pkg", + rels: gateOfficeRelXML(gateOfficeRel, "../custom/main.xml", ""), + contentTypes: validCT, + }, + { + name: "external-target.pkg", + rels: gateOfficeRelXML(gateOfficeRel, "https://example.invalid/main.xml", "External"), + contentTypes: validCT, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + pkg := gateOfficePackage(t, test.rels, test.contentTypes, "custom/main.xml") + if err := os.WriteFile(filepath.Join(sandbox, test.name), pkg, 0o644); err != nil { + t.Fatal(err) + } + res := interpCall(t, r, rc, "register_artifact", `{"path":`+mustJSON(t, test.name)+`}`) + if res.Status != "error" || !strings.Contains(res.Output, "direct Word registration is forbidden") { + t.Fatalf("unsafe officeDocument metadata must fail closed: %s %s", res.Status, res.Output) + } + }) + } +} + +func mustJSON(t *testing.T, value string) string { + t.Helper() + raw, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return string(raw) +} diff --git a/internal/tools/registry.go b/internal/tools/registry.go index c5900b2..847dd3e 100644 --- a/internal/tools/registry.go +++ b/internal/tools/registry.go @@ -13,6 +13,9 @@ import ( core "agent-system-core" ) +// KnowledgeSearchNoHits 是 knowledge_search 项目资料无命中时的输出。 +const KnowledgeSearchNoHits = "(无命中)" + // Executor 是无状态执行器:入参已校验,返回输出文本或错误。 type Executor func(rc ToolExecCtx, args json.RawMessage) (string, error) @@ -20,6 +23,8 @@ type Executor func(rc ToolExecCtx, args json.RawMessage) (string, error) // PeerConversations/PeerConversationMessages 的门禁(项目开关 crossSession.read、 // 出站 allowCrossRead、入站 crossPrivate、租户/项目归属)在实现方 runmgr 内执行, // fail-closed;本包只做参数校验与渲染(提案 2026-09-30-cross-session-reading)。 +// 运行时还可实现 Context() 与 CheckExternalEffect():外部请求/进程/文件写入 +// 使用前者取消在途操作,并在每个副作用紧前调用后者复核有效账号与资源权限。 type ToolExecCtx interface { ProjectID() string ConversationID() string @@ -119,7 +124,7 @@ func (r *Registry) RegisterBuiltin() error { return err } if err := r.Register(core.ToolDesc{ - Name: "knowledge_search", Description: "在项目知识与插件知识库中做全文检索,返回命中片段", Source: core.SourceBuiltin, ReadOnly: true, + Name: "knowledge_search", Description: "在项目资料(项目知识与插件知识库)中做全文检索,返回命中片段;法规、标准、标准号(GB/YY/YY/T/IEC 等)或指导原则类问题优先用 regulations_search", Source: core.SourceBuiltin, ReadOnly: true, Parameters: json.RawMessage(`{"type":"object","properties":{"query":{"type":"string","description":"检索词"}},"required":["query"]}`), }, func(rc ToolExecCtx, args json.RawMessage) (string, error) { var a struct { @@ -139,7 +144,7 @@ func (r *Registry) RegisterBuiltin() error { return "", err } if len(hits) == 0 { - return "(无命中)", nil + return KnowledgeSearchNoHits, nil } var sb strings.Builder for i, h := range hits { @@ -183,6 +188,9 @@ func (r *Registry) RegisterBuiltin() error { }); err != nil { return err } + if err := registerDocumentRenderer(r); err != nil { + return err + } if err := registerQualityReview(r); err != nil { return err } diff --git a/internal/tools/search.go b/internal/tools/search.go index 1315acc..9ed030c 100644 --- a/internal/tools/search.go +++ b/internal/tools/search.go @@ -4,7 +4,6 @@ package tools import ( - "context" "encoding/json" "fmt" "strings" @@ -39,7 +38,7 @@ func registerSearch(r *Registry, providerFor func(ToolExecCtx) agenttools.Search if provider == nil { return "", fmt.Errorf("搜索未配置:settings.tools.tavily.apiKeys(或对应 secret)为空;配置后立即生效,无需重启") } - return execSearch(provider, args) + return execSearch(rc, provider, args) } if err := r.Register(core.ToolDesc{ Name: "tavily_search", @@ -69,7 +68,7 @@ func registerSearch(r *Registry, providerFor func(ToolExecCtx) agenttools.Search }, tavily) } -func execSearch(provider agenttools.SearchProvider, args json.RawMessage) (string, error) { +func execSearch(rc ToolExecCtx, provider agenttools.SearchProvider, args json.RawMessage) (string, error) { var params struct { Query string `json:"query"` SearchDepth string `json:"search_depth"` @@ -82,7 +81,10 @@ func execSearch(provider agenttools.SearchProvider, args json.RawMessage) (strin if query == "" { return "", fmt.Errorf("Error: query cannot be empty.") } - resp, err := provider.Search(context.Background(), agenttools.SearchRequest{ + if err := checkToolExternalEffect(rc); err != nil { + return "", err + } + resp, err := provider.Search(execParentCtx(rc), agenttools.SearchRequest{ Query: query, Depth: params.SearchDepth, MaxResults: int(params.MaxResults), diff --git a/internal/vision/fetch.go b/internal/vision/fetch.go new file mode 100644 index 0000000..085afac --- /dev/null +++ b/internal/vision/fetch.go @@ -0,0 +1,193 @@ +package vision + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "time" + + agentllm "agent-native/runtime/llm" +) + +var ( + errPrivateAddress = errors.New("地址不在允许范围") + errNotImage = errors.New("响应不是受支持的图片") + errTooLarge = errors.New("超过单图大小上限") +) + +// FetchImage 下载一张公网图片。拒绝回环、私网、链路本地和云元数据地址, +// 并把连接钉在解析到的公网 IP 上,避免 DNS 重绑定。不走环境变量里的代理。 +func FetchImage(ctx context.Context, rawURL string) (agentllm.Image, error) { + if err := validateImageURL(ctx, rawURL); err != nil { + return agentllm.Image{}, err + } + client := &http.Client{ + Timeout: 20 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 3 { + return errors.New("重定向过多") + } + if err := validateImageURL(req.Context(), req.URL.String()); err != nil { + return err + } + return nil + }, + Transport: &http.Transport{ + Proxy: nil, + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, err + } + ip, err := pickPublicIP(ctx, host) + if err != nil { + return nil, err + } + var d net.Dialer + d.Timeout = 10 * time.Second + return d.DialContext(ctx, network, net.JoinHostPort(ip.String(), port)) + }, + ResponseHeaderTimeout: 10 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + }, + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return agentllm.Image{}, err + } + req.Header.Set("User-Agent", "agent-system-vision/1.0") + resp, err := client.Do(req) + if err != nil { + if errors.Is(err, errPrivateAddress) { + return agentllm.Image{}, errPrivateAddress + } + return agentllm.Image{}, fmt.Errorf("下载失败") + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return agentllm.Image{}, fmt.Errorf("下载失败") + } + body, err := io.ReadAll(io.LimitReader(resp.Body, MaxImageBytes+1)) + if err != nil { + return agentllm.Image{}, fmt.Errorf("下载失败") + } + if len(body) > MaxImageBytes { + return agentllm.Image{}, errTooLarge + } + kind := MIMEOf(body) + if kind == "" || !contentTypeOK(resp.Header.Get("Content-Type"), kind) { + return agentllm.Image{}, errNotImage + } + // 只回传字节。URL 留给网关自己去拉会绕过这里的地址校验。 + return agentllm.Image{ + MIME: kind, + Data: base64.StdEncoding.EncodeToString(body), + }, nil +} + +func publicReason(err error) string { + switch { + case err == nil: + return "" + case errors.Is(err, errPrivateAddress): + return errPrivateAddress.Error() + case errors.Is(err, errNotImage): + return errNotImage.Error() + case errors.Is(err, errTooLarge): + return errTooLarge.Error() + default: + return "下载失败" + } +} + +func validateImageURL(ctx context.Context, raw string) error { + u, err := url.Parse(raw) + if err != nil || u.Host == "" { + return errPrivateAddress + } + if u.User != nil { + return errPrivateAddress + } + switch strings.ToLower(u.Scheme) { + case "http", "https": + default: + return errPrivateAddress + } + if _, err := pickPublicIP(ctx, u.Hostname()); err != nil { + return errPrivateAddress + } + return nil +} + +func pickPublicIP(ctx context.Context, host string) (net.IP, error) { + if ip := net.ParseIP(host); ip != nil { + if err := checkIP(ip); err != nil { + return nil, err + } + return ip, nil + } + if host == "" || strings.EqualFold(host, "localhost") { + return nil, errPrivateAddress + } + addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil || len(addrs) == 0 { + return nil, errPrivateAddress + } + var first net.IP + for _, addr := range addrs { + if err := checkIP(addr.IP); err != nil { + return nil, err + } + if first == nil { + first = addr.IP + } + } + if first == nil { + return nil, errPrivateAddress + } + return first, nil +} + +var ( + cgnat = mustCIDR("100.64.0.0/10") +) + +func mustCIDR(s string) *net.IPNet { + _, n, err := net.ParseCIDR(s) + if err != nil { + panic(err) + } + return n +} + +func checkIP(ip net.IP) error { + if ip == nil { + return errPrivateAddress + } + if v4 := ip.To4(); v4 != nil { + ip = v4 + } + switch { + case ip.IsLoopback(), ip.IsPrivate(), ip.IsLinkLocalUnicast(), ip.IsLinkLocalMulticast(), + ip.IsMulticast(), ip.IsUnspecified(), cgnat.Contains(ip): + return errPrivateAddress + default: + return nil + } +} + +func contentTypeOK(header, sniffed string) bool { + ct := strings.ToLower(strings.TrimSpace(strings.Split(header, ";")[0])) + switch ct { + case "", "application/octet-stream", "binary/octet-stream": + return sniffed != "" + default: + return ct == sniffed + } +} diff --git a/internal/vision/images.go b/internal/vision/images.go new file mode 100644 index 0000000..dba0ed6 --- /dev/null +++ b/internal/vision/images.go @@ -0,0 +1,136 @@ +package vision + +import ( + "encoding/base64" + "fmt" + "path/filepath" + "strings" + + agentllm "agent-native/runtime/llm" + core "agent-system-core" +) + +// PrepareImage 把上传的图片收成一条 image-ref。Ref 是标准 base64(无换行)。 +// 不支持的类型和超限图片返回警告且 ok 为 false,调用方必须把警告展示出来。 +func PrepareImage(filename, mime string, content []byte) (core.DocBlock, string, bool) { + ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(filename), ".")) + kind, known := sendableExt[ext] + if !known { + return core.DocBlock{}, fmt.Sprintf("图片 .%s 不作为图像发送(请转为 png、jpeg、gif 或 webp)", ext), false + } + if len(content) == 0 { + return core.DocBlock{}, "图片内容为空,本轮不发送图像", false + } + if len(content) > MaxImageBytes { + return core.DocBlock{}, fmt.Sprintf("图片超过 %dMB,本轮不发送图像(请压缩后重传)", MaxImageBytes>>20), false + } + detected := sniffImage(content) + if detected == "" || detected != kind { + return core.DocBlock{}, "图片内容与扩展名不符,本轮不发送图像", false + } + if mime != "" && !strings.EqualFold(strings.TrimSpace(strings.Split(mime, ";")[0]), kind) && !strings.EqualFold(mime, "application/octet-stream") { + return core.DocBlock{}, "图片类型与内容不符,本轮不发送图像", false + } + return core.DocBlock{ + Type: core.BlockImageRef, + Ref: base64.StdEncoding.EncodeToString(content), + Text: filename, + }, "图片按图像发送给模型,不转成文字", true +} + +// ImagesFromDoc 取出文档里已编码的图片。MIME 来自文件名,不信任外部声明。 +func ImagesFromDoc(doc core.ParsedDocument) []agentllm.Image { + ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(doc.Meta.Filename), ".")) + kind := sendableExt[ext] + if kind == "" { + return nil + } + var out []agentllm.Image + for _, b := range doc.Blocks { + if b.Type != core.BlockImageRef || strings.TrimSpace(b.Ref) == "" { + continue + } + out = append(out, agentllm.Image{MIME: kind, Data: b.Ref}) + } + return out +} + +// HasVisualInput 判断这一轮是否带图:已解析的图片附件,或正文里的图片链接。 +func HasVisualInput(docs []core.ParsedDocument, text string) bool { + for _, doc := range docs { + if len(ImagesFromDoc(doc)) > 0 { + return true + } + } + return len(ImageLinks(text)) > 0 +} + +// Admit 按张数和总字节数收下一批图片。被拒绝的每一项都有一句说明。 +func Admit(existing []agentllm.Image, incoming []agentllm.Image) (kept []agentllm.Image, notes []string) { + count, total := usage(existing) + for _, img := range incoming { + n := decodedLen(img.Data) + switch { + case n <= 0: + notes = append(notes, "一张图片没有可用内容,已跳过") + case n > MaxImageBytes: + notes = append(notes, fmt.Sprintf("一张图片超过 %dMB,已跳过", MaxImageBytes>>20)) + case count >= MaxImages: + notes = append(notes, fmt.Sprintf("本轮图片超过 %d 张,其余未发送", MaxImages)) + return kept, notes + case total+n > MaxTotalBytes: + notes = append(notes, fmt.Sprintf("本轮图片合计超过 %dMB,其余未发送", MaxTotalBytes>>20)) + return kept, notes + default: + kept = append(kept, img) + count++ + total += n + } + } + return kept, notes +} + +func usage(images []agentllm.Image) (count, total int) { + for _, img := range images { + n := decodedLen(img.Data) + if n > 0 { + count++ + total += n + } + } + return count, total +} + +func decodedLen(data string) int { + raw, err := base64.StdEncoding.DecodeString(data) + if err != nil { + return 0 + } + return len(raw) +} + +var sendableExt = map[string]string{ + "png": "image/png", + "jpg": "image/jpeg", + "jpeg": "image/jpeg", + "gif": "image/gif", + "webp": "image/webp", +} + +func sniffImage(b []byte) string { + switch { + case len(b) >= 8 && string(b[:8]) == "\x89PNG\r\n\x1a\n": + return "image/png" + case len(b) >= 3 && b[0] == 0xFF && b[1] == 0xD8 && b[2] == 0xFF: + return "image/jpeg" + case len(b) >= 6 && (string(b[:6]) == "GIF87a" || string(b[:6]) == "GIF89a"): + return "image/gif" + case len(b) >= 12 && string(b[:4]) == "RIFF" && string(b[8:12]) == "WEBP": + return "image/webp" + default: + return "" + } +} + +// MIMEOf 返回嗅探结果,供下载后的响应校验。 +func MIMEOf(content []byte) string { return sniffImage(content) } diff --git a/internal/vision/links.go b/internal/vision/links.go new file mode 100644 index 0000000..72f2c71 --- /dev/null +++ b/internal/vision/links.go @@ -0,0 +1,90 @@ +package vision + +import ( + "context" + "fmt" + "regexp" + "strings" + + agentllm "agent-native/runtime/llm" +) + +// 只认路径以图片扩展名结尾的 http(s) 链接。不根据任意 URL 去探测, +// 避免把用户消息里的普通网页都拿去下载。 +var imageURLRe = regexp.MustCompile(`(?i)https?://[^\s<>"',。、;:]+\.(?:png|jpe?g|gif|webp)(?:\?[^\s<>"',。、;:]*)?`) + +// ImageLinks 按出现顺序返回去重后的图片链接。 +func ImageLinks(text string) []string { + seen := map[string]bool{} + var out []string + for _, raw := range imageURLRe.FindAllString(text, -1) { + u := strings.TrimRight(raw, ".,;:)]>\"'") + if u == "" || seen[u] { + continue + } + seen[u] = true + out = append(out, u) + } + return out +} + +// AttachLinkedImages 下载最后一条用户消息里的图片链接,并写进该消息的 Images。 +// 下载失败、超限或地址被拒绝时,把原因追加到正文,不静默丢掉。 +func AttachLinkedImages(ctx context.Context, msgs []agentllm.Message) []string { + idx := -1 + for i := len(msgs) - 1; i >= 0; i-- { + if msgs[i].Role == agentllm.RoleUser { + idx = i + break + } + } + if idx < 0 { + return nil + } + links := ImageLinks(msgs[idx].Content) + if len(links) == 0 { + return nil + } + var notes []string + var incoming []agentllm.Image + byData := map[string]string{} + for _, link := range links { + img, err := FetchImage(ctx, link) + if err != nil { + notes = append(notes, fmt.Sprintf("%s 未能下载:%s", link, publicReason(err))) + continue + } + incoming = append(incoming, img) + byData[img.Data] = link + } + kept, dropped := Admit(msgs[idx].Images, incoming) + msgs[idx].Images = append(msgs[idx].Images, kept...) + sent := map[string]bool{} + for _, img := range kept { + if link := byData[img.Data]; link != "" { + notes = append(notes, link+" 已随消息发送") + sent[img.Data] = true + } + } + for _, img := range incoming { + if !sent[img.Data] { + if link := byData[img.Data]; link != "" { + notes = append(notes, link+" 未发送") + } + } + } + notes = append(notes, dropped...) + if len(notes) == 0 { + return nil + } + msgs[idx].Content += "\n\n[图片链接]\n" + strings.Join(prefixLines(notes), "\n") + "\n" + return notes +} + +func prefixLines(notes []string) []string { + out := make([]string, len(notes)) + for i, n := range notes { + out[i] = "- " + n + } + return out +} diff --git a/internal/vision/models.go b/internal/vision/models.go new file mode 100644 index 0000000..b0086fc --- /dev/null +++ b/internal/vision/models.go @@ -0,0 +1,18 @@ +// Package vision 负责「这一轮要不要把图发给模型」:能力清单、图片字节预算、 +// 以及用户粘贴的图片链接的受控下载。模型名和端点仍由 runtime 的路由解析。 +package vision + +// 默认可看图清单与兜底模型。网关路由变化时改设置 models.vision.capable, +// 不必改代码。空设置回落到这里。 +const ( + DefaultVisionModel = "qwen3.8-flash" + DefaultVisionCapable = "deepseek-v4.1-flash,qwen3.8-flash,ZHIPU/GLM-5.3-FlashX,ZHIPU/GLM-5.3-Flash" +) + +// 单图与单轮上限。按网关实测的小图请求体留出余量:base64 约膨胀 4/3, +// 8MB 原图约 11MB 请求体,单轮合计 20MB 原图。 +const ( + MaxImageBytes = 8 << 20 + MaxImages = 8 + MaxTotalBytes = 20 << 20 +) diff --git a/internal/vision/vision_test.go b/internal/vision/vision_test.go new file mode 100644 index 0000000..91abee4 --- /dev/null +++ b/internal/vision/vision_test.go @@ -0,0 +1,120 @@ +package vision + +import ( + "context" + "encoding/base64" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + + agentllm "agent-native/runtime/llm" + core "agent-system-core" +) + +func TestCheckIP(t *testing.T) { + blocked := []string{"127.0.0.1", "10.1.2.3", "192.168.1.1", "172.16.0.1", "169.254.169.254", "100.64.0.1", "::1", "fc00::1", "0.0.0.0"} + for _, s := range blocked { + if err := checkIP(net.ParseIP(s)); err == nil { + t.Errorf("%s should be blocked", s) + } + } + if err := checkIP(net.ParseIP("8.8.8.8")); err != nil { + t.Fatal(err) + } + if err := checkIP(net.ParseIP("1.1.1.1")); err != nil { + t.Fatal(err) + } +} + +func TestValidateImageURLRejectsUnsafeTargets(t *testing.T) { + ctx := context.Background() + for _, raw := range []string{ + "http://127.0.0.1/a.png", + "http://169.254.169.254/latest/a.png", + "http://10.0.0.8/a.jpg", + "http://user:pass@8.8.8.8/a.png", + "file:///tmp/a.png", + "http://localhost/a.png", + } { + if err := validateImageURL(ctx, raw); err == nil { + t.Errorf("accepted %s", raw) + } + } + if err := validateImageURL(ctx, "https://8.8.8.8/a.png"); err != nil { + t.Fatal(err) + } +} + +func TestFetchImageRejectsLoopbackServer(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(onePixelPNG) + })) + defer srv.Close() + _, err := FetchImage(context.Background(), srv.URL+"/pixel.png") + if err == nil || !strings.Contains(err.Error(), "允许范围") { + t.Fatalf("err = %v", err) + } +} + +func TestImageLinksAndAttachNote(t *testing.T) { + text := "看这张 http://127.0.0.1/a.png。还有 https://cdn.example.com/b.JPG?x=1 和重复 http://127.0.0.1/a.png" + links := ImageLinks(text) + if len(links) != 2 || links[0] != "http://127.0.0.1/a.png" || !strings.Contains(links[1], "b.JPG") { + t.Fatalf("%#v", links) + } + msgs := []agentllm.Message{{Role: agentllm.RoleUser, Content: "请看 http://127.0.0.1/secret.png"}} + notes := AttachLinkedImages(context.Background(), msgs) + if len(notes) == 0 || len(msgs[0].Images) != 0 { + t.Fatalf("notes=%v images=%d", notes, len(msgs[0].Images)) + } + if !strings.Contains(msgs[0].Content, "地址不在允许范围") { + t.Fatalf("content missing reason: %s", msgs[0].Content) + } +} + +func TestPrepareImageAndBudget(t *testing.T) { + block, warn, ok := PrepareImage("pixel.png", "image/png", onePixelPNG) + if !ok || !strings.Contains(warn, "按图像发送") || block.Type != core.BlockImageRef || block.Ref == "" { + t.Fatalf("ok=%v warn=%q block=%+v", ok, warn, block) + } + if _, warn, ok := PrepareImage("icon.svg", "image/svg+xml", []byte("")); ok || !strings.Contains(warn, "不作为图像发送") { + t.Fatalf("svg ok=%v warn=%q", ok, warn) + } + big := make([]byte, MaxImageBytes+1) + if _, warn, ok := PrepareImage("big.png", "image/png", big); ok || !strings.Contains(warn, "超过") { + t.Fatalf("big ok=%v warn=%q", ok, warn) + } + if _, warn, ok := PrepareImage("bad.png", "image/png", []byte("not a png")); ok || !strings.Contains(warn, "不符") { + t.Fatalf("bad ok=%v warn=%q", ok, warn) + } + + img := agentllm.Image{MIME: "image/png", Data: block.Ref} + kept, notes := Admit(nil, []agentllm.Image{img}) + if len(kept) != 1 || len(notes) != 0 { + t.Fatalf("kept=%d notes=%v", len(kept), notes) + } + many := make([]agentllm.Image, MaxImages+1) + for i := range many { + many[i] = img + } + kept, notes = Admit(nil, many) + if len(kept) != MaxImages || len(notes) == 0 { + t.Fatalf("kept=%d notes=%v", len(kept), notes) + } + raw, err := base64.StdEncoding.DecodeString(block.Ref) + if err != nil || len(raw) != len(onePixelPNG) { + t.Fatal(err) + } +} + +// 1x1 PNG。 +var onePixelPNG = []byte{ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, + 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x50, + 0x0f, 0x00, 0x04, 0x85, 0x01, 0x80, 0x84, 0xa9, 0x8c, 0x21, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, + 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, +} diff --git a/internal/wfcompile/host.go b/internal/wfcompile/host.go index b0ca13a..12a57da 100644 --- a/internal/wfcompile/host.go +++ b/internal/wfcompile/host.go @@ -98,6 +98,17 @@ type hostRunner struct { } func (r *hostRunner) Run(ctx context.Context, step squad.IntentStep) (*squad.ExecResult, error) { + if r.rc.Run().ProjectID != "" { + guard, ok := r.rc.(ProjectStepProvenance) + if !ok { + return &squad.ExecResult{Success: false, ErrorReason: "project workflow source guard unavailable"}, nil + } + wsDir := filepath.Dir(filepath.Dir(step.WorkDir)) + strict := step.IntentID == IntentTool || step.IntentID == IntentArtifact || step.IntentID == IntentScript || step.IntentID == IntentCritic + if err := verifyPriorWorkflowOutputs(ctx, guard, wsDir, r.subOf, r.stepKinds, strict); err != nil { + return &squad.ExecResult{Success: false, ErrorReason: err.Error()}, nil + } + } var content string var fields map[string]string var err error diff --git a/internal/wfcompile/project_backend.go b/internal/wfcompile/project_backend.go index 47e2e1c..08eb01a 100644 --- a/internal/wfcompile/project_backend.go +++ b/internal/wfcompile/project_backend.go @@ -2,6 +2,7 @@ package wfcompile import ( "context" + "path/filepath" "agent-squad/squad" "agent-system-backend/internal/projectcontext" @@ -10,7 +11,7 @@ import ( // projectStepBackends carries the engine-generated execution ID through the // executor's request context into each model manifest. This associates a // responded model request with exactly one workflow step attempt. -func projectStepBackends(base *squad.BackendResolver) *squad.BackendResolver { +func projectStepBackends(base *squad.BackendResolver, rc ProjectStepProvenance, subOf, kinds map[string]string) *squad.BackendResolver { wrapped := squad.NewBackendResolver() for _, name := range base.ListBackends() { backendName := name @@ -19,13 +20,17 @@ func projectStepBackends(base *squad.BackendResolver) *squad.BackendResolver { if err != nil { return nil, err } - return &projectStepExecutor{next: backend}, nil + return &projectStepExecutor{next: backend, rc: rc, subOf: subOf, kinds: kinds}, nil }) } return wrapped } -type projectStepExecutor struct{ next squad.Executor } +type projectStepExecutor struct { + next squad.Executor + rc ProjectStepProvenance + subOf, kinds map[string]string +} func (e *projectStepExecutor) Name() string { return e.next.Name() } @@ -33,5 +38,11 @@ func (e *projectStepExecutor) Execute(ctx context.Context, req squad.ExecRequest if req.ExecutionID == "" { return &squad.ExecResult{Success: false, ErrorReason: "project workflow step attempt unavailable"}, nil } + if e.rc != nil { + wsDir := filepath.Dir(filepath.Dir(req.WorkDir)) + if err := verifyPriorWorkflowOutputs(ctx, e.rc, wsDir, e.subOf, e.kinds, true); err != nil { + return &squad.ExecResult{Success: false, ErrorReason: err.Error()}, nil + } + } return e.next.Execute(projectcontext.WithWorkflowStepAttempt(ctx, req.ExecutionID), req) } diff --git a/internal/wfcompile/project_sources.go b/internal/wfcompile/project_sources.go index 0c971ab..c191f94 100644 --- a/internal/wfcompile/project_sources.go +++ b/internal/wfcompile/project_sources.go @@ -3,6 +3,7 @@ package wfcompile import ( "context" "errors" + "fmt" "os" "path/filepath" "sync" @@ -11,6 +12,41 @@ import ( "agent-squad/squad" ) +// verifyPriorWorkflowOutputs is shared by model and host steps. The engine's +// task brief/blackboard/handoff can carry earlier output as a fresh user +// prompt, so every completed machine step must have a current source proof. +func verifyPriorWorkflowOutputs(ctx context.Context, rc ProjectStepProvenance, wsDir string, subOf, kinds map[string]string, modelSend bool) error { + state, err := (squad.FileStore{}).LoadRun(wsDir) + if err != nil { + return fmt.Errorf("project workflow state unavailable: %w", err) + } + for _, step := range state.Plan.Workflow.Steps { + if step.Status != "completed" { + continue + } + switch kinds[step.ID] { + case "prompt": + sub := subOf[step.ID] + if sub == "" || step.AttemptID == "" { + return fmt.Errorf("project workflow step %s lacks source identity", step.ID) + } + data, err := os.ReadFile(filepath.Join(wsDir, filepath.FromSlash(sub), "output.md")) + if err != nil { + return fmt.Errorf("project workflow step %s output unavailable: %w", step.ID, err) + } + if err := rc.VerifyProjectStepOutput(ctx, step.ID, step.AttemptID, "prompt", data); err != nil { + return fmt.Errorf("project workflow step %s output source changed: %w", step.ID, err) + } + case "hitl", "critic": // human input or a verdict whose prompt source was checked above + default: + if modelSend { + return fmt.Errorf("project workflow step %s has unproven machine output", step.ID) + } + } + } + return nil +} + // completedStepAttempt comes from the squad engine's persisted state, not a // model response or plugin argument. It identifies the exact execution that // produced output.md, including after a workflow resumes. diff --git a/internal/wfcompile/run.go b/internal/wfcompile/run.go index 8799e13..41c011b 100644 --- a/internal/wfcompile/run.go +++ b/internal/wfcompile/run.go @@ -80,6 +80,9 @@ func RunCompiled(rc Host, deps Deps, launch workflow.Launch, vars workflow.Vars, for _, step := range def.Steps { stepKinds[step.ID] = step.Kind } + for _, pair := range compiled.Critics { + stepKinds[pair.EvalID] = "critic" + } if rc.Run().ProjectID != "" { if _, ok := rc.(ProjectStepProvenance); !ok { return Outcome{}, fmt.Errorf("项目工作流缺少可信步骤产物绑定能力") @@ -97,7 +100,7 @@ func RunCompiled(rc Host, deps Deps, launch workflow.Launch, vars workflow.Vars, backends = squad.NewBackendResolver() } if rc.Run().ProjectID != "" { - backends = projectStepBackends(backends) + backends = projectStepBackends(backends, rc.(ProjectStepProvenance), compiled.SubOf, stepKinds) } runner := &hostRunner{ rc: rc, deps: deps, scripts: launch.Scripts, subOf: compiled.SubOf, diff --git a/internal/wfcompile/wfcompile_test.go b/internal/wfcompile/wfcompile_test.go index a9a57e2..7ec0bfa 100644 --- a/internal/wfcompile/wfcompile_test.go +++ b/internal/wfcompile/wfcompile_test.go @@ -476,7 +476,7 @@ func TestProjectStepBackendCarriesEngineAttempt(t *testing.T) { probe := &stepContextProbe{} base := squad.NewBackendResolver() base.RegisterInstance("probe", probe) - backend, _, err := projectStepBackends(base).Resolve(squad.RoleSpec{Role: "writer", Backend: "probe"}, "") + backend, _, err := projectStepBackends(base, nil, nil, nil).Resolve(squad.RoleSpec{Role: "writer", Backend: "probe"}, "") if err != nil { t.Fatal(err) } @@ -563,11 +563,24 @@ func TestProjectToolCriticRejectsUnprovenOutputBeforeModel(t *testing.T) { return verdictStream(verdictPass)(ctx, model, messages, tools, opts) } _, err := RunCompiled(f, Deps{DataDir: t.TempDir(), JudgeStream: judge}, workflow.Launch{Def: def}, workflow.Vars{Input: "read"}, RunOptions{}) - if err == nil || called || !strings.Contains(err.Error(), "产物依据不可用") { + if err == nil || called || (!strings.Contains(err.Error(), "产物依据不可用") && !strings.Contains(err.Error(), "unproven machine output")) { t.Fatalf("unproven tool output reached judge: err=%v called=%v", err, called) } } +func TestProjectModelStepRejectsEarlierToolOutputInTaskBrief(t *testing.T) { + f := &projectSourceHost{fakeHost: &fakeHost{toolNames: []string{"time_now"}}} + be := &seqBackend{texts: []string{"should not run"}} + def := core.WorkflowDef{ID: "tool-then-model", Title: "Tool then model", Steps: []core.WorkflowStep{ + {ID: "get", Title: "Read", Kind: "tool", Tool: "time_now"}, + {ID: "draft", Title: "Draft", Kind: "prompt", Agent: "writer", Prompt: "use {{steps.get.output}}"}, + }} + _, err := RunCompiled(f, Deps{DataDir: t.TempDir(), Backends: seqResolver(t, be), DefaultBackend: "seq"}, workflow.Launch{Def: def}, workflow.Vars{Input: "write"}, RunOptions{}) + if err == nil || be.calls != 0 || !strings.Contains(err.Error(), "unproven machine output") { + t.Fatalf("tool output entered project model step: err=%v modelCalls=%d", err, be.calls) + } +} + // critic 预算耗尽仍不通过 → 工作流失败(P4 fail-closed)。 func TestRunCompiledCriticExhaustedFails(t *testing.T) { def := parseDef(t, demoBriefJSON) diff --git a/pkg/extension/account_directory.go b/pkg/extension/account_directory.go new file mode 100644 index 0000000..ed73b00 --- /dev/null +++ b/pkg/extension/account_directory.go @@ -0,0 +1,94 @@ +package extension + +import "context" + +const AccountDirectorySchemaVersion = 1 + +const ( + AccountDirectoryPurposeSystemUsers = "system.users" + AccountDirectoryPurposeProjectMembers = "project.member-candidate" + AccountDirectoryPurposeProjectRoster = "project.member-roster" + AccountDirectoryPurposeDocumentDuties = "document.duty-candidate" + AccountDirectoryModeExact = "exact" + AccountDirectoryModeFuzzy = "fuzzy" +) + +// AccountDirectoryQuery is a request for account candidates. Actor and field +// permissions are deliberately absent: the host supplies the authenticated +// dispatch actor and obtains current domain authorization from its registrar. +type AccountDirectoryQuery struct { + SchemaVersion int `json:"schemaVersion"` + Purpose string `json:"purpose"` + ProjectID string `json:"projectId,omitempty"` + DocumentID string `json:"documentId,omitempty"` + Mode string `json:"mode"` + Query string `json:"query"` + SearchFields []string `json:"searchFields,omitempty"` + Status string `json:"status,omitempty"` // enabled|disabled|all; domain candidates require enabled + Cursor string `json:"cursor,omitempty"` + Limit int `json:"limit,omitempty"` +} + +// AccountDirectoryEntry contains only identity, effective state and fields +// specifically authorized for the current purpose. Contact details never use +// this candidate DTO. +type AccountDirectoryEntry struct { + UserID string `json:"userId"` + Owner string `json:"owner"` + Name string `json:"name,omitempty"` + StaffNo string `json:"staffNo,omitempty"` + Valid bool `json:"valid"` +} + +type AccountDirectoryPage struct { + Entries []AccountDirectoryEntry `json:"entries"` + DirectoryVersion string `json:"directoryVersion"` + NextCursor string `json:"nextCursor,omitempty"` +} + +type AccountDirectoryLookup struct { + Entry AccountDirectoryEntry `json:"entry"` + DirectoryVersion string `json:"directoryVersion"` +} + +// AccountDirectoryProvider is optional on trusted dispatch Host handles. +// Missing capability is a denial, not permission to use EXT-19 as a fallback. +type AccountDirectoryProvider interface { + QueryAccountDirectory(context.Context, AccountDirectoryQuery) (AccountDirectoryPage, error) + LookupAccountDirectory(context.Context, AccountDirectoryQuery, string) (AccountDirectoryLookup, error) + // LookupAccountDirectoryOwner is an explicit legacy-owner compatibility + // lookup; it resolves to the same stable user ID and applies the same scope. + LookupAccountDirectoryOwner(context.Context, AccountDirectoryQuery, string) (AccountDirectoryLookup, error) +} + +// AccountDirectoryScopeRequest is constructed by the Backend from the trusted +// dispatch actor, registered plugin and untrusted locator. A resolver must +// validate the locator and current member.manage / duty.assign authority. +type AccountDirectoryScopeRequest struct { + Actor string + PluginID string + Purpose string + ProjectID string + DocumentID string +} + +// AccountDirectoryScope is returned by the trusted in-process domain resolver. +// AllCandidates is explicit: a nil candidate list never means global access. +// CandidateIDs or CandidateOwners are applied before search and pagination; +// domain membership stores may use owners while the provider returns stable IDs. +type AccountDirectoryScope struct { + AllowedFields []string + MatchFields []string + AllCandidates bool + CandidateIDs []string + CandidateOwners []string + ScopeVersion string +} + +type AccountDirectoryScopeResolver func(context.Context, AccountDirectoryScopeRequest) (AccountDirectoryScope, error) + +// AccountDirectoryRegistrar is optional on a compiled in-process Registry. +// Only the trusted RDM plugin may register the domain authorization resolver. +type AccountDirectoryRegistrar interface { + RegisterAccountDirectoryScopeResolver(AccountDirectoryScopeResolver) error +} diff --git a/pkg/extension/account_directory_test.go b/pkg/extension/account_directory_test.go new file mode 100644 index 0000000..9404747 --- /dev/null +++ b/pkg/extension/account_directory_test.go @@ -0,0 +1,13 @@ +package extension + +import "testing" + +func TestAccountDirectoryContractIsOptional(t *testing.T) { + var host Host + _, _ = host.(AccountDirectoryProvider) + var registry Registry + _, _ = registry.(AccountDirectoryRegistrar) + if AccountDirectorySchemaVersion < 1 { + t.Fatal("directory schema version must be explicit") + } +} diff --git a/pkg/extension/capability_explain.go b/pkg/extension/capability_explain.go new file mode 100644 index 0000000..73ac903 --- /dev/null +++ b/pkg/extension/capability_explain.go @@ -0,0 +1,158 @@ +package extension + +import ( + "context" + "errors" + "net/http" +) + +const CapabilityExplainSchemaVersion = 1 + +// Capability sources name where an allowed action comes from. +const ( + CapabilitySourceRole = "role" + CapabilitySourceSystemGrant = "system-grant" + CapabilitySourceBootstrapAdmin = "bootstrap-admin" +) + +// Capability scopes name where an allowed action applies. +const ( + CapabilityScopeSystem = "system" + CapabilityScopeOwnProjects = "own-projects" + CapabilityScopeMemberProjects = "member-projects" +) + +// CapabilitySubject identifies the Host-bound actor and the authority snapshot +// an explanation was computed from. It never names another account. +type CapabilitySubject struct { + SchemaVersion int `json:"schemaVersion"` + UserID string `json:"userId"` + Owner string `json:"owner"` + AuthorityEpoch int64 `json:"authorityEpoch"` + RoleID string `json:"roleId,omitempty"` + RoleName string `json:"roleName,omitempty"` + RoleEnabled bool `json:"roleEnabled"` +} + +// CapabilityActionExplain reports one action's effective decision. Source is +// set only when Allowed; LinkedVia names the action that links it; Conflict +// marks a linked action that the enabled role caps off. +type CapabilityActionExplain struct { + ActionID string `json:"actionId"` + Scope string `json:"scope"` + Allowed bool `json:"allowed"` + CapAllowed bool `json:"capAllowed"` + Source string `json:"source,omitempty"` + LinkedVia string `json:"linkedVia,omitempty"` + Conflict bool `json:"conflict,omitempty"` + Reason string `json:"reason,omitempty"` +} + +// CapabilityExplain is the creator explanation: project.create, the linked +// own-project management actions, their conflicts and the migration note. +type CapabilityExplain struct { + CapabilitySubject + ProjectCreate CapabilityActionExplain `json:"projectCreate"` + Linked []CapabilityActionExplain `json:"linked"` + Conflicts []string `json:"conflicts"` + MigrationNote string `json:"migrationNote,omitempty"` +} + +// MemberBasicCollaborate explains the role defaults a member brings to the +// projects they are a member of. Bootstrap admin is not a content source. +type MemberBasicCollaborate struct { + CapabilitySubject + Actions []CapabilityActionExplain `json:"actions"` +} + +// DocumentDutyQualify reports whether the role cap lets a document duty be +// assigned to the actor and whether the role grants it by default. +type DocumentDutyQualify struct { + ActionID string `json:"actionId"` + Qualified bool `json:"qualified"` + DefaultAllowed bool `json:"defaultAllowed"` + Source string `json:"source,omitempty"` + Reason string `json:"reason,omitempty"` +} + +type DocumentQualify struct { + CapabilitySubject + Duties []DocumentDutyQualify `json:"duties"` +} + +// CapabilityExplainProvider is optional on a trusted dispatch Host. Every +// method explains only the Host-bound actor; callers cannot name another one. +type CapabilityExplainProvider interface { + CurrentCapabilityExplain(context.Context) (CapabilityExplain, error) + CurrentMemberBasicCollaborate(context.Context) (MemberBasicCollaborate, error) + CurrentDocumentQualify(context.Context) (DocumentQualify, error) +} + +// Work operations whose failures are categorized for clients. +const ( + WorkOperationProjectCreate = "project.create" + WorkOperationMemberAdd = "project.member.add" + WorkOperationConversation = "conversation.work" +) + +// Work failure reasons. Each maps to exactly one HTTP status. +const ( + WorkFailureDuplicateName = "duplicate-name" + WorkFailurePolicyRejected = "policy-rejected" + WorkFailureMappingNotReady = "mapping-not-ready" + WorkFailureServiceUnavailable = "service-unavailable" +) + +// WorkFailure is a categorized refusal a provider may return from project +// create, member add or conversation work. Its text carries only the operation +// and reason, never an account, project or name. +type WorkFailure struct { + Operation string `json:"operation"` + Reason string `json:"reason"` +} + +func (f WorkFailure) Error() string { return f.Operation + ": " + f.Reason } + +// StatusCode returns 0 for an unknown reason so it is never trusted as a status. +func (f WorkFailure) StatusCode() int { + switch f.Reason { + case WorkFailureDuplicateName: + return http.StatusConflict + case WorkFailurePolicyRejected: + return http.StatusForbidden + case WorkFailureMappingNotReady, WorkFailureServiceUnavailable: + return http.StatusServiceUnavailable + } + return 0 +} + +// CategorizeWorkFailure classifies a provider error for operation. A known +// WorkFailure keeps its reason; a coded 403/503 (and 409 outside conversation +// work) maps to its category; a deadline is service-unavailable. Anything else +// is left uncategorized so the caller keeps its existing not-found handling. +func CategorizeWorkFailure(operation string, err error) (WorkFailure, bool) { + if err == nil { + return WorkFailure{}, false + } + var failure WorkFailure + if errors.As(err, &failure) && failure.StatusCode() != 0 { + return WorkFailure{Operation: operation, Reason: failure.Reason}, true + } + var coded interface{ StatusCode() int } + if errors.As(err, &coded) { + switch coded.StatusCode() { + case http.StatusForbidden: + return WorkFailure{Operation: operation, Reason: WorkFailurePolicyRejected}, true + case http.StatusConflict: + if operation != WorkOperationConversation { + return WorkFailure{Operation: operation, Reason: WorkFailureDuplicateName}, true + } + case http.StatusServiceUnavailable: + return WorkFailure{Operation: operation, Reason: WorkFailureServiceUnavailable}, true + } + } + if errors.Is(err, context.DeadlineExceeded) { + return WorkFailure{Operation: operation, Reason: WorkFailureServiceUnavailable}, true + } + return WorkFailure{}, false +} diff --git a/pkg/extension/capability_explain_test.go b/pkg/extension/capability_explain_test.go new file mode 100644 index 0000000..85244b1 --- /dev/null +++ b/pkg/extension/capability_explain_test.go @@ -0,0 +1,56 @@ +package extension + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + "testing" +) + +type codedTestError int + +func (e codedTestError) Error() string { return "provider detail about other-owner" } +func (e codedTestError) StatusCode() int { return int(e) } + +func TestWorkFailureStatusAndTextCarryNoIdentifiers(t *testing.T) { + for reason, code := range map[string]int{ + WorkFailureDuplicateName: http.StatusConflict, + WorkFailurePolicyRejected: http.StatusForbidden, + WorkFailureMappingNotReady: http.StatusServiceUnavailable, + WorkFailureServiceUnavailable: http.StatusServiceUnavailable, + "invented": 0, + } { + failure := WorkFailure{Operation: WorkOperationMemberAdd, Reason: reason} + if failure.StatusCode() != code || failure.Error() != WorkOperationMemberAdd+": "+reason { + t.Fatalf("%s: status=%d text=%q", reason, failure.StatusCode(), failure.Error()) + } + } +} + +func TestCategorizeWorkFailure(t *testing.T) { + cases := []struct { + op string + err error + reason string + ok bool + }{ + {WorkOperationProjectCreate, fmt.Errorf("wrap: %w", WorkFailure{Reason: WorkFailureMappingNotReady}), WorkFailureMappingNotReady, true}, + {WorkOperationProjectCreate, codedTestError(409), WorkFailureDuplicateName, true}, + {WorkOperationMemberAdd, codedTestError(403), WorkFailurePolicyRejected, true}, + {WorkOperationConversation, codedTestError(503), WorkFailureServiceUnavailable, true}, + {WorkOperationConversation, codedTestError(409), "", false}, + {WorkOperationConversation, context.DeadlineExceeded, WorkFailureServiceUnavailable, true}, + {WorkOperationMemberAdd, codedTestError(404), "", false}, + {WorkOperationMemberAdd, WorkFailure{Reason: "invented"}, "", false}, + {WorkOperationMemberAdd, errors.New("plain"), "", false}, + {WorkOperationMemberAdd, nil, "", false}, + } + for _, tc := range cases { + failure, ok := CategorizeWorkFailure(tc.op, tc.err) + if ok != tc.ok || failure.Reason != tc.reason || (ok && failure.Operation != tc.op) || strings.Contains(failure.Error(), "other-owner") { + t.Fatalf("%s %v: failure=%+v ok=%v", tc.op, tc.err, failure, ok) + } + } +} diff --git a/pkg/extension/extension.go b/pkg/extension/extension.go index f36e91e..6ab17f2 100644 --- a/pkg/extension/extension.go +++ b/pkg/extension/extension.go @@ -6,6 +6,8 @@ import ( "context" "encoding/json" "errors" + "flag" + "fmt" "sync" core "agent-system-core" @@ -34,7 +36,46 @@ import ( // 分区组件自身的命令面 fail-closed)。 // 2.12.0-experimental: optional ProjectHost/ProjectLifecycleRegistry and trusted // ResourceAuthzQuery.Source for per-document publication authorization. -const SurfaceVersion = "2.13.0-experimental" +// 2.13.0-experimental: optional AccountDirectoryProvider and trusted in-process +// AccountDirectoryRegistrar for scoped, current account candidate decisions. +// 2.14.0-experimental: optional ProjectAuditHost and ProjectAuditRegistry (X2 +// project-bound command-audit read slice and domain audit.read callback). +// 2.15.0-experimental: X2 optional committed project-audit link publisher, +// verifier, disclosure callback, and exact linked receipt read. +// 2.16.0-experimental: trusted Request.OperationID before handler execution and +// retryable ProjectAuditLinkRequest.HostAuditOffset=0 resolution. +// 2.17.0-experimental: cryptographic operation IDs and registration-bound +// ProjectAuditPublisherFactory; scoped Host string IDs cannot publish links. +// 2.18.0-experimental: PostgreSQL project audit pages require a verified +// committed-prefix ID generator; unsafe sequence/default drift rejects reads. +// 2.19.0-experimental: all PostgreSQL audit writes require the database +// insert guard and append-only policy before a committed-prefix claim. +// 2.20.0-experimental: combine optional X1 project context/source recheck and +// X2 project audit capabilities on one Backend surface. +// 2.21.0-experimental: optional ProjectWorkObservationRegistry; host-origin +// work-source observations and project action decisions (X3 contract). +// 2.22.0-experimental: optional HumanDecisionVerifier. The host proves that +// this dispatch is an authenticated human response to one exact pending card. +// It does not consume the proof; the caller does that in its own transaction. +// 2.23.0-experimental: InProc.HostOptions / CLIHook, plus MergeHostOptions, +// RegisterCLIFlags、RunCLIHooks(组合二进制把插件声明并进基座启动,不覆盖显式参数)。 +// 2.24.0-experimental:EXT-20 可选 RegulationsHost 与受信进程内 RegulationsRegistrar +// (共享法规检索与网络证据;仅插件 kbreg 可注册提供方)。 +// 2.25.0-experimental: IAM U4 optional RolePolicyProvider and +// SystemActionProvider for the Backend-owned single-effective-role policy; +// resource queries carry the current authority epoch so downstream caches can +// reject stale role snapshots. +// 2.26.0-experimental: IAM link U1 RolePolicySnapshot.Linked carries the +// Backend-computed project.create own-project management link and its source. +// 2.27.0-experimental: IAM link U3 optional CapabilityExplainProvider +// (CapabilityExplain / MemberBasicCollaborate / DocumentQualify for the +// Host-bound actor) and categorized WorkFailure for create, member add and +// conversation work. +// 2.28.0-experimental: optional RoleOptionsProvider (read-only enabled role +// directory: RoleOption roleId+name, no permissions or bindings) and the +// AccountDirectory purpose project.member-roster, an explicit project member +// enumeration inside the trusted RDM scope. +const SurfaceVersion = "2.28.0-experimental" // ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。 var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link") @@ -56,6 +97,12 @@ type ResourceAuthzQuery struct { // Source is optional trusted resource provenance, populated from stored knowledge rows. Source string `json:"source,omitempty"` ProjectID core.ID `json:"projectId,omitempty"` // 所属项目;空=无项目私域 + // AuthorityEpoch is read from Backend authority_state at the point of use. + // Downstream policy caches must reject snapshots from another epoch. + AuthorityEpoch int64 `json:"authorityEpoch,omitempty"` + // RolePolicy is the current Backend snapshot for Actor on trusted in-process + // calls. It lets downstream resource authorization refresh without a command. + RolePolicy *RolePolicySnapshot `json:"rolePolicy,omitempty"` } // ResourceAuthorizerV2 EXT-8b 资源级授权适配器:V1(owner×project 布尔)之上的 @@ -97,17 +144,15 @@ type AccountEntry struct { Valid bool `json:"valid"` } -// AccountLister EXT-19 可选能力接口(实现由宿主注入):游标分页列举系统账号。 -// 未实现时下游回落现行行为(成员添加不做账号有效性校验);实现后仅用于 -// 授权管理读面——不授予任何写权。 +// AccountLister EXT-19 旧接口:仅有受信系统管理员分发上下文时可读取。 +// 新领域消费者必须使用 AccountDirectoryProvider,不得以本接口作为降级路径。 type AccountLister interface { ListAccounts(cursor string, limit int) (entries []AccountEntry, next string, err error) } // AccountSearcher EXT-19 扩展可选能力(P3,2026-10-13):按账号或姓名检索账号 -// 目录,供人员选择器的输入搜索。query 匹配账号子串或姓名子串(大小写不敏感; -// 空串=不限,返回首页候选)。仅授权管理读面使用;未实现时下游回落 -// AccountLister 分页浏览。 +// 目录。与 AccountLister 相同,它不含领域定位,只允许受信系统管理员上下文; +// 新领域消费者不得回落到此接口。 type AccountSearcher interface { SearchAccounts(query string, limit int) (entries []AccountEntry, err error) } @@ -164,10 +209,24 @@ type IntentSpec struct { Description string `json:"description,omitempty"` } +// CLIHook 让编译插件在基座 serve 旗标集上登记自己的旗标,并在解析后决定是否接管本次进程。 +// 插件只登记自己的旗标,不重复基座已有旗标。基座在 flag.Parse 前调用 Register,解析后调用 AfterParse。 +type CLIHook struct { + Register func(fs *flag.FlagSet) + AfterParse func(opt Options) (handled bool, err error) +} + // InProc 是随下游二进制编译进来的扩展。 type InProc struct { ID string Register func(reg Registry) error + // HostOptions 可选。组合二进制把它当作启动默认值:布尔开关按“任一为 true 则为 true” + // 合并,且不覆盖调用方显式传入的参数。未设置则无产品默认。 + HostOptions func() Options + // CLI 可选。组合二进制在基座 serve 旗标上挂接插件专属旗标(如 bootstrap-admin)。 + CLI *CLIHook + // Prepare 可选。基座合并 HostOptions 之后、进入服务之前调用,可改写 opt 或拒绝启动。 + Prepare func(opt *Options) error } // Options 是下游自有二进制的启动参数,字段与 agent-system serve 对齐。 @@ -198,6 +257,12 @@ type Options struct { // 仅会话工作目录与技能目录挂载、进程数限额、超时杀整个沙箱)。 // DisableExecTools 优先级高于此选择。 ExecBackend string + // ExecDepsDir、ExecWorkspace、ExecEnvRoot 与基座 serve 同名。 + // 产品入口不解析这三项旗标时留空,link 改读同名环境变量 + //(AGENT_SYSTEM_EXEC_DEPS_DIR / AGENT_SYSTEM_EXEC_WORKSPACE / AGENT_SYSTEM_EXEC_ENV_ROOT)。 + ExecDepsDir string + ExecWorkspace string + ExecEnvRoot string } var ( @@ -206,6 +271,80 @@ var ( runner func(Options) error ) +// MergeHostOptions 把已登记插件的 HostOptions 合并进 base。 +// 布尔开关按“任一为 true 则为 true”。ExecBackend 取首个非空值,彼此冲突则返回错误。 +// Addr、DataDir、PluginsDir、StorageDSN、AuthMode 等运行参数不从插件合并,避免覆盖调用方。 +func MergeHostOptions(base Options) (Options, error) { + for _, e := range Compiled() { + if e.HostOptions == nil { + continue + } + opt := e.HostOptions() + if opt.DisableUploadIndexing { + base.DisableUploadIndexing = true + } + if opt.DisableConvCardMirror { + base.DisableConvCardMirror = true + } + if opt.DisableExecTools { + base.DisableExecTools = true + } + if opt.ExecBackend == "" { + continue + } + if base.ExecBackend != "" && base.ExecBackend != opt.ExecBackend { + return base, fmt.Errorf("extension: plugin %s exec backend %q conflicts with %q", e.ID, opt.ExecBackend, base.ExecBackend) + } + if base.ExecBackend == "" { + base.ExecBackend = opt.ExecBackend + } + } + return base, nil +} + +// RegisterCLIFlags 让每个已登记插件往 fs 登记自己的旗标。须在 Parse 之前调用。 +func RegisterCLIFlags(fs *flag.FlagSet) { + if fs == nil { + return + } + for _, e := range Compiled() { + if e.CLI != nil && e.CLI.Register != nil { + e.CLI.Register(fs) + } + } +} + +// RunCLIHooks 在旗标解析之后依次调用插件 AfterParse。 +// 第一个 handled 的插件接管本次进程,基座不应再进入服务循环。 +func RunCLIHooks(opt Options) (bool, error) { + for _, e := range Compiled() { + if e.CLI == nil || e.CLI.AfterParse == nil { + continue + } + handled, err := e.CLI.AfterParse(opt) + if err != nil || handled { + return handled, err + } + } + return false, nil +} + +// PrepareCompiled 在 HostOptions 合并进 opt 之后调用各插件 Prepare。任一错误拒绝启动。 +func PrepareCompiled(opt *Options) error { + if opt == nil { + return errors.New("extension: nil options") + } + for _, e := range Compiled() { + if e.Prepare == nil { + continue + } + if err := e.Prepare(opt); err != nil { + return fmt.Errorf("extension: %s: %w", e.ID, err) + } + } + return nil +} + // RegisterCompiled 在 init 中登记进程内扩展。 func RegisterCompiled(e InProc) { mu.Lock() diff --git a/pkg/extension/host.go b/pkg/extension/host.go index 9d36b71..197a428 100644 --- a/pkg/extension/host.go +++ b/pkg/extension/host.go @@ -2,6 +2,7 @@ package extension import ( "context" + "time" core "agent-system-core" ) @@ -14,6 +15,9 @@ type Request struct { Actor string Owner string ConversationID string + // OperationID is generated by the trusted host bus before this handler runs. + // Persist it with an actual domain commit/outbox; never treat it as a retry key. + OperationID string // HostProjectID 本次分发所属基座项目(EXT-18 可选上下文;空=无项目 // 上下文——API 入口无对话或对话未关联项目时为零值)。下游按显式映射 // 解析(如 RDM rdm_host_project_map),不得据此替代自身授权判定。 @@ -43,6 +47,9 @@ type Handler func(ctx context.Context, h Host, req Request) (any, error) // - SurfaceHost — EXT-17:页面保存/读取 // - WorkCompletionHost — X1:受信项目工作生成 // - WorkTaskHost — X1:分发期建立可恢复的受信项目任务绑定 +// - HumanDecisionVerifier — 本次分发是否为认证人对确切待确认卡的响应 +// - RolePolicyProvider — Backend 权威职位与动作默认/上限 +// - CapabilityExplainProvider — 当前分发主体的创建者/成员/文档职责能力解释 type Host interface { // PluginID 返回注册处理器的插件标识(形如 rdm)。 PluginID() string @@ -89,3 +96,53 @@ type Host interface { } // —— AdminProvider 定义移至本文件上方(Request 之后)—— + +// HumanDecisionAudienceStructure is the only audience this proof can authorize. +// Another plugin cannot reuse the same human response as its own confirmation. +const HumanDecisionAudienceStructure = "rdm.structure.decide" + +// HumanDecisionBinding is the exact object a human must have confirmed. +// Callers pass it to the host; they do not carry a copyable secret. +type HumanDecisionBinding struct { + Audience string + Actor string + ProjectID string + ProposalID string + ProposalRevision int + PayloadHash string + Decision string +} + +// HumanDecisionCard is the pending object a person confirms. +type HumanDecisionCard struct { + CardID string + Question string + ExpiresAt time.Time +} + +// HumanDecisionProof is a read-only statement that a person already responded. +// ProofID is a consumption key for the caller. The host does not mark it used. +type HumanDecisionProof struct { + ProofID string + Issuer string + HumanActor string + Audience string + ProjectID string + ProposalID string + Revision int + PayloadHash string + Decision string + IssuedAt time.Time + ExpiresAt time.Time + ConversationID string + RunID string + HITLResponseRef string +} + +// HumanDecisionVerifier is optional. A missing assertion means the capability +// is absent and the caller must refuse the write. +type HumanDecisionVerifier interface { + IssueHumanDecision(binding HumanDecisionBinding, question string) (HumanDecisionCard, error) + VerifyHumanDecision(expected HumanDecisionBinding) (HumanDecisionProof, error) + RevokeHumanDecision(cardID string) error +} diff --git a/pkg/extension/hostoptions_test.go b/pkg/extension/hostoptions_test.go new file mode 100644 index 0000000..cabbd02 --- /dev/null +++ b/pkg/extension/hostoptions_test.go @@ -0,0 +1,111 @@ +package extension + +import ( + "errors" + "flag" + "testing" +) + +func TestMergeHostOptionsORAndExecConflict(t *testing.T) { + ResetCompiled() + t.Cleanup(ResetCompiled) + RegisterCompiled(InProc{ + ID: "a", + HostOptions: func() Options { + return Options{DisableUploadIndexing: true, ExecBackend: "sandbox"} + }, + }) + RegisterCompiled(InProc{ + ID: "b", + HostOptions: func() Options { + return Options{DisableExecTools: true, DisableConvCardMirror: true} + }, + }) + got, err := MergeHostOptions(Options{Addr: "127.0.0.1:1"}) + if err != nil { + t.Fatal(err) + } + if !got.DisableUploadIndexing || !got.DisableExecTools || !got.DisableConvCardMirror { + t.Fatalf("bool merge: %+v", got) + } + if got.ExecBackend != "sandbox" || got.Addr != "127.0.0.1:1" { + t.Fatalf("kept caller addr and first exec backend: %+v", got) + } + RegisterCompiled(InProc{ + ID: "c", + HostOptions: func() Options { return Options{ExecBackend: "direct"} }, + }) + if _, err := MergeHostOptions(Options{}); err == nil { + t.Fatal("expected exec backend conflict") + } +} + +func TestPrepareCompiledCanRejectStartup(t *testing.T) { + ResetCompiled() + t.Cleanup(ResetCompiled) + RegisterCompiled(InProc{ + ID: "rdm", + Prepare: func(opt *Options) error { + if opt.ExecBackend == "sandbox" { + return errors.New("no bwrap") + } + opt.DisableExecTools = true + return nil + }, + }) + opt := Options{ExecBackend: "sandbox"} + if err := PrepareCompiled(&opt); err == nil { + t.Fatal("expected prepare error") + } + opt.ExecBackend = "direct" + if err := PrepareCompiled(&opt); err != nil { + t.Fatal(err) + } + if !opt.DisableExecTools { + t.Fatal("prepare should be able to rewrite options") + } +} + +func TestCLIHookHandlesBeforeServe(t *testing.T) { + ResetCompiled() + t.Cleanup(ResetCompiled) + var admin *string + RegisterCompiled(InProc{ + ID: "rdm", + CLI: &CLIHook{ + Register: func(fs *flag.FlagSet) { + admin = fs.String("bootstrap-admin", "", "owner") + }, + AfterParse: func(opt Options) (bool, error) { + if admin == nil || *admin == "" { + return false, nil + } + if opt.DataDir == "" { + return true, errors.New("missing data") + } + return true, nil + }, + }, + }) + fs := flag.NewFlagSet("serve", flag.ContinueOnError) + RegisterCLIFlags(fs) + if err := fs.Parse([]string{"-bootstrap-admin", "owner"}); err != nil { + t.Fatal(err) + } + if fs.Lookup("data") != nil { + t.Fatal("plugin must not redefine base flags") + } + handled, err := RunCLIHooks(Options{DataDir: "/tmp/data"}) + if err != nil || !handled { + t.Fatalf("handled=%v err=%v", handled, err) + } + fs2 := flag.NewFlagSet("serve", flag.ContinueOnError) + RegisterCLIFlags(fs2) + if err := fs2.Parse(nil); err != nil { + t.Fatal(err) + } + handled, err = RunCLIHooks(Options{DataDir: "/tmp/data"}) + if err != nil || handled { + t.Fatalf("empty flag should not handle: handled=%v err=%v", handled, err) + } +} diff --git a/pkg/extension/link/link.go b/pkg/extension/link/link.go index fda2744..ec9363b 100644 --- a/pkg/extension/link/link.go +++ b/pkg/extension/link/link.go @@ -3,20 +3,56 @@ package link import ( + "os" + "agent-system-backend/internal/app" + "agent-system-backend/internal/config" "agent-system-backend/pkg/extension" ) func init() { extension.SetRunner(func(opt extension.Options) error { - return app.Run(app.Options{ - Addr: opt.Addr, DataDir: opt.DataDir, PluginsDir: opt.PluginsDir, ForceMock: opt.Mock, - StorageDSN: opt.StorageDSN, ArtifactsDir: opt.ArtifactsDir, AuthMode: opt.AuthMode, - RequireProjectContext: opt.RequireProjectContext, - DisableUploadIndexing: opt.DisableUploadIndexing, - DisableConvCardMirror: opt.DisableConvCardMirror, - DisableExecTools: opt.DisableExecTools, - ExecBackend: opt.ExecBackend, - }) + appOpt, err := runOptions(opt) + if err != nil { + return err + } + return app.Run(appOpt) }) } + +// runOptions 在 appOptions 基础上补齐超级管理员凭据,与 cmd/agent-system 同源: +// 进程环境优先,未设置的键从 AGENT_SYSTEM_ENV_FILE 指向的受保护 dotenv 补齐。 +func runOptions(opt extension.Options) (app.Options, error) { + appOpt := appOptions(opt) + authEnabled := config.Resolved{Config: config.Config{AuthMode: opt.AuthMode, StorageDSN: opt.StorageDSN}}.AuthEnabled() + superAdmin, err := config.LoadSuperAdmin(os.LookupEnv, "", authEnabled) + if err != nil { + return app.Options{}, err + } + appOpt.SuperAdmin = superAdmin + return appOpt, nil +} + +// appOptions 把产品启动参数映射到基座。执行环境三项产品旗标留空时读环境变量, +// 与 cmd/agent-system 的 config 键一致,这样 svc 注入环境即可,不必改产品 main。 +func appOptions(opt extension.Options) app.Options { + return app.Options{ + Addr: opt.Addr, DataDir: opt.DataDir, PluginsDir: opt.PluginsDir, ForceMock: opt.Mock, + StorageDSN: opt.StorageDSN, ArtifactsDir: opt.ArtifactsDir, AuthMode: opt.AuthMode, + RequireProjectContext: opt.RequireProjectContext, + DisableUploadIndexing: opt.DisableUploadIndexing, + DisableConvCardMirror: opt.DisableConvCardMirror, + DisableExecTools: opt.DisableExecTools, + ExecBackend: opt.ExecBackend, + ExecDepsDir: firstSet(opt.ExecDepsDir, os.Getenv("AGENT_SYSTEM_EXEC_DEPS_DIR")), + ExecWorkspace: firstSet(opt.ExecWorkspace, os.Getenv("AGENT_SYSTEM_EXEC_WORKSPACE")), + ExecEnvRoot: firstSet(opt.ExecEnvRoot, os.Getenv("AGENT_SYSTEM_EXEC_ENV_ROOT")), + } +} + +func firstSet(explicit, fromEnv string) string { + if explicit != "" { + return explicit + } + return fromEnv +} diff --git a/pkg/extension/link/link_test.go b/pkg/extension/link/link_test.go new file mode 100644 index 0000000..a7dce0d --- /dev/null +++ b/pkg/extension/link/link_test.go @@ -0,0 +1,113 @@ +package link + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "agent-system-backend/internal/config" + "agent-system-backend/pkg/extension" +) + +const fakeSuperAdminPassword = "Fake-Test-Passw0rd-Only" + +func clearSuperAdminEnv(t *testing.T) { + t.Helper() + for _, key := range []string{config.EnvSuperAdminOwner, config.EnvSuperAdminPassword, config.EnvFileKey} { + t.Setenv(key, "") + os.Unsetenv(key) + } +} + +func writeEnvFile(t *testing.T, mode os.FileMode) string { + t.Helper() + path := filepath.Join(t.TempDir(), "super-admin.env") + content := "SUPER_ADMIN_OWNER=fake-file-owner\nSUPER_ADMIN_PASSWORD=" + fakeSuperAdminPassword + "\n" + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, mode); err != nil { + t.Fatal(err) + } + return path +} + +func TestRunOptionsLoadsSuperAdminFromEnv(t *testing.T) { + clearSuperAdminEnv(t) + t.Setenv(config.EnvSuperAdminOwner, "fake-env-owner") + t.Setenv(config.EnvSuperAdminPassword, fakeSuperAdminPassword) + got, err := runOptions(extension.Options{AuthMode: "on", RequireProjectContext: true}) + if err != nil { + t.Fatalf("runOptions: %v", err) + } + if got.SuperAdmin.Owner != "fake-env-owner" || got.SuperAdmin.Password() == "" { + t.Fatalf("super admin not loaded from env: %v", got.SuperAdmin) + } + if !got.RequireProjectContext || got.AuthMode != "on" { + t.Fatalf("appOptions mapping lost: auth=%q requireProject=%t", got.AuthMode, got.RequireProjectContext) + } +} + +func TestRunOptionsLoadsSuperAdminFromEnvFile(t *testing.T) { + clearSuperAdminEnv(t) + t.Setenv(config.EnvFileKey, writeEnvFile(t, 0o600)) + got, err := runOptions(extension.Options{AuthMode: "on"}) + if err != nil { + t.Fatalf("runOptions: %v", err) + } + if got.SuperAdmin.Owner != "fake-file-owner" || got.SuperAdmin.Password() == "" { + t.Fatalf("super admin not loaded from env file: %v", got.SuperAdmin) + } +} + +func TestRunOptionsSkipsSuperAdminWithoutAuthentication(t *testing.T) { + clearSuperAdminEnv(t) + got, err := runOptions(extension.Options{AuthMode: "off"}) + if err != nil { + t.Fatalf("runOptions without auth: %v", err) + } + if got.SuperAdmin.Owner != "" || got.SuperAdmin.Password() != "" { + t.Fatalf("super admin should be empty without auth: %v", got.SuperAdmin) + } +} + +func TestRunOptionsRejectsExposedEnvFile(t *testing.T) { + clearSuperAdminEnv(t) + t.Setenv(config.EnvFileKey, writeEnvFile(t, 0o640)) + _, err := runOptions(extension.Options{AuthMode: "on"}) + if !errors.Is(err, config.ErrSuperAdminConfig) { + t.Fatalf("exposed env file accepted: %v", err) + } +} + +func TestAppOptionsPrefersExplicitExecPaths(t *testing.T) { + t.Setenv("AGENT_SYSTEM_EXEC_DEPS_DIR", "/from/env/deps") + t.Setenv("AGENT_SYSTEM_EXEC_WORKSPACE", "overlay") + t.Setenv("AGENT_SYSTEM_EXEC_ENV_ROOT", "/from/env/root") + got := appOptions(extension.Options{ + ExecDepsDir: "/explicit/deps", + ExecWorkspace: "direct", + ExecEnvRoot: "/explicit/root", + }) + if got.ExecDepsDir != "/explicit/deps" || got.ExecWorkspace != "direct" || got.ExecEnvRoot != "/explicit/root" { + t.Fatalf("explicit paths lost: %+v", got) + } +} + +func TestAppOptionsReadsExecEnvWhenUnset(t *testing.T) { + t.Setenv("AGENT_SYSTEM_EXEC_DEPS_DIR", "/from/env/deps") + t.Setenv("AGENT_SYSTEM_EXEC_WORKSPACE", "overlay") + t.Setenv("AGENT_SYSTEM_EXEC_ENV_ROOT", "/from/env/root") + got := appOptions(extension.Options{ExecBackend: "sandbox"}) + if got.ExecBackend != "sandbox" || got.ExecDepsDir != "/from/env/deps" || got.ExecWorkspace != "overlay" || got.ExecEnvRoot != "/from/env/root" { + t.Fatalf("env fallback: %+v", got) + } +} + +func TestAppOptionsPreservesProjectContextRequirement(t *testing.T) { + got := appOptions(extension.Options{RequireProjectContext: true}) + if !got.RequireProjectContext { + t.Fatal("required project context was dropped at the product entry") + } +} diff --git a/pkg/extension/project_audit.go b/pkg/extension/project_audit.go new file mode 100644 index 0000000..c05d6dc --- /dev/null +++ b/pkg/extension/project_audit.go @@ -0,0 +1,161 @@ +package extension + +import ( + "context" + "errors" + "time" + + core "agent-system-core" +) + +// ProjectAuditAuthorizer answers the domain's audit.read decision for the +// authenticated actor and a project identified by trusted host storage. +// A project ID supplied by a caller is a locator, never an authorization. +type ProjectAuditAuthorizer func(actor string, projectID core.ID) (bool, error) + +// ProjectAuditRegistry is optional so older extension registries remain valid. +// Only an in-process domain plugin can register this callback. +type ProjectAuditRegistry interface { + RegisterProjectAuditAuthorizer(ProjectAuditAuthorizer) error +} + +var ( + ErrProjectAuditDenied = errors.New("extension: project audit denied") + ErrProjectAuditUnsupported = errors.New("extension: project audit unsupported") + ErrProjectAuditLinkConflict = errors.New("extension: project audit link conflict") + ErrProjectAuditPending = errors.New("extension: committed host audit receipt pending") +) + +// ProjectAuditLinkRequest binds one host command receipt to an already +// committed domain fact. Owner is never caller supplied; the host handle and +// stored audit row establish it. HostAuditOffset=0 asks the host to resolve a +// unique committed row by its trusted owner/project/OperationID, which lets an +// outbox wait for the bus audit commit after the domain handler returns. This +// first slice supports domain-commit only. +type ProjectAuditLinkRequest struct { + ProjectID core.ID `json:"projectId"` + HostAuditOffset int64 `json:"hostAuditOffset"` + SourceKind string `json:"sourceKind"` + SourceID string `json:"sourceId"` + OperationID string `json:"operationId"` + ResourceKind string `json:"resourceKind"` + ResourceID string `json:"resourceId"` + RunID string `json:"runId,omitempty"` + DisclosureClass string `json:"disclosureClass"` + IdempotencyKey string `json:"idempotencyKey"` +} + +// ProjectAuditLinkProof contains the caller request plus identity and source +// fields re-read from trusted host storage. The domain verifier must check its +// committed source event and operation/resource attribution, not a model claim. +type ProjectAuditLinkProof struct { + ProjectAuditLinkRequest + Owner string +} + +type ProjectAuditLinkVerifier func(context.Context, ProjectAuditLinkProof) (bool, error) + +// ProjectAuditLinkedResource is the stored, immutable link presented to the +// current domain disclosure policy. A caller ID alone never authorizes it. +type ProjectAuditLinkedResource struct { + ProjectID core.ID + Owner string + SourceKind string + SourceID string + OperationID string + ResourceKind string + ResourceID string + RunID string + DisclosureClass string +} + +type ProjectAuditDisclosure struct { + Metadata bool + Body bool +} + +type ProjectAuditDisclosureAuthorizer func(context.Context, string, ProjectAuditLinkedResource) (ProjectAuditDisclosure, error) + +// ProjectAuditPublisherFactory is issued only by successful verifier +// registration. A domain outbox may retain it and bind an owner for each job; +// owner and committed-source attribution are checked again when publishing. +// NewScopedHost(owner, pluginID) cannot mint this capability. +type ProjectAuditPublisherFactory interface { + ForOwner(owner string) (ProjectAuditPublisher, error) +} + +// ProjectAuditLinkRegistry is optional. Only the registering compiled domain +// plugin receives a publisher factory; string plugin IDs carry no authority. +type ProjectAuditLinkRegistry interface { + RegisterProjectAuditLinkVerifier(ProjectAuditLinkVerifier) (ProjectAuditPublisherFactory, error) + RegisterProjectAuditDisclosureAuthorizer(ProjectAuditDisclosureAuthorizer) error +} + +type ProjectAuditLinkReceipt struct { + LinkID int64 `json:"linkId"` + HostAuditOffset int64 `json:"hostAuditOffset"` +} + +type ProjectAuditPublisher interface { + PublishProjectAuditLink(context.Context, ProjectAuditLinkRequest) (ProjectAuditLinkReceipt, error) +} + +// ProjectAuditQuery reads one project-bound command-audit slice. Offset is a +// host source offset, not a cross-domain event sequence. A nonzero BeforeOffset +// must be paired with the CutoffOffset returned on the first page. PostgreSQL +// pages are served only while the installed generator, sequence, and all-writer +// database guards prove a +// committed prefix; otherwise the host rejects the read instead of claiming a +// fixed snapshot. See Coverage.CutoffConsistency. +type ProjectAuditQuery struct { + ProjectID core.ID `json:"projectId"` + BeforeOffset int64 `json:"beforeOffset,omitempty"` + CutoffOffset int64 `json:"cutoffOffset,omitempty"` + Limit int `json:"limit,omitempty"` + // Linked* select one exact committed link. This avoids enumerating hidden + // private links or leaking their counts before cursor policy is complete. + LinkedSourceKind string `json:"linkedSourceKind,omitempty"` + LinkedSourceID string `json:"linkedSourceId,omitempty"` + LinkedHostOffset int64 `json:"linkedHostOffset,omitempty"` +} + +// HostAuditReceipt is a server-projected receipt, never a raw audit payload. +// ActorName is the name recorded with this command; old rows have no snapshot. +type HostAuditReceipt struct { + SourceOffset int64 `json:"sourceOffset"` + Time time.Time `json:"time"` + ActorID string `json:"actorId,omitempty"` + ActorName string `json:"actorName,omitempty"` + ActorNameStatus string `json:"actorNameStatus"` + CommandID string `json:"commandId"` + State string `json:"state"` + OperationID string `json:"operationId,omitempty"` + ConversationID string `json:"conversationId"` +} + +// ProjectAuditCoverage states the exact scope of this first X2 slice. Missing +// host sources and old unbound rows cannot be inferred from owner-wide audit. +type ProjectAuditCoverage struct { + CommandAudit string `json:"commandAudit"` + CutoffConsistency string `json:"cutoffConsistency"` + CrossMember string `json:"crossMember"` + LegacyProjectBinding string `json:"legacyProjectBinding"` + RunRelations string `json:"runRelations"` + ModelReceipts string `json:"modelReceipts"` + BackgroundReceipts string `json:"backgroundReceipts"` + ResourceBodies string `json:"resourceBodies"` +} + +type ProjectAuditPage struct { + Receipts []HostAuditReceipt `json:"receipts"` + NextOffset int64 `json:"nextOffset,omitempty"` + CutoffOffset int64 `json:"cutoffOffset"` + Coverage ProjectAuditCoverage `json:"coverage"` +} + +// ProjectAuditHost is an optional, actor-bound read capability. A consumer must +// treat a failed type assertion as unsupported; an authorized page still reports +// partial coverage explicitly. +type ProjectAuditHost interface { + QueryProjectAudit(context.Context, ProjectAuditQuery) (ProjectAuditPage, error) +} diff --git a/pkg/extension/project_context.go b/pkg/extension/project_context.go index 2010b0c..9c207bb 100644 --- a/pkg/extension/project_context.go +++ b/pkg/extension/project_context.go @@ -83,6 +83,27 @@ type ProjectContextProvider interface { ReadEvidence(context.Context, ProjectEvidenceRequest) (ProjectEvidence, error) } +// ProjectContextSourceRecheckRequest names only sources already present in a +// trusted model-input receipt. Scope identity is rebuilt by Backend from the +// persisted run; no prompt, output body, or caller-chosen owner is accepted. +type ProjectContextSourceRecheckRequest struct { + Scope ProjectContextRequest + Included []ProjectContextManifestEntry + OfferedReferences []ProjectEvidenceRef + ReadEvidence []ProjectContextManifestEntry +} + +// ProjectContextSourceRecheckResult contains current authorized source text +// only for transient Backend hash verification. It is never stored as a new +// model receipt. Every requested source/ref must be present in Bundle. +type ProjectContextSourceRecheckResult struct{ Bundle ProjectContextBundle } + +// ProjectContextSourceRechecker is an optional method of the same registered +// ProjectContextProvider. Project workflow reuse fails closed without it. +type ProjectContextSourceRechecker interface { + Recheck(context.Context, ProjectContextSourceRecheckRequest) (ProjectContextSourceRecheckResult, error) +} + // WorkCompletionRequest gives the generation goal and optional target. It has // no owner/project field; both are resolved from the dispatch host's stored // conversation immediately before the model call. diff --git a/pkg/extension/project_work.go b/pkg/extension/project_work.go new file mode 100644 index 0000000..e397525 --- /dev/null +++ b/pkg/extension/project_work.go @@ -0,0 +1,130 @@ +package extension + +import ( + "context" + "time" + + core "agent-system-core" +) + +// WorkObservation is built by the host from a committed message or an actual +// retrieval, tool, or visible analysis result. Callers and models cannot supply +// Actor, ProjectID, source identity, or the source authorization epoch. +// RunID may be empty for a newly committed user message or chat command. +type WorkObservation struct { + EventID string `json:"eventId"` + Phase string `json:"phase"` + Actor string `json:"actor"` + Owner string `json:"owner"` + ProjectID core.ID `json:"projectId"` + ConversationID core.ID `json:"conversationId,omitempty"` + RunID core.ID `json:"runId,omitempty"` + SourceKind string `json:"sourceKind"` + SourceID string `json:"sourceId"` + SourceVersion string `json:"sourceVersion"` + SourceSHA256 string `json:"sourceSha256"` + PartIndex int `json:"partIndex"` + SpanStart int `json:"spanStart"` // UTF-8 byte offset in the exact source + SpanEnd int `json:"spanEnd"` + Text string `json:"text,omitempty"` // only the actual authorized fragment + Scope string `json:"scope"` // private|project|controlled + Delivery string `json:"delivery"` // complete|summary-only|partial + DeliveryReason string `json:"deliveryReason,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` + AuthorizationEpoch string `json:"authorizationEpoch"` + ObservedAt time.Time `json:"observedAt"` +} + +type WorkObservationState string + +const ( + WorkObservationAccepted WorkObservationState = "accepted" // durable intake, analysis may be pending + WorkObservationPending WorkObservationState = "pending" + WorkObservationDenied WorkObservationState = "denied" + WorkObservationPartial WorkObservationState = "partial" +) + +type WorkObservationResult struct { + ReceiptID string `json:"receiptId"` + State WorkObservationState `json:"state"` + Reason string `json:"reason,omitempty"` +} + +// ProjectWorkSource is one exact part consumed by the pending action. A hash +// mismatch or missing part must block Preflight rather than use a stale receipt. +type ProjectWorkSource struct { + EventID string `json:"eventId"` + SourceKind string `json:"sourceKind"` + SourceID string `json:"sourceId"` + SourceVersion string `json:"sourceVersion"` + SourceSHA256 string `json:"sourceSha256"` + PartIndex int `json:"partIndex"` +} + +// ProjectWorkAction is constructed by the host immediately before model use, +// project command dispatch, or another project-affecting action. ProjectID is +// checked against the stored conversation or run, never trusted from Params. +type ProjectWorkAction struct { + Actor string `json:"actor"` + Owner string `json:"owner"` + ProjectID core.ID `json:"projectId"` + ConversationID core.ID `json:"conversationId,omitempty"` + RunID core.ID `json:"runId,omitempty"` + Action string `json:"action"` + TargetID string `json:"targetId,omitempty"` + TargetVersion string `json:"targetVersion,omitempty"` + Manifest []ProjectWorkSource `json:"manifest"` + SourceWatermark string `json:"sourceWatermark"` +} + +type ProjectWorkDecisionState string + +const ( + ProjectWorkProceed ProjectWorkDecisionState = "proceed" + ProjectWorkClarify ProjectWorkDecisionState = "clarify" + ProjectWorkPending ProjectWorkDecisionState = "pending" + ProjectWorkDenied ProjectWorkDecisionState = "denied" +) + +// ProjectWorkDecision is a domain decision, not a replacement for a controlled +// document's separate one-time human approval. A successful Observe ACK cannot +// be interpreted as ProjectWorkProceed. +type ProjectWorkDecision struct { + State ProjectWorkDecisionState `json:"state"` + DecisionID string `json:"decisionId"` + RiskID string `json:"riskId,omitempty"` + RiskRevision string `json:"riskRevision,omitempty"` + SourceWatermark string `json:"sourceWatermark,omitempty"` + Reason string `json:"reason,omitempty"` + Question string `json:"question,omitempty"` + Options []string `json:"options,omitempty"` +} + +// ProjectRiskAnswer comes from a current authenticated session and persisted +// risk card. An answer is applied only after the provider commits a CAS receipt. +type ProjectRiskAnswer struct { + Actor string `json:"actor"` + Owner string `json:"owner"` + ProjectID core.ID `json:"projectId"` + RiskID string `json:"riskId"` + RiskRevision string `json:"riskRevision"` + Answer string `json:"answer"` + IdempotencyKey string `json:"idempotencyKey"` +} + +// ProjectWorkObserver is owned by the project domain. Backend must check a +// returned decision against the exact current source manifest and must fail +// closed on missing provider, timeout, or partial analysis when project facts +// are required. RDM remains the sole persistent risk-state owner. +type ProjectWorkObserver struct { + Observe func(context.Context, WorkObservation) (WorkObservationResult, error) + Preflight func(context.Context, ProjectWorkAction) (ProjectWorkDecision, error) + Respond func(context.Context, ProjectRiskAnswer) (ProjectWorkDecision, error) +} + +// ProjectWorkObservationRegistry is optional so older in-process extensions do +// not acquire a mandatory Registry method. Declarative plugins cannot register +// a callback. The host accepts one trusted RDM provider. +type ProjectWorkObservationRegistry interface { + RegisterProjectWorkObserver(ProjectWorkObserver) error +} diff --git a/pkg/extension/projects.go b/pkg/extension/projects.go index d0f5042..29bf9fa 100644 --- a/pkg/extension/projects.go +++ b/pkg/extension/projects.go @@ -7,7 +7,9 @@ import ( // ProjectHost is an optional actor-scoped host projection capability. Metadata // can be shared by an authorizer; registered-lifecycle metadata masks credentials. -// OwnedProject remains an owner-only management lookup; mutations remain owner-only. +// OwnedProject remains an owner-only management lookup. A request-bound +// delegated projection may carry a separate real operator and fixed creator; +// its final Store transaction checks both accounts and the persisted owner. type ProjectHost interface { Owner() string OwnedProjects() ([]core.Project, error) @@ -18,6 +20,14 @@ type ProjectHost interface { ProjectNameAvailable(name, exceptID string) (bool, error) } +// DelegatedProjectProjectionHost is an optional capability of a current, +// authenticated RDM request Host. It derives a single-project creator +// projection while retaining the original operator for final account fencing. +// A globally reconstructed ScopedHost does not implement this capability. +type DelegatedProjectProjectionHost interface { + ProjectProjectionForCreator(creator, projectID string) (ProjectHost, error) +} + // ProjectLifecycle supplies the domain's public project lifecycle and live work // authorization. Each Host is scoped to the authenticated actor. Errors with a // StatusCode() int method may report 400, 403, 404, 409, or 503 to HTTP clients. diff --git a/pkg/extension/regulations.go b/pkg/extension/regulations.go new file mode 100644 index 0000000..21bf58b --- /dev/null +++ b/pkg/extension/regulations.go @@ -0,0 +1,209 @@ +package extension + +import ( + "context" + "errors" + "time" +) + +// RegulationsSchemaVersion is the only request schema the host accepts for +// EXT-20; any other value is rejected before reaching the provider. +const RegulationsSchemaVersion = 1 + +const ( + RegulationsStateOK = "ok" + RegulationsStateEmpty = "empty" + RegulationsStateDegraded = "degraded" + RegulationsStateUnavailable = "unavailable" + + RegulationDisclosureFull = "full" + RegulationDisclosureExcerpt = "excerpt" + RegulationDisclosureMetadataOnly = "metadata_only" + RegulationDisclosureWithheld = "withheld" + + WebSearchStateOK = "ok" + WebSearchStateEmpty = "empty" + WebSearchStateUnavailable = "unavailable" + WebSearchStateRateLimited = "rate_limited" + WebSearchStateRejected = "rejected" + WebSearchStateError = "error" + + WebCaptureStateCaptured = "captured" + WebCaptureStateRejected = "rejected" + WebCaptureStateFailed = "failed" + + WebRetentionFull = "full" + WebRetentionExcerpt = "excerpt" + WebRetentionMetadataOnly = "metadata_only" +) + +// ErrRegulationsUnavailable is returned by every RegulationsHost method when no +// trusted provider is registered. Callers must not fall back to KnowledgeSearch. +var ErrRegulationsUnavailable = errors.New("extension: regulations provider unavailable") + +// RegulationsCaller is filled by the host from the trusted dispatch context. +// It deliberately has no project ID: shared regulation access neither widens +// nor narrows with a project. +type RegulationsCaller struct { + PluginID string `json:"pluginId"` + Actor string `json:"actor,omitempty"` + Owner string `json:"owner"` +} + +// RegulationsSearchRequest searches the controlled regulation corpus. An empty +// CorpusRevision means the current published revision; the result echoes the +// revision actually searched. +type RegulationsSearchRequest struct { + SchemaVersion int `json:"schemaVersion"` + Query string `json:"query"` + CorpusRevision string `json:"corpusRevision,omitempty"` + Kinds []string `json:"kinds,omitempty"` + CurrentOnly bool `json:"currentOnly,omitempty"` + DocNo string `json:"docNo,omitempty"` + ClauseNo string `json:"clauseNo,omitempty"` + Limit int `json:"limit,omitempty"` +} + +type RegulationsSearchResult struct { + CorpusRevision string `json:"corpusRevision"` + State string `json:"state"` // ok|empty|degraded|unavailable + DegradedReasons []string `json:"degradedReasons,omitempty"` + Hits []RegulationHit `json:"hits"` +} + +type RegulationHit struct { + RegulationID string `json:"regulationId"` + EditionID string `json:"editionId"` + NodeID string `json:"nodeId"` + DocNo string `json:"docNo,omitempty"` + Title string `json:"title,omitempty"` + EditionLabel string `json:"editionLabel,omitempty"` + LegalStatus string `json:"legalStatus,omitempty"` + ClauseNo string `json:"clauseNo,omitempty"` + HeadingPath []string `json:"headingPath,omitempty"` + Excerpt string `json:"excerpt,omitempty"` + ExcerptStart int `json:"excerptStart,omitempty"` + ExcerptEnd int `json:"excerptEnd,omitempty"` + NodeSHA256 string `json:"nodeSha256,omitempty"` + SourceAssetSHA256 string `json:"sourceAssetSha256,omitempty"` + PageStart int `json:"pageStart,omitempty"` + PageEnd int `json:"pageEnd,omitempty"` + Disclosure string `json:"disclosure"` // full|excerpt|metadata_only|withheld + WithheldReason string `json:"withheldReason,omitempty"` + IsSummary bool `json:"isSummary,omitempty"` + MatchKinds []string `json:"matchKinds,omitempty"` + Score float64 `json:"score,omitempty"` +} + +// RegulationEvidenceRequest retrieves the original text span of a node pinned +// to a corpus revision. +type RegulationEvidenceRequest struct { + SchemaVersion int `json:"schemaVersion"` + CorpusRevision string `json:"corpusRevision"` + NodeID string `json:"nodeId"` + Start int `json:"start,omitempty"` + End int `json:"end,omitempty"` +} + +type RegulationEvidence struct { + CorpusRevision string `json:"corpusRevision"` + RegulationID string `json:"regulationId,omitempty"` + EditionID string `json:"editionId,omitempty"` + NodeID string `json:"nodeId"` + ClauseNo string `json:"clauseNo,omitempty"` + Excerpt string `json:"excerpt,omitempty"` + Start int `json:"start,omitempty"` + End int `json:"end,omitempty"` + NodeSHA256 string `json:"nodeSha256,omitempty"` + SourceAssetSHA256 string `json:"sourceAssetSha256,omitempty"` + PageStart int `json:"pageStart,omitempty"` + PageEnd int `json:"pageEnd,omitempty"` + Disclosure string `json:"disclosure"` + WithheldReason string `json:"withheldReason,omitempty"` + HashVerified bool `json:"hashVerified"` +} + +// WebSearchRequest performs a real web search. Exclusions are caller-declared +// confidential terms (project names, numbers, people); a query containing any +// of them is rejected by the host before the provider sees it. +type WebSearchRequest struct { + SchemaVersion int `json:"schemaVersion"` + Query string `json:"query"` + Exclusions []string `json:"exclusions,omitempty"` + PreferOfficial bool `json:"preferOfficial,omitempty"` + Limit int `json:"limit,omitempty"` +} + +type WebSearchResult struct { + Provider string `json:"provider,omitempty"` + SentQuery string `json:"sentQuery,omitempty"` + SearchedAt time.Time `json:"searchedAt"` + State string `json:"state"` // ok|empty|unavailable|rate_limited|rejected|error + Reason string `json:"reason,omitempty"` + Results []WebSearchHit `json:"results,omitempty"` +} + +type WebSearchHit struct { + Rank int `json:"rank"` + URL string `json:"url"` + Title string `json:"title,omitempty"` + Snippet string `json:"snippet,omitempty"` + Publisher string `json:"publisher,omitempty"` + Official bool `json:"official,omitempty"` +} + +// WebCaptureRequest fetches a page found by a prior SearchWeb call. +type WebCaptureRequest struct { + SchemaVersion int `json:"schemaVersion"` + URL string `json:"url"` + SearchProvider string `json:"searchProvider,omitempty"` + SearchQuery string `json:"searchQuery,omitempty"` + SearchRank int `json:"searchRank,omitempty"` +} + +type WebCapture struct { + CaptureID string `json:"captureId,omitempty"` + FinalURL string `json:"finalUrl,omitempty"` + FetchedAt time.Time `json:"fetchedAt"` + ContentType string `json:"contentType,omitempty"` + ByteSize int64 `json:"byteSize,omitempty"` + SHA256 string `json:"sha256,omitempty"` + Retention string `json:"retention,omitempty"` // full|excerpt|metadata_only + // Text is present only when the source rights allow full retention. + Text string `json:"text,omitempty"` + Candidates []WebClauseCandidate `json:"candidates,omitempty"` + State string `json:"state"` // captured|rejected|failed + Reason string `json:"reason,omitempty"` +} + +type WebClauseCandidate struct { + ClauseNo string `json:"clauseNo,omitempty"` + Excerpt string `json:"excerpt"` + Start int `json:"start"` + End int `json:"end"` +} + +// RegulationsProvider is implemented and registered by the trusted regulations +// knowledge plugin (plugin ID "kbreg"); the host ships no implementation. +type RegulationsProvider interface { + SearchRegulations(context.Context, RegulationsCaller, RegulationsSearchRequest) (RegulationsSearchResult, error) + ResolveRegulationEvidence(context.Context, RegulationsCaller, RegulationEvidenceRequest) (RegulationEvidence, error) + SearchWeb(context.Context, RegulationsCaller, WebSearchRequest) (WebSearchResult, error) + CaptureWeb(context.Context, RegulationsCaller, WebCaptureRequest) (WebCapture, error) +} + +// RegulationsRegistrar is optional on a compiled in-process Registry. Only +// plugin ID "kbreg" may register, and only once. +type RegulationsRegistrar interface { + RegisterRegulationsProvider(RegulationsProvider) error +} + +// RegulationsHost is optional on dispatch Host handles (obtained by type +// assertion). A missing capability means unavailable; callers must not fall +// back to KnowledgeSearch. +type RegulationsHost interface { + SearchRegulations(context.Context, RegulationsSearchRequest) (RegulationsSearchResult, error) + ResolveRegulationEvidence(context.Context, RegulationEvidenceRequest) (RegulationEvidence, error) + SearchWeb(context.Context, WebSearchRequest) (WebSearchResult, error) + CaptureWeb(context.Context, WebCaptureRequest) (WebCapture, error) +} diff --git a/pkg/extension/regulations_test.go b/pkg/extension/regulations_test.go new file mode 100644 index 0000000..f0bd49a --- /dev/null +++ b/pkg/extension/regulations_test.go @@ -0,0 +1,78 @@ +package extension_test + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "agent-system-backend/pkg/extension" +) + +// publicRegulationsProvider is a downstream provider built only from the +// public extension surface. +type publicRegulationsProvider struct{} + +func (publicRegulationsProvider) SearchRegulations(context.Context, extension.RegulationsCaller, extension.RegulationsSearchRequest) (extension.RegulationsSearchResult, error) { + return extension.RegulationsSearchResult{State: extension.RegulationsStateEmpty}, nil +} + +func (publicRegulationsProvider) ResolveRegulationEvidence(context.Context, extension.RegulationsCaller, extension.RegulationEvidenceRequest) (extension.RegulationEvidence, error) { + return extension.RegulationEvidence{Disclosure: extension.RegulationDisclosureMetadataOnly}, nil +} + +func (publicRegulationsProvider) SearchWeb(context.Context, extension.RegulationsCaller, extension.WebSearchRequest) (extension.WebSearchResult, error) { + return extension.WebSearchResult{State: extension.WebSearchStateUnavailable}, nil +} + +func (publicRegulationsProvider) CaptureWeb(context.Context, extension.RegulationsCaller, extension.WebCaptureRequest) (extension.WebCapture, error) { + return extension.WebCapture{State: extension.WebCaptureStateFailed}, nil +} + +var _ extension.RegulationsProvider = publicRegulationsProvider{} + +func TestRegulationsContractIsOptional(t *testing.T) { + var host extension.Host + if _, ok := host.(extension.RegulationsHost); ok { + t.Fatal("nil host must not expose regulations") + } + var registry extension.Registry + if _, ok := registry.(extension.RegulationsRegistrar); ok { + t.Fatal("nil registry must not expose regulations registration") + } + if extension.RegulationsSchemaVersion != 1 { + t.Fatalf("schema version = %d", extension.RegulationsSchemaVersion) + } + if extension.ErrRegulationsUnavailable == nil { + t.Fatal("unavailable error must be defined") + } +} + +func TestRegulationsWireNames(t *testing.T) { + states := []string{extension.RegulationsStateOK, extension.RegulationsStateEmpty, extension.RegulationsStateDegraded, + extension.RegulationsStateUnavailable, extension.RegulationDisclosureFull, extension.RegulationDisclosureExcerpt, + extension.RegulationDisclosureMetadataOnly, extension.RegulationDisclosureWithheld, extension.WebSearchStateOK, + extension.WebSearchStateEmpty, extension.WebSearchStateUnavailable, extension.WebSearchStateRateLimited, + extension.WebSearchStateRejected, extension.WebSearchStateError, extension.WebCaptureStateCaptured, + extension.WebCaptureStateRejected, extension.WebCaptureStateFailed, extension.WebRetentionFull, + extension.WebRetentionExcerpt, extension.WebRetentionMetadataOnly} + want := "ok empty degraded unavailable full excerpt metadata_only withheld ok empty unavailable rate_limited " + + "rejected error captured rejected failed full excerpt metadata_only" + if got := strings.Join(states, " "); got != want { + t.Fatalf("wire states = %q", got) + } + raw, err := json.Marshal(extension.WebSearchRequest{SchemaVersion: extension.RegulationsSchemaVersion, Query: "q"}) + if err != nil { + t.Fatal(err) + } + if string(raw) != `{"schemaVersion":1,"query":"q"}` { + t.Fatalf("optional fields not omitted: %s", raw) + } + raw, err = json.Marshal(extension.RegulationsCaller{PluginID: "rdm", Owner: "alice"}) + if err != nil { + t.Fatal(err) + } + if strings.Contains(strings.ToLower(string(raw)), "project") { + t.Fatalf("caller must not carry a project: %s", raw) + } +} diff --git a/pkg/extension/role_policy.go b/pkg/extension/role_policy.go new file mode 100644 index 0000000..4fe1dd6 --- /dev/null +++ b/pkg/extension/role_policy.go @@ -0,0 +1,145 @@ +package extension + +import "context" + +const RolePolicySchemaVersion = 1 + +type RolePermission struct { + Allowed bool `json:"allowed"` + CapAllowed bool `json:"capAllowed"` +} + +// RolePolicySnapshot contains authorization metadata only. It intentionally +// excludes profile contact fields and credentials. +type RolePolicySnapshot struct { + SchemaVersion int `json:"schemaVersion"` + UserID string `json:"userId"` + Owner string `json:"owner"` + AccountEnabled bool `json:"accountEnabled"` + BindingID string `json:"bindingId,omitempty"` + RoleID string `json:"roleId,omitempty"` + RoleName string `json:"roleName,omitempty"` + RoleDescription string `json:"roleDescription,omitempty"` + RoleEnabled bool `json:"roleEnabled"` + RoleVersion int64 `json:"roleVersion,omitempty"` + AuthorityEpoch int64 `json:"authorityEpoch"` + Permissions map[string]RolePermission `json:"permissions"` + Linked LinkedCapabilities `json:"linked"` +} + +// LinkedCapabilities is the Backend-computed project.create link for one +// account. Actions apply only to projects the account created; Source names +// where project.create comes from (bootstrap-admin, system-grant or role) and +// Conflicts lists linked actions that the account's enabled role caps off. +type LinkedCapabilities struct { + ProjectCreate bool `json:"projectCreate"` + Source string `json:"source,omitempty"` + Actions []string `json:"actions"` + Conflicts []string `json:"conflicts,omitempty"` +} + +type RoleActionDecision struct { + ActionID string `json:"actionId"` + RoleID string `json:"roleId,omitempty"` + Allowed bool `json:"allowed"` + CapAllowed bool `json:"capAllowed"` + AuthorityEpoch int64 `json:"authorityEpoch"` + Reason string `json:"reason,omitempty"` +} + +// RolePolicyProvider is optional on a trusted dispatch Host. Its actor is +// bound by the Backend; callers cannot supply or replace that actor. +type RolePolicyProvider interface { + CurrentRolePolicy(context.Context) (RolePolicySnapshot, error) + RolePolicyForUser(context.Context, string) (RolePolicySnapshot, error) + RolePolicyForOwner(context.Context, string) (RolePolicySnapshot, error) + CheckRoleAction(context.Context, string, string) (RoleActionDecision, error) +} + +// RoleOption is the public directory projection of one enabled role: a stable +// identifier and its display name only. It never carries permissions, +// bindings or counts, so a non-admin caller learns nothing about authority. +type RoleOption struct { + RoleID string `json:"roleId"` + Name string `json:"name"` +} + +// RoleOptionsProvider is optional on a trusted dispatch Host. The Host binds +// the actor; callers still authorize their own domain action before offering +// the options. +type RoleOptionsProvider interface { + ListRoleOptions(context.Context) ([]RoleOption, error) +} + +type SystemActionDecision struct { + ActionID string `json:"actionId"` + Allowed bool `json:"allowed"` + AuthorityEpoch int64 `json:"authorityEpoch"` + Reason string `json:"reason,omitempty"` +} + +// SystemActionProvider exposes only the current Host-bound actor's effective +// system action. It never accepts a caller-supplied actor. +type SystemActionProvider interface { + CheckCurrentSystemAction(context.Context, string) (SystemActionDecision, error) +} + +type RoleAdminRecord struct { + RoleID string `json:"roleId"` + Name string `json:"name"` + Description string `json:"description"` + Enabled bool `json:"enabled"` + RoleVersion int64 `json:"roleVersion"` + BindingCount int `json:"bindingCount"` + Permissions map[string]RolePermission `json:"permissions"` +} + +type RoleBindingResult struct { + BindingID string `json:"bindingId"` + UserID string `json:"userId"` + RoleID string `json:"roleId"` + UserStateVersion int64 `json:"userStateVersion"` + AuthorityEpoch int64 `json:"authorityEpoch"` +} + +type RoleAuthorityImportRole struct { + RoleID string `json:"roleId"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Enabled bool `json:"enabled"` + RoleVersion int64 `json:"roleVersion"` + Permissions map[string]RolePermission `json:"permissions"` + Owners []string `json:"owners"` +} + +type RoleAuthorityImport struct { + SourceSchemaVersion int `json:"sourceSchemaVersion"` + SourceSHA256 string `json:"sourceSha256"` + Roles []RoleAuthorityImportRole `json:"roles"` +} + +type RoleAuthorityImportResult struct { + AuthorityEpoch int64 `json:"authorityEpoch"` + ImportedRoles int `json:"importedRoles"` + ImportedUsers int `json:"importedUsers"` +} + +type RoleAuthorityImportProjection struct { + SourceSHA256 string `json:"sourceSha256"` + AuthorityEpoch int64 `json:"authorityEpoch"` + Roles []RoleAdminRecord `json:"roles"` + Bindings map[string]string `json:"bindings"` // stable owner -> roleId +} + +// RolePolicyAdmin is a temporary compatibility surface for trusted RDM +// commands while the UI moves to Backend HTTP APIs. The Host binds the actor. +type RolePolicyAdmin interface { + ListRoles(context.Context) ([]RoleAdminRecord, error) + CreateRole(context.Context, string, string) (RoleAdminRecord, error) + SetRolePermission(context.Context, string, string, bool, bool, string) (RoleAdminRecord, error) + AssignRoleByOwner(context.Context, string, string, string) (RoleBindingResult, error) + SetRoleEnabled(context.Context, string, bool) (RoleAdminRecord, error) + ImportRoleAuthority(context.Context, RoleAuthorityImport, string) (RoleAuthorityImportResult, error) + ReadRoleAuthorityImport(context.Context, string) (RoleAuthorityImportProjection, error) + ActivateRoleAuthorityImport(context.Context, string, int64) (RoleAuthorityImportResult, error) +} diff --git a/pkg/extension/surface.txt b/pkg/extension/surface.txt index c3a08ec..6c140a6 100644 --- a/pkg/extension/surface.txt +++ b/pkg/extension/surface.txt @@ -1,3 +1,23 @@ +AccountDirectoryEntry +AccountDirectoryLookup +AccountDirectoryModeExact +AccountDirectoryModeFuzzy +AccountDirectoryPage +AccountDirectoryProvider +AccountDirectoryProvider.LookupAccountDirectory +AccountDirectoryProvider.LookupAccountDirectoryOwner +AccountDirectoryProvider.QueryAccountDirectory +AccountDirectoryPurposeDocumentDuties +AccountDirectoryPurposeProjectMembers +AccountDirectoryPurposeProjectRoster +AccountDirectoryPurposeSystemUsers +AccountDirectoryQuery +AccountDirectoryRegistrar +AccountDirectoryRegistrar.RegisterAccountDirectoryScopeResolver +AccountDirectorySchemaVersion +AccountDirectoryScope +AccountDirectoryScopeRequest +AccountDirectoryScopeResolver AccountEntry AccountLister AccountLister.ListAccounts @@ -6,8 +26,33 @@ AccountSearcher.SearchAccounts AdminProvider AdminProvider.IsAdmin AdminZoneSpec +CLIHook +CapabilityActionExplain +CapabilityExplain +CapabilityExplainProvider +CapabilityExplainProvider.CurrentCapabilityExplain +CapabilityExplainProvider.CurrentDocumentQualify +CapabilityExplainProvider.CurrentMemberBasicCollaborate +CapabilityExplainSchemaVersion +CapabilityScopeMemberProjects +CapabilityScopeOwnProjects +CapabilityScopeSystem +CapabilitySourceBootstrapAdmin +CapabilitySourceRole +CapabilitySourceSystemGrant +CapabilitySubject +CategorizeWorkFailure Compiled +DelegatedProjectProjectionHost +DelegatedProjectProjectionHost.ProjectProjectionForCreator +DocumentDutyQualify +DocumentQualify ErrNotLinked +ErrProjectAuditDenied +ErrProjectAuditLinkConflict +ErrProjectAuditPending +ErrProjectAuditUnsupported +ErrRegulationsUnavailable Handler Host Host.ArtifactContent @@ -25,13 +70,48 @@ Host.ParseDocument Host.PluginID Host.TaskCreate Host.TaskUpdate +HostAuditReceipt HostClient +HumanDecisionAudienceStructure +HumanDecisionBinding +HumanDecisionCard +HumanDecisionProof +HumanDecisionVerifier +HumanDecisionVerifier.IssueHumanDecision +HumanDecisionVerifier.RevokeHumanDecision +HumanDecisionVerifier.VerifyHumanDecision InProc IntentSpec +LinkedCapabilities Main +MemberBasicCollaborate +MergeHostOptions NewHostClient NewScopedHost Options +PrepareCompiled +ProjectAuditAuthorizer +ProjectAuditCoverage +ProjectAuditDisclosure +ProjectAuditDisclosureAuthorizer +ProjectAuditHost +ProjectAuditHost.QueryProjectAudit +ProjectAuditLinkProof +ProjectAuditLinkReceipt +ProjectAuditLinkRegistry +ProjectAuditLinkRegistry.RegisterProjectAuditDisclosureAuthorizer +ProjectAuditLinkRegistry.RegisterProjectAuditLinkVerifier +ProjectAuditLinkRequest +ProjectAuditLinkVerifier +ProjectAuditLinkedResource +ProjectAuditPage +ProjectAuditPublisher +ProjectAuditPublisher.PublishProjectAuditLink +ProjectAuditPublisherFactory +ProjectAuditPublisherFactory.ForOwner +ProjectAuditQuery +ProjectAuditRegistry +ProjectAuditRegistry.RegisterProjectAuditAuthorizer ProjectContextBundle ProjectContextDenied ProjectContextIncomplete @@ -46,6 +126,10 @@ ProjectContextReady ProjectContextRegistry ProjectContextRegistry.RegisterProjectContextProvider ProjectContextRequest +ProjectContextSourceRecheckRequest +ProjectContextSourceRecheckResult +ProjectContextSourceRechecker +ProjectContextSourceRechecker.Recheck ProjectContextStatus ProjectEvidence ProjectEvidenceRef @@ -61,6 +145,19 @@ ProjectHost.RenameOwnedProject ProjectLifecycle ProjectLifecycleRegistry ProjectLifecycleRegistry.RegisterProjectLifecycle +ProjectRiskAnswer +ProjectWorkAction +ProjectWorkClarify +ProjectWorkDecision +ProjectWorkDecisionState +ProjectWorkDenied +ProjectWorkObservationRegistry +ProjectWorkObservationRegistry.RegisterProjectWorkObserver +ProjectWorkObserver +ProjectWorkPending +ProjectWorkProceed +ProjectWorkSource +RegisterCLIFlags RegisterCompiled Registry Registry.AddAdminZone @@ -71,11 +168,66 @@ Registry.AddSkill Registry.Note Registry.RegisterResourceAuthorizer Registry.RegisterResourceAuthorizerV2 +RegulationDisclosureExcerpt +RegulationDisclosureFull +RegulationDisclosureMetadataOnly +RegulationDisclosureWithheld +RegulationEvidence +RegulationEvidenceRequest +RegulationHit +RegulationsCaller +RegulationsHost +RegulationsHost.CaptureWeb +RegulationsHost.ResolveRegulationEvidence +RegulationsHost.SearchRegulations +RegulationsHost.SearchWeb +RegulationsProvider +RegulationsProvider.CaptureWeb +RegulationsProvider.ResolveRegulationEvidence +RegulationsProvider.SearchRegulations +RegulationsProvider.SearchWeb +RegulationsRegistrar +RegulationsRegistrar.RegisterRegulationsProvider +RegulationsSchemaVersion +RegulationsSearchRequest +RegulationsSearchResult +RegulationsStateDegraded +RegulationsStateEmpty +RegulationsStateOK +RegulationsStateUnavailable Request ResetCompiled ResourceAuthorizer ResourceAuthorizerV2 ResourceAuthzQuery +RoleActionDecision +RoleAdminRecord +RoleAuthorityImport +RoleAuthorityImportProjection +RoleAuthorityImportResult +RoleAuthorityImportRole +RoleBindingResult +RoleOption +RoleOptionsProvider +RoleOptionsProvider.ListRoleOptions +RolePermission +RolePolicyAdmin +RolePolicyAdmin.ActivateRoleAuthorityImport +RolePolicyAdmin.AssignRoleByOwner +RolePolicyAdmin.CreateRole +RolePolicyAdmin.ImportRoleAuthority +RolePolicyAdmin.ListRoles +RolePolicyAdmin.ReadRoleAuthorityImport +RolePolicyAdmin.SetRoleEnabled +RolePolicyAdmin.SetRolePermission +RolePolicyProvider +RolePolicyProvider.CheckRoleAction +RolePolicyProvider.CurrentRolePolicy +RolePolicyProvider.RolePolicyForOwner +RolePolicyProvider.RolePolicyForUser +RolePolicySchemaVersion +RolePolicySnapshot +RunCLIHooks SetRunner SetScopedHostFactory SurfaceHost @@ -85,9 +237,45 @@ SurfaceHost.SurfaceList SurfaceHost.SurfaceSave SurfaceSaveRequest SurfaceVersion +SystemActionDecision +SystemActionProvider +SystemActionProvider.CheckCurrentSystemAction +WebCapture +WebCaptureRequest +WebCaptureStateCaptured +WebCaptureStateFailed +WebCaptureStateRejected +WebClauseCandidate +WebRetentionExcerpt +WebRetentionFull +WebRetentionMetadataOnly +WebSearchHit +WebSearchRequest +WebSearchResult +WebSearchStateEmpty +WebSearchStateError +WebSearchStateOK +WebSearchStateRateLimited +WebSearchStateRejected +WebSearchStateUnavailable WorkCompletionHost WorkCompletionHost.CompleteForWork WorkCompletionRequest WorkCompletionResult +WorkFailure +WorkFailureDuplicateName +WorkFailureMappingNotReady +WorkFailurePolicyRejected +WorkFailureServiceUnavailable +WorkObservation +WorkObservationAccepted +WorkObservationDenied +WorkObservationPartial +WorkObservationPending +WorkObservationResult +WorkObservationState +WorkOperationConversation +WorkOperationMemberAdd +WorkOperationProjectCreate WorkTaskHost WorkTaskHost.TaskCreateForWork diff --git a/pkg/identity/identity_test.go b/pkg/identity/identity_test.go index 81a238f..3b00cdb 100644 --- a/pkg/identity/identity_test.go +++ b/pkg/identity/identity_test.go @@ -137,6 +137,9 @@ func TestDSNFileMode(t *testing.T) { if err := os.WriteFile(good, []byte("postgres://example\n"), 0o600); err != nil { t.Fatal(err) } + if err := os.Chmod(good, 0o600); err != nil { + t.Fatal(err) + } got, err := ResolveDSN("", good) if err != nil || got != "postgres://example" { t.Fatalf("0600 文件:%q %v", got, err) @@ -145,6 +148,9 @@ func TestDSNFileMode(t *testing.T) { if err := os.WriteFile(loose, []byte("postgres://x"), 0o644); err != nil { t.Fatal(err) } + if err := os.Chmod(loose, 0o644); err != nil { + t.Fatal(err) + } if _, err := ResolveDSN("", loose); err == nil { t.Fatal("0644 的 dsn 文件必须拒绝") } diff --git a/policy/guidelines-adoption.md b/policy/guidelines-adoption.md new file mode 100644 index 0000000..93fd575 --- /dev/null +++ b/policy/guidelines-adoption.md @@ -0,0 +1,37 @@ +# 通用规范采纳记录 + +- 项目/负责人:Agent-System-Backend / weichen +- 状态:active +- 规范位置:`deps/development-guidelines` +- 规范性文档目录:`policy/`(项目规则与制度放此处,含 `policy/modules/` 模块卡;`policy/README.md` 为既有项目规范索引) +- 过程文档位置:`.task/`(任务卡,随 PR 维护);任务主记录在 PR 描述中 +- 版本权威:本仓 gitlink `deps/development-guidelines` @ `2688fd2be6a5`;可读标签:无 +- 批准/生效依据:`feature/governance-adoption` PR(Agent-System-Backend) +- 替代范围和迁移说明:无 + +| 规范/工作流 | 适用条件与范围 | 采纳或不适用理由 | 执行方式/证据 | +|---|---|---|---| +| core/baseline.md | 日常工作 | 采纳:本仓日常开发的基线规则,AGENTS.md 已引用 | manual | +| workflows/delivery.md、debugging.md、handoff.md | 交付、排障与交接 | 采纳:AGENTS.md 已引用交付、排障与交接入口 | manual | +| standards/collaboration.md | 并行共同起点、两级分支、节点同步与陈旧候选拒绝 | 采纳:AGENTS.md 任务启动门禁与验收/合入前同步要求 | manual;暂未登记 enforced,有真实平台负例后再登记 | +| standards/merge-queue.md | 多个就绪任务的最终同步、CI、Review 与合入调度 | 不适用:本仓当前无多任务并行合入队列,合入仍按单 PR 经 CI 与 Review 进行 | 不适用 | +| standards/agent-execution.md | 工程 Agent CLI 执行路由、会话隔离与受控集成 | 凡允许 AI 协助必须采纳;配置见 AGENT-EXECUTION.md | 执行方式:AGENT-EXECUTION.md + scripts/agent-exec(validate / admit);证据:governance check + 正负例(.task/feature-governance-adoption.md) | +| standards/verification.md | 验证与结论 | 采纳:PROJECT.md「验证」节为本仓验证命令入口 | `go vet ./...`、`go test -count=1 ./...`(.gitea/workflows/ci.yml);`scripts/agent-exec validate --repo .`、`scripts/dm governance check --repo .`(.gitea/workflows/governance.yml) | +| workflows/design-verification.md、standards/testability.md、standards/failure-containment.md | 功能增量的设计/验证、模块测试与故障控制 | 采纳:模块隔离档位与升级条件由 policy/modules/ 模块卡记录 | manual;证据:policy/modules/*.md、PROJECT.md「验证」节 | + +## 项目差异 + +| 原规则/章节 | 项目采用方式 | 原因与风险 | 负责人/复查条件 | +|---|---|---|---| +| 任务启动门禁(scripts/task start) | 保留 AGENTS.md 既有的 Dev 仓 `make precheck` / `make task-start` 流程;`scripts/task` 作为共享入口并存 | 本仓作为子仓随父仓编排,启动动作由 Dev 仓承担;两条入口并存的风险是启动记录分散 | weichen;在本仓独立验证 `scripts/task start` 后复查并统一 | + +默认做法差异说明理由;硬边界冲突须有明确有效决策,不能用此表自授权。建议不采用无需登记。 + +## 本次升级或首次接入验证 + +- 入口与相对链接:AGENTS.md、PROJECT.md、AGENT-EXECUTION.md、policy/guidelines-adoption.md 中的 `deps/development-guidelines` 链接指向 gitlink 固定版本。 +- 版本获取:`dm-server governance init --adopt`(DM 固定版本 `bd3a7de`)检出 `deps/development-guidelines` @ `2688fd2be6a52c0bf110e7e6366b6f291ab5e4eb`,检出 `deps/development-manager` @ `bd3a7de57a3a9e1a4495b94c5fa5c08d71e7be8c`。 +- 冲突检查:init 以 adopt 模式运行,AGENTS.md、PROJECT.md、AGENT-EXECUTION.md 为 kept,未覆盖既有内容;AGENTS.md 仅追加「Agent 执行与新项目准入(强制)」节。 +- 占位符:本记录已替换全部模板占位符。 +- 门禁正负例:见 .task/feature-governance-adoption.md(PR 取证后回填)。 +- 回退:移除 `.gitea/workflows/governance.yml`、`scripts/` 目录、`.task/templates/task-card.md`,并回退两条 gitlink 及 `.gitmodules` 中对应条目;AGENTS.md 中追加的节同步删除。 diff --git a/scripts/agent-exec b/scripts/agent-exec new file mode 100755 index 0000000..7dc09b3 --- /dev/null +++ b/scripts/agent-exec @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# 共享 Agent 执行器薄入口:转调 scripts/dm 的 agent-exec 子命令(validate / run / apply / admit / cleanup)。 +set -euo pipefail +exec "$(dirname "$0")/dm" agent-exec "$@" diff --git a/scripts/dm b/scripts/dm new file mode 100755 index 0000000..a7cdae3 --- /dev/null +++ b/scripts/dm @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# 共享 dm-server 薄入口:读取 deps/development-manager 的 gitlink 钉住提交, +# 按该提交构建并缓存 dm-server,再 exec 其子命令(agent-exec、task、governance)。执行语义与退出码以该二进制为准。 +set -euo pipefail + +root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P) +dm="$root/deps/development-manager" + +die() { echo "dm: $*" >&2; exit 2; } + +# git 钩子会导出仓库本地变量(GIT_DIR、GIT_INDEX_FILE 等),它们会让 `git -C "$dm"` 解析到外层仓库。 +# 所有针对 DM 检出的 git 与 go 调用都在清除这些变量后执行;读取外层 gitlink 与最终 exec 仍保留继承环境。 +dm_local_env="GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_PREFIX GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_COMMON_DIR GIT_GRAFT_FILE GIT_NAMESPACE GIT_CONFIG GIT_CONFIG_PARAMETERS GIT_CONFIG_COUNT GIT_SHALLOW_FILE GIT_IMPLICIT_WORK_TREE $(git rev-parse --local-env-vars 2>/dev/null || true)" +dm_clean_env() { for v in $dm_local_env; do unset "$v"; done; } +dmgit() ( dm_clean_env; git -C "$dm" "$@" ) + +# 1. 钉住版本只来自索引中的 gitlink(mode 160000),不读取工作区文件。 +entry=$(git -C "$root" ls-files -s -- deps/development-manager 2>/dev/null || true) +[ -n "$entry" ] || die "索引中缺少 deps/development-manager 的 gitlink,无法确定钉住的 DM 版本" +read -r mode pin _stage _path <<<"$entry" +[ "$mode" = 160000 ] || die "deps/development-manager 不是 gitlink(mode=$mode)" +[[ "$pin" =~ ^[0-9a-f]{40}$ ]] || die "gitlink 提交格式无效:$pin" + +# 2. 检出必须存在且确为 DM 独立仓库(避免 git -C 误解析到外层仓库),HEAD 必须等于钉住提交。 +[ -e "$dm/.git" ] || die "deps/development-manager 未检出;请按钉住提交 $pin 检出该 gitlink" +top=$(dmgit rev-parse --show-toplevel 2>/dev/null || true) +[ -n "$top" ] && [ "$top" = "$(cd "$dm" && pwd -P)" ] \ + || die "deps/development-manager 不是独立检出;请按钉住提交 $pin 检出该 gitlink" +head=$(dmgit rev-parse HEAD 2>/dev/null || true) +[ "$head" = "$pin" ] \ + || die "deps/development-manager HEAD(${head:-未知})与钉住提交 $pin 不一致;请按钉住提交检出" + +# 3. 检出工作树必须干净(忽略嵌套 submodule 状态)。 +dirty=$(dmgit status --porcelain --ignore-submodules=all) \ + || die "无法读取 deps/development-manager 的工作树状态" +[ -z "$dirty" ] || die "deps/development-manager 存在未提交改动,拒绝运行(请先清理该检出)" + +# 4. 二进制按钉住提交缓存;缺失时在缓存目录内临时构建,完成后原子改名,避免留下半成品。 +cache="${DM_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/development-manager/dm-server}" +bin="$cache/dm-server-$pin" +if [ ! -x "$bin" ]; then + command -v go >/dev/null 2>&1 || die "缺少 go,无法构建 dm-server($pin)" + mkdir -p "$cache" + cache=$(cd "$cache" && pwd -P) + bin="$cache/dm-server-$pin" + work=$(mktemp -d "$cache/build-$pin.XXXXXX") + if ! (dm_clean_env; cd "$dm" && GOWORK=off go build -trimpath -o "$work/dm-server" ./cmd/dm-server); then + rm -rf "$work" + die "构建 dm-server 失败($pin)" + fi + mv -f "$work/dm-server" "$bin" + rm -rf "$work" +fi + +exec "$bin" "$@" diff --git a/scripts/hooks/pre-commit b/scripts/hooks/pre-commit new file mode 100755 index 0000000..47404f4 --- /dev/null +++ b/scripts/hooks/pre-commit @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# 本地检查(pre-commit):集成分支直提拒绝 + 分支命名 + 本地完整性核对。权威准入在 PR(baseline.md:26)。 +branch=$(git rev-parse --abbrev-ref HEAD) +case "$branch" in + main|master) + echo "pre-commit: 拒绝——集成分支 ${branch} 不得直接提交,走 feature 分支 + PR(collaboration.md)" >&2 + exit 1 ;; +esac +echo "$branch" | grep -qE '^(feature|fix|hotfix|release)/[A-Za-z0-9._-]+$' || { + echo "pre-commit: 拒绝——分支名须为 feature|fix|hotfix|release/,当前:${branch}" >&2 + exit 1 +} +root=$(git rev-parse --show-toplevel) +# admit 只是本地完整性/篡改证据(SELF):同一 OS 用户可读取本地 HMAC 密钥并绕过本钩子, +# 它不是 GATE,也不是可信身份。缺失或无效的本地记录仍拒绝;交付信任来自精确 HEAD 的 CI 或独立 REPLAY。 +"$root/scripts/agent-exec" admit --repo "$root" diff --git a/scripts/task b/scripts/task new file mode 100755 index 0000000..fe7d2ca --- /dev/null +++ b/scripts/task @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# 通用任务入口:转调 scripts/dm 的 task 子命令(triage / start / pr-check)。 +set -euo pipefail +exec "$(dirname "$0")/dm" task "$@"