* refactor(entries): let a namespaced entry reader declare its own layout (#879) An entry reader took its directory, file name, activation key and failure wording from its EntryType. It can now declare them as an EntryLayout, defaulting to entryLayout(type), which gives today's values. This lets a later reader read env/secrets.yaml and env/<ns>/secrets.yaml activated by resources.env. No behaviour change: env, hooks, MCP and models resolve and report as before. Part of #875. * refactor(models): share the team values path and stdin reader with a second store getTeamValuesPath takes the store directory (defaulting to models/teams) and keeps its <team>-<hash>.json naming. The piped-stdin reader moves to utils/prompt.ts as readStdin; the --api-key-stdin checks and messages stay in the models command. No behaviour change. Refs #879 (S2), #875 * feat(env): declare team secrets in env/secrets.yaml and show their state (#879) A team repo can declare the secrets its members need, with no value, in env/secrets.yaml and env/<ns>/secrets.yaml (key, optional description and url). They resolve like env.yaml: active through resources.env, a namespace entry replaces the root entry with the same key. The declarations are absent, valid or failed; a broken file fails the secrets only, is reported in secret wording by pull, env list and doctor, and env variables are still delivered. - env list and list env show each declared secret as environment or missing, never its value, --reveal included. - doctor fails "Team secrets can be resolved" on a broken file, and its notes name env/secrets.yaml, not env/env.yaml, for an override or a key repeated in legacy mode (describeEntryNotes takes the reader's layout). - push lists a changed secrets.yaml, in single-repo mode too. - docs/designs/team-secrets.md and .zh-CN.md start here, with the #818 boundary; usage guide, product overview, multi-project, management backend and the admin reference updated. Part of #875. * feat(env): declare a team secret with env add --secret (#879) env add <key> [value] --secret [-d] [--url] [--role|--project] writes env/secrets.yaml or env/<ns>/secrets.yaml with no value; a value is rejected and never printed. env remove removes a declared secret when env.yaml does not set the key, and --secret removes only the declaration for a key both files carry. entryNamespaceFromFlags takes a layout so the --role warning names secrets.yaml. * feat(mcp): keep the entry an earlier pull wrote when a declared secret is missing (#879) The session-start pull inherits the agent's environment, which often lacks the member's shell export, so it removed the MCP entry the interactive pull had written. A server whose only missing variables are declared secrets now keeps its entry and ownership record; it is removed when it leaves mcp.yaml or by removeAll. A failed secrets declaration keeps managed MCP state. * feat(env): keep a member's value for a team secret and resolve it in MCP servers (#879) teamai env set KEY (hidden prompt, --stdin, --from-env VAR) and env unset KEY store a member's value per team repo in ~/.teamai/secrets/teams/, 0600, accepting only keys the scope declares as secrets. ${VAR} in MCP servers resolves a declared secret from that value, then from the member's own environment, which leaves out values a teamai env.sh exported (Conflict 10). A key declared as a secret and set in env.yaml resolves as the secret: its repo value leaves env.sh, the env backup, both list renderers and doctor's expected set (Conflict 13). A failed declaration leaves env.sh and the backup as they are (Conflict 14). env list shows team. * docs(env): document team secret values, storage and MCP resolution (#879) * feat(env): set one value for a team secret for every team on the machine (#879) env set/unset --global keep the value in ~/.teamai/secrets/machine.json. Resolution becomes team value > machine value > the member's environment, for MCP servers and env list (state `global`). In a scope --global still accepts only a declared secret; outside any scope it accepts any valid key and notes that no team declares it yet. * docs(env): document the machine value for team secrets (#879) * feat(mcp): keep project MCP configs with resolved values out of git (#882) A project-scope MCP config that carries a resolved ${VAR} sat untracked and unignored in the business repo, one `git add -A` from committing the token. After the reconcile writes such a file and git would track it, teamai lists its path in the clone's .git/info/exclude inside a marked block (resolved via `git rev-parse --git-path`, so linked worktrees and submodules work). The committed .gitignore is never touched; an ignored path or a config with no resolved value adds nothing; dry runs write nothing. Project-scope uninstall removes only teamai's block, and doctor reports such a file git would still commit. The hook sits after the appliers in reconcileMcpForConfig, outside desiredMcpForTarget/applyJson/applyCodex, so it merges cleanly with #880. * feat(env): name a missing team secret and the command that sets it (#879) Interactive pull, mcp list, env list and doctor print one line per declared secret with no value, naming the MCP servers that use it, `teamai env set KEY` and the declared url. doctor prints it as a note and no longer fails the MCP delivery check for a server skipped only for a missing declared secret. Pull and doctor also note a kept entry that may hold an old value and a key declared as a secret and set in env.yaml. The silent pull prints nothing. The lines come from one envAdvisories() result that later pull notices extend. * docs(env): document the missing-secret advisory in pull, doctor and the lists (#879) * fix(uninstall): count the .git/info/exclude block in the removal plan (#882) The plan now records whether the project's .git/info/exclude holds teamai's MCP config block (gitExcludeBlock). It counts toward isPlanEmpty, is listed in the summary and dry run, and gates the removal, so a plan whose only teamai leftover is the block removes it instead of reporting "Nothing to uninstall". * feat(env): run a command with the directory's team env and secrets via env exec (#879) * docs(env): document env exec, what the command can reach and the ANTHROPIC_* caveat (#879) * fix(tests): isolate Claude config dir from model tests (cherry picked from commite567ac31e5) * fix(env): warn when env add --secret updates a declaration an unknown key keeps undeclared (#879) * feat(env): tell agents at session start which team secrets exist and to run their CLIs through env exec (#879) * docs(env): teach agents to use team secrets through env exec and leave values to the member (#879) * feat(env): resolve plain env variables in one order, with a member override (#879) The environment no longer overrides an env.yaml variable in MCP servers, env exec or env.sh. A member sets their value for a team with `teamai env set KEY`; an interactive pull and doctor say when an export differs and is ignored. env.sh exports a literal override and leaves out a --from-env one; doctor's env delivery check expects the same. * docs(env): document the variable order and the member override (#879) * fix(mcp): keep the exclude block until every MCP config is clean (#882) - uninstall keeps a repository's .git/info/exclude block while a config in it could not be parsed and still holds teamai servers, and warns - uninstall finds and removes the block in nested repositories holding an MCP config, across every worktree - the block opens at the last start marker, so an orphaned start never pairs with a later block's end and takes the member's lines - doctor counts only servers the ownership manifest records, not a member's own server under a team name * fix(env): discount an old env.sh export in every later command (#879) A shell opened before a pull keeps the values env.sh exported then. Only the process that rewrote env.sh discounted them, so the next command read the team's old token as the member's own (Conflict 10). Each env.sh now keeps env.sh.exports.json beside it: SHA-256 of KEY=VALUE for the last 20 values per key, mode 0600, never a value. memberEnvironment discounts any recorded export, which replaces the in-memory snapshot. * fix(uninstall): remove the exclude block only once its files are proven clean (#882) A missing or unreadable managed-mcp.json made the MCP cleanup return early without reporting anything, so uninstall removed the block while .mcp.json still held the resolved token. Uninstall now inspects every path the block protects after the cleanup. The block goes only when each one is missing, or parses and holds none of the team's servers that need a resolved ${VAR}. Anything it cannot check keeps the block, with a warning naming the file. This replaces the leftInPlace report from the reconcile, which the check subsumes. * fix(mcp): protect every project MCP config holding a resolved value (#882) Pull, doctor and uninstall each skipped a case they had not inspected and treated it as safe. Now: - pull lists a config in .git/info/exclude whether or not it delivered to it this run: a disabled or undetected tool's file, a team with automatic delivery off, an unreadable mcp.yaml (any teamai entry counts), a failed write to another tool's config, and a lost ownership manifest (the resolved value found in the file) - doctor checks the same files, including one that does not parse, and counts a git error as a failure - git check-ignore failing inside a repository is no longer read as "not tracked": the path is excluded anyway, or teamai warns with git's error - uninstall also keeps the block while a file contains the value (8+ characters, not a path or the login name) of a variable still set in the environment, which finds a server since dropped from mcp.yaml * refactor(env): resolve a scope's env once per command (#879) resolveTeamEnv (src/env-resolution.ts) reads env.yaml, secrets.yaml, both value stores and the env.sh exports once. buildVarTable, the MCP reconcile, envAdvisories, env exec, doctor and pull take that result: an interactive pull resolves once per scope in its env stage and reuses it for MCP and the advisories (was up to four secrets.yaml reads). - Variable and secret values move out of resources/secrets.ts, which now holds declarations only (R3); one StoreResolution<V> and storeEntry (R2); resolveSecretDeclarations takes { active } instead of a tri-state array (R4); reportMissingSecrets lives in env-advisories (X1). - ENV_KEY_RE moves to resources/env-key.ts, so env.ts imports SECRETS_LAYOUT statically (EV1). - env list and `teamai list env` print one listing (env-listing.ts); a variable shows the value it resolves to and its source, team or env.yaml, like a secret (S1). - A failed declaration is never "no secrets": the listings show no variable value then, --reveal included, and a server skipped for a missing variable is kept (S2). - SecretState gains `unreadable` for a store that can't be read, handled with a never check (R1). * fix(mcp): mcp list reports a broken secrets.yaml and exits 1 (#879) A failed declaration read as no secrets, so every server variable came from the raw environment, another team's token included, and mcp list said 'all set' while pull kept MCP frozen. It now names the file, exits 1, and shows a server's variables as 'not resolved' (Conflict 14, C3). * fix(env): env commands outside a scope say so and exit 1 (#879) env list, add, remove, set and unset threw NotInitializedError with a stack trace outside any teamai scope. They now print its message and exit 1; env set/unset --global still work there. * fix(env): env exec refuses a command without -- before it (#879) Commander drops --, so `teamai env exec gh pr list --dry-run` read the command's flag as teamai's and ran nothing. env exec now takes what was typed after `exec`, and without -- before the command it says "Put -- before the command: teamai env exec -- <command>" and exits 2. teamai's own options may still come before --. * feat(doctor): check that the member's secret values can be read (#879) While teams/<team>.json or machine.json can't be read, every secret has no value and MCP keeps what the last pull wrote, yet the MCP check passed and only a pull warning said why. doctor now fails 'Your team secret values can be read' with the reason, for a scope whose secrets or variables read those files. * fix(env): name the unset variable a --from-env secret reads (#879) The missing-secret line told the member to run `teamai env set KEY`, which replaces the reference they chose. For a secret whose deciding entry reads an unset variable it now says: KEY reads VAR, which is not set. Set VAR, or run `teamai env set KEY [--global]` to replace the reference. * test(mcp): env unset keeps the entry, still owned, until a new value (#879) env set, pull, env unset with nothing exported, pull: the entry keeps the old value and the manifest still claims it, so the next value replaces it instead of colliding with a user-owned server. * refactor(env): expected env set/remove failures as values, one wording (#879) - secretInput returns a tagged result; an unexpected stdin or prompt failure propagates instead of printing as a user error (E1). - Every error path in env-commands uses fail() and invalidKeyMessage(), so each exits 1, env add's invalid key included (E2). - removeSecret returns removed | absent | reported: env remove of a variable env.yaml lacks still says it is not found next to a broken secrets.yaml (E3). - env add branches on --secret, not on a missing value (E4). - env unset with declarations that can't be read no longer calls the key a variable (E5). - "Secret is not declared" names the file and the next step (E6). - Messages call the machine store "global value (every team on this machine)", matching --global and the env list label (R5). * refactor(entries): EntryFailure always carries its layout (#879) An optional layout fell back to the type's, so a failure site that forgot it would word a broken secrets.yaml as env's. activeEntryNamespaces now takes the layout and every failure sets it (N1). * refactor(doctor): name entry resolution checks with their layout (#879) The check names were looked up by the layout's message label, so a wording change to a label would silently drop its doctor check. Each entry set now carries its check name (DD1). * refactor(secrets): report an unparsable value store by path only (#879) - Drop src/utils/json-position.ts, a second JSON grammar kept only to print a line and column: a parse error now reports the path alone, which is what the spec asks (never the input), and a disagreement with JSON.parse can no longer leak the parser's quote (SS3). - The unreadable-store error names what to check and the way out, and reads the error code without a cast (SS1). - SecretStore is inferred from its schema (SS2). - The design doc says an unreadable store leaves secrets 'unreadable'. * test(secrets): typed fixtures instead of casts in the new tests (#879) The LocalConfig and TeamaiConfig fixtures in env-exec, mcp-secrets and secret-values are now checked against the types, so a new required field breaks them instead of testing a shape production never has (T1). * docs(env): env set takes variables, listings show sources, exec needs -- (#879) - Usage guide: `env set` accepts an env.yaml variable without --global, not only a declared secret (D1/C2); `env exec` links the resolution order instead of "the order above", which the section never gave (C4). - team-secrets.md intro states the variable order; a --from-env variable override leaves the key out of new shells entirely. - Document the shared listing (variable source, `unreadable`, no values while the declarations fail), `mcp list`'s `not resolved`, the doctor check for an unreadable values file, and `env exec`'s exit 2 without --. * fix(env): refuse env set/unset/list when the project config can't be read (#879) Detection returned null for an unreadable project config, so env set fell back to the user scope and stored the value for that team. Report the file and why, exit 1, and write nothing, as pull and env exec do. * fix(env-exec): exit 128 + signal for a command killed by SIGPIPE or SIGUSR1 (#879) Re-raising SIGPIPE on teamai does nothing (Node ignores it) and SIGUSR1 starts the inspector, so teamai exited 0. Set the shell's exit code first and re-raise only the signals Node ends on. * fix(env-exec): don't send the command a second SIGINT on Ctrl-C (#879) The terminal sends Ctrl-C and Ctrl-\ to the whole foreground process group, so the command already has them; forwarding sent a second SIGINT, which tools such as terraform take as force-quit. Ignore SIGINT and SIGQUIT while the command runs and keep forwarding SIGTERM and SIGHUP. * docs(env): exec signal handling and env set on an unreadable project config (#879) * fix(mcp): judge MCP configs by disk and manifest, not current config (#882) Pull, doctor and uninstall still decided "clean" from the current team config in places. Now one function, resolvedValueEvidence, decides for all three: - a teamai-owned entry still in the file counts when its server has left mcp.yaml, as well as when it needs a resolved ${VAR} or mcp.yaml cannot be read (doctor no longer skips that case) - targets include the built-in location of a tool the team dropped from toolPaths or moved - doctor names a file two tools share once - exclude updates take the existing acquireLock helper, re-read the file and write it atomically, so concurrent commands keep each other's paths - uninstall inspects every worktree of each repository owning a block, including a nested repository's linked worktrees, and applies the manifest rule per worktree - the kept-block warning names each file and why, such as the variable whose value matched * fix(env): the env commands and env exec load config through --dry-run (#866) #866 threaded { dryRun } through the loaders pull, push, status and list use. The scope lookups this branch added did not take it, so `teamai --dry-run env list|set|unset|add|remove` and `env exec --dry-run` still persisted the legacy role migration, adopted a pre-#546 partition or ran the self-mode bootstrap. - resolveConfigForDir takes LoadOptions and passes them to both loaders. - scopeHere/requireScope (env commands) and commandEnvironment (env exec) forward options.dryRun. Without the flag nothing changes: env exec still runs the migrations every command runs (spec #879 Conflict 12). Five cases added to dry-run-load-path.test.ts; each failed before this change (config.yaml rewritten, partition renamed). * fix(secrets): name the team values file by the repo identity hash alone (#879) Renaming team: in teamai.yaml changed <team>-<hash>.json and orphaned every member's values. The secrets store now uses ~/.teamai/secrets/teams/<hash>.json; the models key files keep their names. The path has not shipped, so there is no migration. * fix(env): keep a __proto__ env key through the store, MCP and env exec (#879) ENV_KEY_RE accepts __proto__, but z.record dropped it from the value store, assigning it on an ordinary object hit the inherited setter, and reading it unset returned Object.prototype. The store, the MCP var table and the env exec overlay are now built without a prototype (envTable), and the member's environment and --from-env references are read as own keys (envValue). * fix(env): env list loads config with --dry-run always (#879) env list only reads, so like status and list since #866 it never migrates the config it loads, with or without --dry-run. The dry-run load-path test now runs env list without the flag, which is the case that used to migrate. * revert: drop the #890 cherry-pick from this branch2e15c3f6was picked only to protect the local Claude config while this branch's tests ran; #890 lands it on main on its own. * fix(env): env exec applies no team env while the declarations fail (#879) A legacy GITHUB_TOKEN in env.yaml plus a secrets.yaml that fails gave the command the repo value, though the key may be a declared secret. Like env.sh and the backup (Conflict 14), env exec now overlays nothing on a failed declaration: the command gets the inherited environment, and stderr names the failure. * fix(fs): create the atomic-write temp file with the target mode (#879) writeFileAtomic and writeJsonAtomic wrote the temp file with the umask's default mode (0644 under umask 022) and narrowed it by chmod afterwards, so a secret or model key was readable by other users until then. The temp file is now opened exclusively with the target mode; the chmod stays for the bits the umask removes. * fix(env): env set --dry-run previews without asking for the value (#879) `teamai --dry-run env set KEY` prompted for the value, or read stdin with --stdin, before printing the preview, so it failed without a terminal. The preview now comes first, and no value is read. * fix(env): serialize env set and env unset on the values file (#879) Two env set or env unset runs at once each read the store, changed it and wrote it back, so the later write dropped the other's change. Both now go through updateSecretStore, which takes <store>.lock (the acquireLock helper), re-reads the file inside it and writes the result. A --dry-run takes no lock. * fix(env): keep a secret's stored value from becoming a variable override (#879) Secrets and variable overrides share the team store. env set now records kind: secret | variable from what the scope declares; variable resolution uses only variable entries, secret resolution only secret ones, and an entry without kind counts as a secret. env list flags an entry of the other kind with the fix. * fix(mcp): write an MCP config that holds a resolved value 0600 (#879) writeJsonAtomic preserved an existing file's mode, so a 0644 .mcp.json or ~/.claude.json kept 0644 after teamai wrote a resolved secret into it. A config holding a resolved ${VAR} value (a kept entry included) is now written 0600; one without keeps its mode. * fix(mcp): create the Codex config temp file 0600 (#879) applyCodex wrote config.toml.<pid>.tmp with the umask's mode and chmodded it after, so a resolved secret was briefly readable at 0644. Both Codex writes now go through writeFileAtomic with mode 0600 (random temp name, created with the mode, symlinked targets written through). * docs(env): say env exec ignores a SIGINT or SIGQUIT sent to teamai alone (#879) * fix(mcp): tighten an unchanged config that holds a resolved value to 0600 (#879) * fix(env): mark what each env.sh exported so a value from one no scan finds is not the member's (#879) * fix(env): pass on a SIGINT or SIGQUIT sent to env exec outside the terminal's foreground (#879) * fix(env): keep marking what an env.sh exported before a rewrite dropped it (#879) * docs(env): say a SIGINT sent to a foreground env exec alone is not passed on (#879) * fix(secrets): name the team values file by the configured team repo URL, not teamai.yaml's repo: (#879) A copied or hostile team repo could claim another team's repo: and receive that team's stored values. The secrets file now hashes the URL from the member's own config, normalized so ssh, https and credentialed forms match. The models key store keeps its naming (#894). * fix(env): remove what a teamai env.sh exported from env exec while the declarations fail (#879) An invalid secrets.yaml left the inherited environment untouched, so a shell that had sourced an env.sh still passed the repo's GITHUB_TOKEN to the command. Every inherited value the member-environment rule discounts is now removed, named by key on stderr; the member's own exports stay. * fix(mcp): never write a declared secret into a project MCP config git tracks (#879) .git/info/exclude (#882) stops git add, not a file git already tracks. For such a file the server is skipped for that tool and an entry an earlier pull wrote stays as it is; pull warns, mcp list shows it as withheld and doctor fails the delivery check, each naming the file and git rm --cached. * fix(env): leave no duplicate declaration of a key env add --secret or env remove edits (#879) A key declared twice fails every read of secrets.yaml, and both commands edited only the first declaration. env add --secret now updates the first and removes the rest; env remove removes every one; both say how many. * test: keep the real normalizeRepoUrlForCompare in utils/git mocks the secrets store reaches (#879) * fix(env): keep the port in the URL that names a team's secrets file (#879) normalizeRepoUrlForCompare drops explicit ports, so two team repos on one host with different ports shared one values file and one team's secret reached the other. The file is now named by the URL's scheme family, lowercased host, non-default port and path; only credentials, the ssh user, a trailing .git and slashes are dropped. The scp form and the ssh URL of a repo still share a file; its ssh and https URLs no longer do. The utils/git mocks that kept the real normalizeRepoUrlForCompare for the store are no longer needed and are reverted. * fix(mcp): skip the .git/info/exclude write while another command holds its lock (#882) After the 2.5 s wait for the exclude file's lock, updateExclude wrote without it, so two writers could drop each other's pattern and leave a plaintext MCP config committable. It now writes nothing and reports 'locked': pull warns that the file is not excluded yet and to run `teamai pull` again (doctor's exclude check keeps reporting it meanwhile), and uninstall keeps the block and warns. * fix(uninstall): keep an exclude entry unless its MCP config is proven free of teamai's servers (#882) Uninstall judged a protected file clean from the current mcp.yaml, manifest and resolvable values, so with the manifest lost, the server gone from mcp.yaml and its value unset, a plaintext token looked like the member's own server and the exclusion went. It now fails closed and works per entry: a pattern goes only when its file is gone, holds no server, or holds none of teamai's servers with managed-mcp.json still there to say what teamai wrote. A kept entry is named with its file, why, and how to clean it by hand, since a rerun of uninstall finds no config after a full uninstall. * fix(env): keep http and https team repos in separate secrets files (#879) The store identity mapped https and http to one `http` family and dropped each default port, so `http://host/team.git` and `https://host/team.git` read one file: if the two endpoints serve different repos, one team got the other's stored secrets. The identity now keeps the scheme, still dropping 443 and 80; the ssh forms (`ssh://`, `git+ssh://`, `ssh+git://`, scp) stay one family. * fix(env): strip teamai env.sh exports in env exec when the project config can't be read (#879) With an unreadable project config, env exec passed the inherited environment unchanged, so a value another scope's env.sh exported (a legacy token, a member override) reached the command while the warning said no team values were applied. It now removes what the member-environment rule discounts (env.sh file, record or marker, the env.sh beside the unreadable config included), keeps the member's own exports, and names the removed keys, never values, as the failed-declaration path does. With no config at all the inherited environment is still passed as is. * fix(mcp): exclude a project MCP config from git before writing a resolved value into it (#882) Pull listed the file in .git/info/exclude only after writing the plaintext, and a failed exclusion only warned, so the secret-bearing file stayed eligible for git add -A. The exclusion now comes first; when it cannot be established (exclude file or .git/info not writable, lock held past the wait, file already tracked, git error) the file is left as it was and the warning names the reason and the fix. * fix(mcp): report a server withheld from a file git would commit in mcp list and doctor (#882) * fix(secrets): keep the ssh user in a team's values-file identity (#879) alice@host:team.git and bob@host:team.git can be different repos in each user's home; they no longer share one values file. The scp and ssh:// forms of one user, host, port and path still match; http(s) credentials are still dropped. * fix(env): overlay and remove env exec keys case-insensitively on Windows (#879) Windows environment names are case-insensitive, so a declared api_url left an inherited API_URL in place (Node keeps the first name of a case-folded pair) and a removed secret survived in another case. On win32 a key now replaces or removes every case variant; elsewhere nothing changes. * fix(mcp): report a tracked file on a dry run and a withheld server already installed (#882) Backports #880's merge0d9f7fa7: a dry run (doctor, mcp list) names a tracked file before any pull has listed it, mcp list reports withheld for a server an earlier pull installed, and doctor's withheld note carries the exclusion's own fix instead of the pull --force advice. * fix(mcp): name a tracked MCP config before an unwritable .git/info/exclude (#882) A tracked file needs `git rm --cached` whatever else is wrong, so ensureExcludedFromGit checks gitTracks before the writability check, on a pull and a dry run alike, and lists nothing for it. * fix(mcp): take a project MCP config's exclude line back out once it holds no resolved value (#882) A pull that lists a config in .git/info/exclude and then writes no value into it (it does not parse, a member's server holds the team's name, the write fails) removes the line it added. After a pull or `teamai mcp remove`, a line whose configs are proven clean in every worktree, by the proof uninstall uses (moved to mcp-reconcile.ts), is removed under the lock; one not proven clean stays. A config listed before its write is listed again after it, so a concurrent uninstall that dropped the line between the check and the write does not leave the value unprotected. * fix(secrets): tell an scp path in the ssh user's home from an ssh:// path from the root (#879) git@host:acme/team is relative to the ssh user's home, ssh://git@host/acme/team is absolute; on a plain ssh host they can be different repos, yet they shared one values file. An scp path starting with neither / nor ~ is now keyed as ~/path, the path ssh://host/~/path names; host:/abs and ssh://host/abs still match. The scp form without a user (host:path) is now read as ssh too. * fix(mcp): judge a project MCP config by the manifest as it stood before the pull rewrote it (#882) A pull whose manifest was lost before it ran recreates managed-mcp.json while reconciling, so the clean-file proof read the new record and took the exclude line out of a file still holding a teamai server that left mcp.yaml with its variable unset. The proof now uses this worktree's manifest as read before the reconcile. * fix(mcp): log a rolled-back exclude line at debug level (#882) A line this pull added and took back out, because it wrote no resolved value into the file, was reported as removed although the member never saw it added. Only removing a line an earlier run added stays at info. * fix(mcp): keep a shared exclude line while another worktree's config holds a server (#882) A pull or `teamai mcp remove` judged every linked worktree's MCP config with today's definitions and values. Once a server's ${VAR} became a literal, and the value was no longer set, a pull in worktree A took worktree B's stale token-bearing entry for clean and removed the shared /.mcp.json line, so `git add -A` in B staged the token. These commands now release a line only when the current worktree's file passes the full proof and every other worktree's file is missing or holds no MCP server. `teamai uninstall` keeps its full proof in each worktree. * test(mcp): build the other worktree from the real temp path so the test checks what it names (#882) * fix(env): strip teamai env.sh exports from env exec with no config or an HTTP scope (#879) Neither path applies team values, yet both passed on whatever a sourced teamai env.sh exported, another team's credentials included. Remove every inherited value that is not the member's own, as the unreadable-config path does, and name the removed keys on stderr. * fix(secrets): name a team's values file by the full SHA-256 digest (#879) The file name is the boundary between teams, and 40 bits let a hostile team search for a repo URL whose name matches another team's file and read its values. Nothing has shipped, so no migration. * fix(env): name the env.sh provenance marker by the full SHA-256 of its data home (#879) * test(push): push publishes the secrets.yaml env add --secret leaves (#879) * fix(uninstall): list no worktrees for a project root that no longer exists (#882) buildRemovalPlan now lists every worktree to find teamai's exclude blocks, outside the MCP cleanup's try. simple-git throws synchronously for a missing directory, so a project uninstall whose root is gone crashed; main's #878 test caught it after the merge. * fix(secrets): keep the URL query and fragment in a team's values file name (#879) * fix(mcp): treat an empty, unparsable or tool-less managed-mcp.json as no record (#882) The clean-file proof took any managed-mcp.json on disk as teamai's record, so an empty or truncated one let a pull, `mcp remove` or uninstall judge a file still holding a stale secret-bearing entry clean and drop its exclude line. A file now counts as recorded only when the manifest parses and holds an entry for that tool's file. A project record teamai empties stays as [] so a file left with only the member's own servers can still be released. * fix(env): compare exported, recorded and marked keys case-insensitively on Windows (#879) * fix(uninstall): list no worktrees for a project root that no longer exists (#879) * fix(mcp): keep the exclude line of a config this pull wrote when a later step fails (#882) * fix(mcp): read a check-ignore error as unsafe unless ls-files proves the config untracked (#882) * fix(mcp): report withheld only for targets delivery would write the server to (#882) * docs(mcp): describe the .git/info/exclude block in the setup skill and the stricter git check (#882) * fix(mcp): keep the exclude line of an entry a pull wrote with a resolved value after its definition turns literal (#882) * fix(mcp): judge a nested repository's linked worktree config by its sibling's tool (#882) * feat(mcp): record the project MCP configs a pull wrote a resolved value to in managed-mcp-files.json (#882) * fix(mcp): keep protecting a config a pull wrote under a toolPaths mapping the team has since changed (#882) * fix(mcp): keep a config's exclude line past the pull that rebuilt its lost record (#882) * test(mcp): pin today's exclude rules for a missing, corrupt or locked managed-mcp-files.json (#882) * docs(mcp): describe managed-mcp-files.json and the configs it keeps protected (#882) * refactor(mcp): keep the #882 record edits off the lines #880 changes (#882) * test(mcp): keep excluding a config whose entry a pull kept for a missing declared secret (#875, #882) * fix(mcp): protect a config an older teamai wrote under a mapping an earlier teamai.yaml made (#882) A teamai from before managed-mcp-files.json kept no record of the path it wrote a resolved value to. Once the team changed that toolPaths mapping, no pull visited the file. The first pull on this version now reads every mcpProject path the team repo's history of teamai.yaml mapped, once per worktree: a file under the project root that no current mapping or record reaches, and that holds a resolved value, is listed in .git/info/exclude and recorded. A git error leaves the read for the next pull; a shallow clone reads the history it has. * fix(mcp): keep a rebuilt record from persisting without its note of the file's other servers (#882) When a pull rebuilt a lost managed-mcp.json and could not note the other servers in the file (managed-mcp-files.json locked, an I/O error), it still wrote the rebuilt record, so no later pull knew the record was rebuilt and a stale server's line could go. The same manifest write now marks those records unnoted: the file counts as having no record, so it keeps its line while it holds a server, and the next pull notes them and clears the mark. * fix(mcp): take back a managed-mcp-files.json record for a config the pull then did not write (#882) A pull records a config before writing a resolved value to it. When the write failed or did not happen (the file does not parse), the record stayed, and once the mapping changed a config of the member's own at that path was kept excluded while it held any server. The pull now takes back a record it added for a file it did not write, as it does the file's exclude line; the settle after records it again if the file holds a resolved value anyway. * docs(mcp): describe the teamai.yaml history read, the unnoted rebuilt record and the record a failed write takes back (#882) * refactor(mcp): keep the r3 edits off the lines #880 changes (#882) * fix(mcp): also protect a config an older teamai wrote under a built-in default it has since changed (#882) * fix(mcp): replace a symlinked Codex config instead of writing into the file it links to (#875, #882) * fix(env): drop what a teamai env.sh exported from env exec when env.yaml or the values file fails (#875) * fix(env): on Windows, match a declared secret to an env.yaml variable in any case (#875) * fix(mcp): judge a project MCP config under a symlinked directory where the write lands (#882) The appliers replace the file itself (tmp + rename) but follow its directories. Every git check now judges realFilePath(file), the one resolver the release keying already used: a directory linked out of any repository no longer withholds the servers on git's "not a git repository", and a tracked file there is named with both paths, with a git rm --cached that works (git refuses the path through the link). * docs(mcp): describe how a config under a symlinked directory is kept out of git (#882) * refactor(mcp): keep realFilePath next to existingAncestor, without an import cycle (#882) * refactor(mcp): key each worktree's targets with realFilePath, the one rule for where a write lands (#882) * fix(mcp): judge a config under an earlier teamai.yaml mapping as a recorded file, not by today's records (#882) * fix(mcp): have doctor check the configs earlier teamai.yaml mappings reach until a pull reads them (#882) * docs(mcp): describe how a config under an earlier teamai.yaml mapping is judged, and doctor's check of it (#882) * fix(mcp): record a config under an earlier teamai.yaml mapping that git tracks, and judge it once git no longer does (#882) * fix(mcp): keep judging a recorded config for a tool the team moved while another tool still maps it (#882) * docs(mcp): describe the tracked config an earlier mapping reached, and a moved tool's config another tool still maps (#882) * fix(mcp): find a config an older teamai wrote under an earlier mapping another tool maps today, and judge it by that tool's records (#882) * docs(mcp): describe the history read's configs another tool maps today (#882) * fix(mcp): prove a shared config clean only while every tool that wrote a resolved value there has its record (#882) * fix(env): on Windows, set and unset a key under the name the scope declares, in any case typed (#875) * fix(mcp): judge a built-in location no mapping reaches today as an earlier-mapped file, and a shared config no pull recorded by every tool mapping it (#882) * docs(mcp): describe the built-in location of a moved or dropped tool, and a shared config no pull recorded (#882) * fix(env): on Windows, match a secret's declaration and stored value in any case (#875) * fix(mcp): name the ignore rule that re-includes a config teamai just listed, instead of saying git tracks it (#882) * fix(mcp): judge a moved tool's built-in location another tool maps for that tool too, and hold a config's line while no managed-mcp.json claims its servers (#882) * docs(mcp): describe the no-manifest rule, a moved tool's built-in location another tool maps, and a re-including ignore rule (#882) * fix(env): key a team's values by its repo URL when the configured remote is only an alias (#875) * fix(env): on Windows, recognise a secret's env.yaml value under another case of its name in the environment (#875) * fix(mcp): on Windows, keep an inherited value under another case of a team variable's name out of MCP servers (#875) * fix(env): on Windows, replace and remove every stored entry under another case of the key (#875) * fix(mcp): keep a tool's record as it was when its config does not parse, and take back each tool a write that did not happen recorded (#882) * fix(mcp): mark the records a pull with no managed-mcp.json writes as unnoted until the servers no record claims are noted (#882) * fix(mcp): have doctor judge a record marked unnoted like a missing managed-mcp.json (#882) * fix(mcp): judge a config tools of different formats share in each of their formats before releasing its line (#882) * fix(mcp): note the servers no record claims in the file of a tool whose record a pull writes first, as with no managed-mcp.json at all (#882) * fix(mcp): take back a tool managed-mcp-files.json recorded before a write unless its own records hold a resolved value there (#882) * fix(mcp): treat an installed tool's missing record as lost at every pull and in doctor, not only an empty managed-mcp.json (#882) * fix(mcp): tighten to 0600 every project config the protection pass keeps out of git, not only the ones a pull writes (#879) * fix(mcp): on Windows, fill a placeholder from the variable of the same name in another case (#875) * fix(mcp): note the unclaimed servers under every format of a shared config, and pin an uninstalled tool's leftover config (#882) * fix(mcp): count an uninstalled tool's missing record when no installed tool maps its file, and name a re-including .gitignore rule on a dry run (#882) * fix(mcp): on Windows, match a placeholder to its secret in any case in mcp list and the missing-secret notice (#875) * fix(mcp): scope a shared config's claims to the tools reading the same key, and settle its notes on every format's view (#882) * fix(env): keep a file:// repo's .git suffix in its values file identity: team and team.git are two directories (#875) * fix(env): on Windows, update and remove an env.yaml variable typed in another case (#875) * fix(mcp): keep suspect an uninstalled tool managed-mcp-files.json lists as a writer, though an installed tool maps the file (#882)
32 KiB
管理后端设计
状态:#341 的设计提案。 本文描述未来能力,不增加 Go 服务、Web 控制台或 CLI 选项,也不改变现有 Git 或 ClawPro HTTP 实现。本阶段交付物仅为本文及英文版本;实现前需要确认下文的 开放决策并满足各阶段验收条件。
1. 范围与现有实现
对照基线为 main 提交 3f7fa1dedbccac1416fe329bdd308bb45de30b0e。
后端需要同时覆盖已发布资源和成员产生的数据。普通用户无需安装 Git,
也不应看到 Git 凭据、仓库地址或基于 Git 的贡献流程。
ResourceHandler 为七个 已注册处理器 定义扫描、复制、差异和删除接口。 pull 还负责解析命名空间、编译文化与指令、协调 hooks/MCP、 维护召回索引和上报活动。push 准备隔离的变更和审核请求; remove 当前只暴露 skills、rules、agents 和 MCP 的删除。 后端覆盖更多资源,不代表当前每个 CLI 处理器已经支持所有写操作。
现有 local-agent 使用
/api/projects/mine、/api/local-agent/report、/api/local-agent/sync、
/api/local-agent/commands/ack 和 /api/local-agent/get-config,
负责命令与资源下发及工作区绑定,并不生成完整的版本化团队仓快照。
这些路由保留为独立兼容适配器,不作为新 API 的别名。
#469 提议的 Provider 抽象若获合入,
可以承载未来的管理后端适配器;本文不假设该 PR 已经落地。
数据目录设计 区分机器上的工作区分区与逻辑项目。 多项目设计 通过项目和角色选择器决定资源命名空间。 本地选择结果和命名空间名称都不能充当授权凭据。
2. 能力映射
每个导入项记录原始路径和来源 revision,便于审计。已发布资源面保存经过审核的 不可变内容;上报数据面接收与身份绑定的事件,使用独立的保留规则和写权限。
| 现有数据 | 现有代码 / 行为 | 后端映射提案 |
|---|---|---|
teamai.yaml |
配置、共享策略、工具路径及审核人(src/config.ts、src/types.ts) |
版本化策略/配置记录;审核后的变更生成兼容视图 |
skills/ |
SkillsHandler、命名空间及 marketplace 元数据 | 不可变文件和依赖组成的资源包,派生 marketplace 视图 |
rules/ |
RulesHandler 及强制规则选择 | 版本化规则,并单独执行强制策略约束 |
docs/ |
DocsHandler 及文档索引 | 版本化文档、授权物化及召回索引 |
env/env.yaml |
EnvHandler、本地覆盖和环境注入;值为明文 | 非密钥模板及密钥引用,解析密钥时单独授权 |
env/secrets.yaml |
只声明、不含值的密钥(团队密钥);v1 在每个成员的机器上解析其值 | 密钥引用;下文的加密密钥服务仍属未来工作 |
agents/ |
AgentsHandler 及工具格式转换 | 版本化 agent 定义,复用现有工具适配器渲染 |
hooks/hooks.yaml |
HooksHandler 及 hook 协调,不支持通用逐项 push | 可审核的声明式 hooks、客户端同意及类型验证 |
mcp/mcp.yaml |
McpHandler 及 MCP 协调,贡献通过直接编辑 YAML 完成 | 可审核的服务定义、transport 策略及密钥引用 |
learnings/ |
src/contribute.ts、项目命名空间及 src/utils/search-index.ts |
Learning 草稿、批准后的内容版本、命名空间 ACL 和本地召回索引 |
culture.md |
src/pull.ts 中的 compileCulture |
版本化组织/团队上下文及派生客户端视图 |
claudemd/ |
src/pull.ts 的 compileClaudemd 及召回/指令注入 |
版本化指令,只从已授权命名空间选择 |
tags.yaml |
标签发现及本地订阅 | 版本化分类及用户订阅,与访问控制分离 |
manifest/roles.yaml |
src/roles.ts 资源选择器 |
角色/资源选择的兼容视图,后端 RBAC 独立 |
manifest/projects.yaml |
src/projects.ts 逻辑项目命名空间 |
稳定项目 ID 映射到已授权命名空间,保留角色/项目并集语义 |
sources / publicSkills |
src/source.ts 的订阅、优先级及安装 manifest |
固定到已授权资源版本及所有权记录的可审计来源引用 |
members/ |
成员注册及元数据 | User/Membership/Device 记录,客户端不能自行授予成员身份 |
stats/ |
src/team-push.ts 累计使用/会话汇总 |
去重后的 UsageEvent revision、派生汇总和确认后的游标 |
votes/ |
src/votes.ts 及识别增量的上报合并 |
与身份绑定的投票记录/事件及确定性重试语义 |
sessions/ |
src/save-session.ts 摘要与导出、本地会话事件 |
受策略约束的会话 revision、同意、脱敏和独立保留期 |
<type>/.removed |
ResourceHandler tombstone 及本地清理 | 版本化删除操作、tombstone 及保护所有权的删除 |
导入审计必须列出未知文件,不能静默丢弃。未知类型以不可执行的不透明附件保留, 等待管理员分类。密钥不能按普通资源导入。
| 现有命令 | 未来管理适配器 |
|---|---|
teamai init |
认证、接入设备、选择已授权项目并绑定工作区 |
teamai pull |
获取授权快照/增量,物化、协调并建立索引 |
teamai push |
扫描本地资源、上传验证后的 blob、提交变更集审核 |
teamai contribute |
提交附带来源/会话归属的 learning 草稿,保留离线草稿 |
teamai remove |
准备需审核的显式删除,发布 tombstone 后执行本地清理 |
teamai team-push |
发送已认证的使用/投票/会话 revision,持久确认后推进游标 |
teamai recall |
仅搜索活跃绑定的授权本地索引并记录待上报投票 |
teamai source |
通过审核后的配置和能力检查管理已授权来源订阅 |
这些是现有命令未来的能力映射,不代表新增命令或选项已经可用。 初始化可以复用现有交互入口;具体选项名称仍需 CLI 设计确认。 管理端导入可以在服务端使用 Git,但成员接入和日常使用不能依赖它。
3. 领域模型与授权
管理层级为 Organization -> Team -> Project。
项目使用稳定的不透明 ID,不依赖显示名称、文件路径或现有命名空间选择器。
用户可以加入多个组织。WorkspaceBinding 将已认证用户和设备、本地工作区标识,
以及一个或多个已授权项目 ID 关联起来。CLI 本地保存绝对路径;
服务端通常只接收不透明工作区 ID 和显示名称。
核心记录包括 Organization、Team、Project、User、Role、
Membership、Device、WorkspaceBinding、Resource、ResourceVersion、
Revision、ChangeSet、Review、Release、Learning、Vote、
UsageEvent、Session 和 AuditEvent。租户所属记录和 blob 引用都携带组织 ID,
外键及仓储查询也包含该 ID;猜测资源 ID 不能绕过项目授权。
授权独立于现有角色和资源选择器。建议权限为 project:read、
resource:write、review:decide、release:publish、membership:manage、
telemetry:write、audit:read、organization:manage、project:manage、
identity:manage 和 secret:read。贡献者不能审核自己的变更;发布者需要单独的
发布权限,并且审核必须对应准确的内容摘要。组织管理员只能通过明确策略获得项目权限。
设备及服务账号的项目范围应比其所有者更窄。
对每个已授权项目,资源按组织、团队、项目及允许的用户偏好逐层解析。
资源键为 (resource_type, canonical_name),不能只用文件名。
用户覆盖不能降低上层强制策略、放宽禁用的 transport 或获得额外密钥权限。
更具体层级的 tombstone 屏蔽继承资源;删除该 tombstone 后恢复继承版本。
角色和逻辑项目选择器仍取已授权命名空间的并集,彼此不覆盖。
若同一具体层级的两个项目提供相同资源键但版本不同,同步应报告冲突并保留旧快照。
用户必须明确选择符合策略的绑定级来源优先级,不能由遍历顺序决定。
跨项目复用固定到已授权来源的 ResourceVersion,不能引用可变的 latest。
来源权限变化会使受影响绑定失效,并阻止新的读取。
4. 四条端到端用户旅程
J1:企业用户首次登录。 管理员配置可信身份提供方及组织、用户组映射。
新用户在控制台或 CLI 选择企业登录,完成公司认证后,
按 (issuer, subject) 映射到稳定内部用户。
组织成员身份只能来自管理员批准的映射。
界面展示有权访问的团队、项目,或明确的申请权限状态,不要求填写仓库。
J2:非 Git 用户首次加入项目。 管理员创建项目、发布首批资源, 生成限定该项目、单次使用且有有效期的接入码。 成员安装 CLI 并进入初始化流程,通过浏览器批准设备; 无浏览器设备则展示短码,由另一台设备打开验证页面。 接入码本身不授予 bearer token,仍需检查登录身份与申请项目权限是否匹配。 确认后,CLI 将设备凭据保存到操作系统保护的存储中,绑定当前工作区, 验证首份快照并调用现有资源处理器。结果只展示项目、revision 和同步状态,不出现 Git 概念。 过期接入码可以重新签发;拒绝接入不会创建绑定。
J3:同一成员管理多个项目。 成员通过相同流程加入第二个已授权项目, 为其选择独立工作区,或加入已有绑定。选择结果及同步状态按绑定隔离。 切换工作区时激活对应项目集合;在同一绑定内切换项目时, 先预览将新增、替换和删除的资源。同名冲突遵循第 3 节。 项目不可用不能导致另一项目的缓存被空快照覆盖。 退出项目时撤销绑定,只删除仍保持已应用内容的受管资源, 保留个人修改并报告冲突。解绑设备、卸载时撤销凭据, 清理本地凭据、索引和受管状态,不删除无关用户文件。 若处于离线状态,本地清理完成,但远程撤销必须明确显示为待处理, 直到重新联网提交或在控制台完成。
J4:管理员跨项目审核发布。 管理员为同一组织内多个项目准备变更集, 查看有效资源差异及受影响成员后提交审核。 审核者批准准确版本;发布者再次验证权限及所有预期项目 head。 同一个数据库事务创建不可变 release,并更新全部目标 head。 任意 head 过期、审核缺失或授权失败都会拒绝整次发布。 客户端只能看到旧 release 或新 release 的完整 manifest,不能看到混合状态。 回滚通过新 release 引用选定的历史版本,遵循相同的授权与审核规则。 跨组织原子发布不在本文范围内。
所有旅程的重试保留操作 ID,权限在服务端检查,身份服务故障不能降级成匿名访问。 普通用户看到加入、提交、审核、发布、恢复等操作,不接触 branch、commit、merge 或文本冲突标记。
5. 状态机与版本语义
Revision 是服务端签发的不透明、不可变 manifest 标识。
客户端只比较是否相等,不能按大小排序,也不采用 Git SHA 语义。
ResourceVersion 标识不可变字节及其 SHA-256 摘要。
Release 关联多个项目 revision;项目 head 的每次变化都产生审计事件。
ChangeSet: DRAFT -> IN_REVIEW -> APPROVED -> PUBLISHED
IN_REVIEW -> CHANGES_REQUESTED -> DRAFT
IN_REVIEW -> REJECTED
DRAFT | IN_REVIEW | APPROVED -> CANCELLED
Project: ACTIVE -> ARCHIVED -> ACTIVE
ARCHIVED -> DELETION_PENDING -> DELETED
Device: PENDING -> ACTIVE -> REVOKED
Enrollment: PENDING -> APPROVED | DENIED | EXPIRED
Binding: ACTIVE -> SUSPENDED -> ACTIVE
ACTIVE | SUSPENDED -> REVOKED
Sync: IDLE -> DOWNLOADED -> APPLYING -> APPLIED
APPLYING -> PARTIAL -> APPLYING
DOWNLOADED | APPLYING | PARTIAL -> BLOCKED_AUTHORIZATION
提交审核后继续修改会产生新草稿摘要,并使之前审核失效。 发布失败时,已批准变更集仍未发布,返回可恢复的冲突,不部分应用资源操作。 修改内容或目标 head 后不能复用旧审核。 归档项目按策略允许读取,但拒绝写入和新接入。 删除先撤销绑定并记录 tombstone,保留和清除是独立的可审计管理操作。
快照包含准确的解析后项目 revision、资源来源、版本 ID、hash、字节数、 策略版本及 tombstone。manifest 使用接入时建立信任的部署密钥签名。 blob hash 验证字节;签名和授权验证谁可以提供和接收这些字节。 只要保留的 release、审核、草稿或绑定仍有引用, 回滚及跨项目复用所需 blob 就不能被清理。
6. 版本化 API 契约
新资源 API 的建议前缀为 /v1,采用 HTTPS、JSON、
服务端请求 ID、明确权限校验及不透明 ID。
认证端点遵循所选协议,不用自定义资源错误包装 OAuth 错误。
任何端点都不能接收要求在成员设备执行的 shell 命令。
| 方法 | 拟定路由 | 语义 |
|---|---|---|
| GET | /v1/capabilities |
发现版本、操作、资源类型及限制 |
| GET | /v1/auth/authorize; /v1/auth/callback |
浏览器授权及已验证回调 |
| POST | /v1/auth/device/authorizations |
启动 Device Flow,限制轮询并等待用户批准 |
| POST | /v1/auth/token |
授权码/设备授权或刷新,轮换 refresh 凭据 |
| POST | /v1/auth/revocations |
撤销自己的凭据/设备会话,或管理权限内的目标 |
| POST | /v1/enrollments |
管理员签发单次项目接入意向 |
| POST | /v1/enrollments/{id}/decisions |
绑定身份及申请 scope 的认证批准/拒绝 |
| GET, POST | /v1/organizations; /v1/organizations/{id}/teams |
列出授权层级,或在明确组织权限下管理 |
| GET, POST | /v1/teams/{id}/projects |
按团队/组织策略列出或创建项目 |
| GET, PATCH, DELETE | /v1/projects/{id} |
读取、归档/恢复或请求可审计删除,修改需条件请求 |
| GET | /v1/projects/{id}/members |
列出有权查看的项目成员 |
| PUT, DELETE | /v1/projects/{id}/members/{user_id} |
按成员管理权限授予/撤销成员身份 |
| GET, PATCH, DELETE | /v1/devices/{id} |
查看自己/有权管理的设备,改名或撤销 |
| POST | /v1/bindings |
将已认证设备绑定到允许的项目 |
| GET, PATCH, DELETE | /v1/bindings/{id} |
读取/改变项目选择或撤销绑定,修改需条件请求 |
| GET | /v1/bindings/{id}/snapshot |
返回单份签名有效 manifest 及授权 blob 引用 |
| GET | /v1/bindings/{id}/changes |
返回授权游标后的增量,包含 tombstone |
| GET | /v1/projects/{id}/resources |
分页资源、版本和来源元数据 |
| GET | /v1/resource-versions/{id}/content |
返回授权不可变内容或限定范围下载地址 |
| POST | /v1/blobs |
暂存有界二进制内容,验证声明字节数和 hash |
| POST | /v1/change-sets |
创建固定项目 head 且包含明确操作的草稿 |
| PATCH | /v1/change-sets/{id} |
用 If-Match 编辑草稿,使旧审核失效 |
| POST | /v1/change-sets/{id}/submit |
验证并固定待审核摘要 |
| POST | /v1/change-sets/{id}/reviews |
记录针对精确摘要的批准/要求修改/拒绝 |
| POST | /v1/releases |
跨变更集目标项目原子发布已批准内容 |
| POST | /v1/releases/{id}/rollback |
准备需审核的恢复变更集,不改写旧 revision |
| GET | /v1/projects/{id}/releases |
分页不可变历史及差异 |
| POST | /v1/projects/{id}/learnings |
创建可审核 learning 草稿,不直接发布 |
| POST | /v1/reports/events |
去重已认证设备的使用、投票和会话 revision |
| POST | /v1/secret-resolutions |
仅在线、可审计地为授权设备和资源解析密钥 |
| GET | /v1/operations/{id} |
在相同授权条件下按客户端预生成操作 ID 查询,首次响应丢失后仍可恢复 |
| GET | /v1/audit/events |
按 audit:read 权限分页查询租户/项目审计 |
所有租户及资源读取都重新检查成员权限,包括 blob 下载、游标翻页、 审计查询和操作状态。blob 下载地址有短有效期,并限制在已授权版本和组织内, 不暴露存储凭据。暂存上传通过验证后才能被变更集引用。 会话上报端点不授予资源写权限。
创建变更集时指定预期项目 revision 和明确操作。 删除是显式操作,不能通过省略资源表示:
{
"client_operation_id": "op_client_001",
"projects": [{"project_id": "prj_a", "base_revision": "rev_a_24"}],
"operations": [
{"op": "put", "project_id": "prj_a", "type": "skills",
"name": "release-check", "blob_id": "blob_verified_1"},
{"op": "delete", "project_id": "prj_a", "type": "rules",
"name": "retired-rule"}
]
}
并发。 可变记录返回强 ETag,修改必须提供 If-Match。
缺失条件返回 428 PRECONDITION_REQUIRED,
过期条件返回 412 REVISION_MISMATCH。
变更集还固定各目标项目的基础 revision。
发布在更新所有 head 的同一事务内再次验证这些 revision。
客户端获取新差异,请用户解决资源修改冲突,不能静默覆盖。
幂等。 资源修改在首次请求前生成并持久化 client_operation_id,
并将同一值作为 Idempotency-Key;不向 OAuth 协议端点附加该自定义 header 要求。
ID 的作用域包括组织、主体、方法、路由及目标。
服务端在状态修改事务内保存请求摘要及持久操作/结果记录;
建议的 24 小时 HTTP 响应缓存仅用于优化重试。
相同 ID 配合不同内容返回 409 IDEMPOTENCY_CONFLICT。
GET /v1/operations/{id} 接受这个客户端预生成 ID,因此即使第一次响应及其中所有
服务端生成的 ID 都丢失,仍可对账。
客户端重试原 ID 或按原 ID 查询,不能因超时或缓存过期而换一个新 ID。
终态操作 ID 和结果引用的可查询期限超过响应缓存有效期。
结果保留期结束后仍保留精简的过期 ID 标记,返回
410 OPERATION_HISTORY_EXPIRED。
客户端保留待处理意图,对照资源 revision 和审计证据,
只有经过明确对账决策后才能创建真正的新操作。
历史未知或过期不等于先前写入从未发生。
分页与限制。 列表返回 items 和 next_cursor。
签名游标绑定主体、组织、筛选条件和快照 revision,建议有效期为 24 小时。
每一页仍重新检查权限。默认每页 50 项,最多 200 项。
暂定每个变更集最多 100 个操作,每个 blob 最多 32 MiB,
快照解压后最多 1 GiB,同时限制文件数、路径长度、嵌套深度和压缩比。
部署负责人需要在 P1 前确认这些限制;CLI 从服务端发现限制,
不能硬编码另一套值。大型操作先暂存,再原子发布。
资源错误。 错误响应包含 error.code、受长度限制的 error.message、
request_id,以及可选的结构化冲突资源 ID。
稳定错误码包括 AUTH_REQUIRED(401)、ACCESS_DENIED(403)、
NOT_FOUND(404,也用于隐藏的跨租户对象)、CURSOR_EXPIRED、OPERATION_HISTORY_EXPIRED 或 EVENT_WINDOW_EXPIRED(410)、
PAYLOAD_TOO_LARGE(413)、VALIDATION_FAILED(422)、
RATE_LIMITED(429)和 IDENTITY_UNAVAILABLE(503)。
重试遵循 Retry-After 并使用抖动。
验证错误不得回显密钥、文件系统路径或上游堆栈。
兼容。 能力发现返回协议版本、支持的资源类型、可写操作、 客户端版本范围及限制。不支持的主版本在修改前停止。 忽略未知可选响应字段,但未知操作或必需资源类型必须明确报错。 增量游标过期后重新获取已授权完整快照,不能猜测遗漏的删除项。
7. 身份与企业鉴权接入
默认适配器使用 OIDC 登录;具备浏览器的客户端使用带 PKCE 的授权码流程。 无浏览器客户端采用 OAuth 设备授权流程, 处理等待、减慢轮询、拒绝和过期响应。 短码单次使用、有有效期且受限流保护;审批页面明确展示设备、组织、 项目及申请权限。
Go 的 IdentityProvider 边界将提供方认证标准化为
issuer、subject、已验证 claims 和凭据有效期。
独立身份服务进一步解析为 UserID 和已批准组织成员关系。
业务模块只接收内部主体和授权决策。
非标准企业适配器先验证企业签名或会话、受众及有效期,再做标准化,
不能任意断言内部用户 ID 或角色。原始企业 token 只停留在适配器内。
管理员配置明确优先级和拒绝规则的用户组到角色映射。 首次登录验证 issuer 后才创建稳定用户; 邮箱只用于展示或联系,不能作为身份主键。 用户组变化、停用事件更新成员/认证版本,撤销 refresh token 并使活跃设备会话失效。 周期性对账补偿遗漏事件,并生成可审计差异报告。
建议 access token 有效期为五分钟;refresh token 轮换且在服务端以 hash 保存, 检测到重用则撤销整个 token family。每台设备独立记录撤销状态和最近活动时间。 服务账号是独立主体,限制项目 scope 并支持轮换,不能使用交互接入码。 凭据存放在工作区外,不嵌入接入命令、日志或快照。
身份服务无法验证时,新认证、接入、刷新和写操作全部拒绝。 已授权的非密钥缓存资源仅可在建议的 15 分钟授权租约到期前使用; 密钥获取和新的高权限操作始终要求在线授权。 离线撤销不能抹去已读取的信息,文档及验收必须承认这一边界。 下次成功检查到撤销后清理本地物化内容属于尽力而为,不是远程擦除保证。
8. CLI 同步与贡献流程
管理后端适配器将完整验证的快照物化到每个绑定/revision 的不可变缓存目录,
提供兼容资源树。实现需要明确的后端 capability/schema 扩展,
不能伪装成 repo.kind: git,也不能复用现有 ClawPro 的 repo.kind: http 语义。
同步先在暂存区下载并验证完整 manifest、blob、来源权限和删除集合。
原子提升不可变缓存只更新 downloaded_revision,不更新 applied_revision。
本地工具配置与召回索引横跨多个处理器和文件系统,不能组成单个原子事务;
应用期间可能暂时出现新旧内容混合。
修改目标前,在工作区锁内持久化 apply journal,记录绑定、目标 revision、 操作 ID、目标路径、预期旧 hash、新 hash/删除操作及每步状态。 适配器只有具备可幂等执行的逐目标操作后才能接入该流程。 每步检查目标的实际 hash,在支持的地方使用原子替换,再记录完成状态。 若写入后、journal 更新前崩溃,恢复时目标匹配新 hash 即确认该步已完成。 不匹配且属于非受管内容或用户修改的 hash 必须成为冲突,不能静默覆盖。
处理器或索引重建失败时记录 PARTIAL 并保留 journal。
只有全部目标操作及索引重建成功后,才推进 applied_revision 和活跃召回索引指针。
在此之前 CLI 展示部分应用状态及待处理/冲突目标,不宣称同步成功。
旧索引只有在其授权租约仍有效时才可继续使用。
重启后从 journal 恢复,并在每个敏感操作前重新检查授权;
撤销后转为 BLOCKED_AUTHORIZATION。
不承诺整个工作区回滚。下载失败保持原已应用状态。
写盘前拒绝符号链接、绝对路径、路径穿越、Windows 盘符/UNC 路径、 保留名称及大小写不敏感名称冲突。
物化的 teamai.yaml、manifest/roles.yaml 和 manifest/projects.yaml
是从已授权服务端记录生成的兼容视图,不能把任意资源 blob 当成授权依据。
绑定上下文提供 dataHome 和 projectRoot,不要求执行 git rev-parse。
self 模式和普通 Git 来源维持现有行为。
push 时,处理器扫描本地候选并将文件写入临时树, manifest 差异转换为上传 blob 和变更集。 本地复制不能隐式发布资源。 处理器尚不支持的写操作,在明确实现前应通过控制台编辑。 hooks 和 MCP 仍遵循客户端策略及用户同意, 资源下发本身不是执行远程命令的授权。
ownership ledger 记录 provider、绑定、资源 ID/版本、目标路径和上次应用 hash。 删除或切换项目只能移除仍匹配该 hash 的受管内容, 保留个人修改并报告冲突。 自动回退到其他来源前,多个 provider 必须共用 ownership 和冲突裁决层。 在该层实现之前,不能承诺移除 HTTP provider 后会恢复其他 provider 的内容。
遥测在入队前持久化 event_id 和每设备序号。
其去重 ledger 独立于 HTTP 响应缓存,保留期覆盖公布的最长离线窗口及重试窗口。
ledger 压缩后,过期事件 ID/已关闭序号窗口返回
410 EVENT_WINDOW_EXPIRED,要求明确对账;CLI 不能给旧事件换新 ID。
持久确认标识已接收的事件 revision,避免重试静默重复计数。
恢复会话后的修正引用原事件/会话并替换其 revision,
不能再次递增成功会话总数。
Learning 属于可审核内容;投票和使用事件不能编辑已发布资源。
离线队列限制容量,对敏感内容加密,对用户可观察;
只有确认事务完成或用户明确选择后才可丢弃。
9. Go monorepo 与存储边界
以一个可部署 Go 服务和一个事务边界起步:
server/
go.mod
cmd/teamai-server/
internal/
identity/
organizations/
projects/
resources/
changesets/
reviews/
sync/
telemetry/
audit/
platform/
migrations/
api/
tests/
platform 提供 HTTP 中间件、配置、时钟、ID 及数据库基础设施。
领域包依赖窄接口:MetadataRepository、BlobStore、
TransactionManager、EventPublisher、IdentityProvider 和 AuditSink。
跨域流程通过应用服务编排,HTTP handler 不直接访问其他模块的数据表。
建议的参考部署使用 PostgreSQL 元数据和兼容 S3 的 blob 存储。 一个事务同时更新 revision、审核、项目 head、幂等结果及 audit/outbox 记录。 blob 上传先于发布,未引用暂存 blob 到期清理。 outbox 分发可以重试,但重复事件不能创建第二次 release。 替换存储实现必须通过相同并发、持久化和隔离测试; 内存适配器仅作为测试夹具,不代表生产持久化。
设计获批后,server/api 保存 OpenAPI 和兼容性夹具;
server/tests 使用真实数据库/blob 适配器及模拟身份提供方。
本设计 PR 不创建 server 目录或引入依赖。
10. 安全与运维
每条读写路径、导出、来源订阅、blob 下载和上报都执行租户及项目授权。 密钥值保存在独立加密密钥服务中,仅在下发时为已授权设备解析。 审计和遥测默认对密钥及内容脱敏。 现有 env 资源映射为模板和密钥引用,不作为公开明文密钥。
上传执行类型验证、字节/数量限制、安全归档路径和完整性检查。 来源导入只允许批准的 HTTPS 源;除非租户明确配置,否则禁止跳转到私网, 并使用受限服务凭据。 服务端下发可执行插件或任意命令不属于新 API。 用户编写的 hooks/MCP 只有通过现有明确的信任及同意控制才能在本地执行。
限流覆盖主体、设备、组织及高成本操作,配合有界队列和背压。 若无法将审计记录或持久 outbox 与状态原子提交,则拒绝高权限修改; 遥测故障不阻断无关资源读取。 指标覆盖鉴权失败、发布延迟、同步滞后、队列年龄和 blob 错误, 不记录资源内容或无界用户/项目标签。日志和 trace 携带请求/操作 ID。
备份包含元数据、被引用的不可变 blob 和加密密钥材料。 恢复演练验证引用完整性,并验证 outbox 重放不会重复发布。 垃圾回收保留全部有效 release/草稿和活跃同步租约所需数据。 tombstone 保留期必须超过支持的离线/增量窗口, 超出窗口后客户端只能获取完整快照。 具体保留周期、RPO/RTO、密钥轮换和数据驻留区域仍需部署方明确决策。
11. 分阶段实施与验收
| 阶段 | 交付物 | 阶段验收 |
|---|---|---|
| P0 | 本中英设计,确认第 12 节决策 | 完成能力映射、四条旅程及协议语义评审 |
| P1 | 身份、组织/项目成员、schema 与契约 | 租户隔离、Device Flow、token 撤销及并发测试通过 |
| P2 | 只读快照、物化、绑定隔离与召回 | 未安装 Git 的干净机器完成 J1/J2/J3 读取及离线恢复 |
| P3 | 全部资源写入、审核、发布、回滚及来源引用 | 精确摘要审核、跨项目原子发布和保护所有权的删除通过 |
| P4 | Learnings、投票、使用/会话上报及管理端导入 | 幂等重放、会话修正、完整能力往返及可审计导入通过 |
| P5 | Web 控制台、运维加固及试点迁移 | 通过控制台完成 J4,通过恢复演练和部署安全评审 |
P2 是只读试点,不是 Git 的完整替代。 所有映射能力通过验收后才能宣称完整零 Git 管理。 调整顺序时必须明确更新依赖关系及验收证据。
| ID | 必须提供的验收证据 |
|---|---|
| A01 | 第 2 节每行均覆盖导入/读取/修改/审核/发布/删除/恢复,或明确适用的上报生命周期;不能静默遗漏路径 |
| A02 | J1:OIDC 和非标准企业适配器产生相同内部主体结构;伪造 issuer/用户组映射被拒绝 |
| A03 | J2:Git 不可用机器上的浏览器及无浏览器接入;过期、复用、拒绝的短码不能创建绑定 |
| A04 | J3:两个组织、多个项目和工作区;未授权资源不能进入 manifest、缓存、召回索引或 blob 响应 |
| A05 | J4:双项目发布期间并发改变成员权限/head;全部 head 同时改变或全部不变 |
| A06 | 审核后修改内容、用新请求体重用幂等 key、提交过期 ETag;按约定错误码拒绝 |
| A07 | 中断下载、缓存提升、每个目标写入、journal 确认及索引重建;区分 downloaded/applied revision,展示 PARTIAL,按 hash 恢复且不覆盖个人修改 |
| A08 | 登录、刷新、同步过程中撤销用户/用户组/设备;执行在线检查及文档规定的离线租约边界 |
| A09 | 拒绝畸形归档、无效 hash/签名、含密钥日志及跨租户游标/blob 访问 |
| A10 | 有个人修改和重叠来源时删除/切换/卸载;保留非受管文件并展示冲突 |
| A11 | 丢失首次修改响应,并在 24 小时响应缓存过期后重放;按客户端操作 ID 恢复。跨支持的离线窗口重放/修正遥测不重复计数,历史/事件过期后必须明确对账 |
| A12 | 备份恢复、密钥轮换和 outbox 重放;保留的 release 可复现且不会重复发布 |
| A13 | 中英章节、API 名称、状态机、阶段及验收 ID 等价 |
12. 开放决策与非目标
实现前由维护者确认:参考存储与部署方式;SSO 提供方及用户组同步机制; 租户管理员权限;资源覆盖策略和跨项目来源优先级;审核人数及紧急回滚规则; 密钥下发和存储;所有大小、时间和保留限制;身份故障与离线租约策略; 签名 manifest 的密钥分发;迁移归属,以及与待合入 Provider 抽象的兼容方式。
本 PR 明确不实现 Go 服务、Web 控制台,不改变 CLI 行为, 不宣称生产 OAuth/安全认证,也不修改描述当前行为的 usage guide。 微服务、跨组织原子发布、任意远程执行、面向用户模拟 Git 历史和透明离线撤销, 均不是完成本设计的要求。
HTTP 条件请求遵循 RFC 9110, 设备授权遵循 RFC 8628。 实现必须用独立客户端验证契约,不能只依赖服务端自行定义的模型。