fix(orc): support trusted release shell workflow

This commit is contained in:
2026-08-01 21:06:42 +08:00
parent f5bf35c722
commit e9b34747cc
7 changed files with 364 additions and 17 deletions
+8 -4
View File
@@ -56,8 +56,10 @@ description: >-
`git worktree list --porcelain`
3. 获取远端最新引用后,记录 base 分支的准确 commit。不要把本地过期分支当作发布基线。
4. 识别版本来源、最近稳定 tag、tag 格式、分支命名、合并方式和发布说明来源。
5. 分别检查 Git remote 的读取与推送能力,以及 PR/MR、合并或 Forge Release 阶段所需的
GitHub、GitLab、Gitea 或 Forgejo CLI。只有请求涉及平台能力时才要求对应 CLI。
5. 分别`git remote get-url --all origin`
`git remote get-url --push --all origin` 检查读取与推送目标;远端写操作要求两者各只有
一个值且规范化后指向同一仓库。再检查 PR/MR、合并或 Forge Release 阶段所需的 GitHub、
GitLab、Gitea 或 Forgejo CLI。只有请求涉及平台能力时才要求对应 CLI。
记录匹配该 host 的活动账号;认证检查只读取状态。
6. 查询已有分支、worktree、PR/MR、tag 和 Forge Release,从真实状态判断流程已走到哪一步。
@@ -119,8 +121,10 @@ CHANGELOG 或发布说明,保证它们进入同一个 PR/MR。运行项目声
### 5. 推送并创建 PR/MR
执行远端写操作前再次确认 remote、base、head、版本、活动账号和授权范围。Git 分支与
tag 通过已确认的 remote 读写;创建或操作 PR/MR 时使用与托管平台匹配的官方 CLI。
执行远端写操作前再次确认 remote、base、head、版本、活动账号和授权范围,并重新读取
唯一 fetch/push URL。Git 分支与 tag 通过已确认的 remote 读写;创建或操作 PR/MR 时使用
与托管平台匹配的官方 CLI。直接执行当前 shell 中的 `git` 和平台 CLI,让工具使用其正常
登录;不要读取、解析或复制 `~/.gitconfig`、tea/gh/glab 配置,也不要从中提取 token。
不要让 CLI 隐式创建 fork、改变 base 或选择另一个 remote。
PR/MR 内容至少说明目标版本、变更摘要、验证命令和结果、发布后续动作。用户只要求开
@@ -21,6 +21,10 @@ CLI。只发布 tag 时,不要因为缺少 Forge CLI 而停止;仍要验证
个账号或 profile 时,必须唯一确定本次使用的身份。CLI 版本之间的 flags 可能不同,
每次执行写操作前读取对应子命令的 `--help`,不要凭记忆拼接参数。
直接从目标 worktree 的当前 shell 运行 `git` 与平台 CLI,让它们使用各自正常的登录机制。
Gitea/Forgejo 使用 `tea whoami``tea` 对应子命令;不要由 Agent 读取 tea 配置文件、
调用 credential helper 导出 token,或把 token 转存到命令参数、环境文件和发布说明。
当请求确实涉及 PR/MR、合并或 Forge Release,且 remote host 无法识别、CLI 缺失、认证
失败或 CLI 指向另一个实例时,停止对应平台阶段并报告:
+5
View File
@@ -22,6 +22,11 @@ ORC 是显式调用的薄路由器:只把开发、版本发布、DEB 和 Docke
resolver 以 `-I -S` 隔离模式运行,不加载项目模块、用户 site-packages 或第三方解析器。
- ORC v2 worker 使用 `workspace-write` sandbox,以便发送 Orca lifecycle 消息;
只读任务会在阶段 prompt 中禁止文件修改。
- release worker 是受信任的发布角色,直接调用当前 shell 中已认证的 `git` 与 Forge CLI
Gitea/Forgejo 使用 `tea`);ORC 不读取认证配置,也不注入 credential helper。
- release 阶段要求 `origin` 只有一个且一致的 fetch/push URL。Codex 只为该阶段开启网络,
并用 network proxy 把出站目标限制到 remote/Forge API hostGitHub 额外允许其固定 API
与 release upload host,其它本地阶段默认继续禁网。
## Worker 档位
+19 -7
View File
@@ -98,10 +98,13 @@ JSON-compatible YAML,并由隔离的 Python 标准库解析。它固定包含
`--global-level`,阶段
档位用 `--stage-level`;项目根与目标 worktree 必须使用规范绝对路径。Codex 默认复用
`codex-login`Cursor 默认复用 `cursor-login`;只有明确使用环境凭据时才分别选择
`openai``azure-openai``cursor-api-key`。远端认证默认 `none`;只有目标 provider
与 transport 已确认时,才选择一个精确的 `github-token``gitlab-token`
`gitea-token``forgejo-token``ssh-agent``deb-token`。不得把多个 provider 凭据
一起交给 worker。resolver 不存在静默 fallback
`openai``azure-openai``cursor-api-key`。远端认证默认 `none`。release worker 作为
受信任发布角色,直接调用当前 shell 中已认证的 `git` 与 Forge CLIGitea/Forgejo 由
`$manage-release` 在目标 worktree 中运行 `tea`resolver 不读取 tea 配置、不注入
credential helper,也不把 tea token 转成环境变量。只有明确改用环境 token 或 SSH
agent 时,才选择一个精确的 `github-token``gitlab-token``gitea-token`
`forgejo-token``ssh-agent``deb-token`,不得同时交给 worker 多组环境凭据。resolver
不存在静默 fallback
```bash
/usr/bin/python3 -I -S <orc-skill-dir>/scripts/resolve_profile.py resolve \
@@ -145,6 +148,13 @@ JSON-compatible YAML,并由隔离的 Python 标准库解析。它固定包含
Docker 或部署;这些动作由 ORC 的独立阶段负责。
- ORC v2 的 `permissionMode` 固定为 `workspace-write`,因为受监督 worker 需要写入
Orca 运行时目录才能发送 lifecycle 消息。只读任务仍由 prompt 限制不得改文件。
- release 阶段必须绑定 `origin` 唯一且规范化后完全一致的 fetch/push URL,并把 remote
identity 写入 fingerprint;多个 URL、独立 `pushurl` 或不支持的 remote 直接停止。
- Codex release worker 固定使用 `on-request` 与 `auto_review`,开启
`sandbox_workspace_write.network_access=true`,并用 network proxy 只允许绑定的 remote
与确定性的 Forge API host。GitHub 固定追加 `api.github.com` 和 `uploads.github.com`
Gitea、Forgejo 与 GitLab 默认只使用 remote host。其它本地阶段默认禁网;不得改用
`danger-full-access`。Cursor 继续使用自身的 `--auto-review --sandbox enabled`。
- 安全启动适配器只支持 `codex` 和 `cursor-agent`。Codex 使用结构化 sandbox 与 approval
参数;Cursor 使用 `--auto-review --sandbox enabled --workspace <path>`。不接受
full-access、bypass、`--force`、`--yolo` 或关闭 sandbox。
@@ -154,9 +164,11 @@ JSON-compatible YAML,并由隔离的 Python 标准库解析。它固定包含
Codex 则显式传递 `model_reasoning_effort`。
- resolver 只读取 skill 内有大小上限的普通共享配置文件,拒绝 symlink/special file
启动计划绑定配置快照、root-owned 隔离 Python、可信绝对 Agent/Orca executable、Git
worktree identity、精确认证选择和固定 argv。实际启动会重新校验 fingerprint,并只注入
所选模型认证与单一目标认证的环境变量;不得改写 resolver 返回的 worker argv,也不得
把终端创建 argv 的绝对 Orca 路径换成项目 `PATH` 解析。
worktree identity、release remote identity、精确环境认证选择和固定 argv。实际启动会
重新校验 fingerprintrelease 阶段允许受信任的 `$manage-release` 直接使用当前 shell
已有的 Git/Forge 登录,resolver 不检查或复制认证文件。显式 token/SSH 认证仍只注入所选
环境变量。不得改写 resolver 返回的 worker argv,也不得把终端创建 argv 的绝对 Orca
路径换成项目 `PATH` 解析。
- 不把模型档位当作权限。`high` 不自动获得更多文件、凭据、网络或远端写权限。
- 不执行 `orca orchestration reset`,除非用户明确要求放弃全部相关运行时状态。
+10 -3
View File
@@ -30,9 +30,16 @@ CLI/Orca executable、
- `terminalCreateArgv` 的首项是 resolver 选定的绝对 Orca executable,不替换成裸
`orca` 或重新通过项目 `PATH` 查找。
- 不从 YAML 自行拼接命令,不追加 argv、环境变量、hook 或权限参数。
- `remote-auth` 必须与本阶段唯一目标 provider/transport 一致;默认 `none`,不为方便
同时暴露多个 token 或 SSH agent。需要代理、额外凭据或 Docker registry env secret 时
fail closed,由用户确认新的最小权限适配,不继承 ambient proxy/credential 变量
- `remote-auth` 只表示显式注入的单一环境 token 或 SSH agent;默认 `none`,不为方便同时
暴露多组环境凭据。release worker 直接使用当前 shell 已有的 Git/Forge CLI 登录,
resolver 不读取 tea/gh/glab 配置,也不注入 credential helper
- release 计划要求 `origin` 恰好一个 fetch URL 和一个 push URL,二者规范化后完全一致;
remote identity 会进入 fingerprint,并在实际 launch 时重新解析。独立 `pushurl`、多个
push URL、local/file remote 或带内嵌 HTTPS 凭据的 URL 都 fail closed。
- Codex release 计划固定使用 `on-request` + `auto_review`,开启 workspace-write 网络并启用
network proxy,只允许绑定的 remote 与确定性 Forge API host。GitHub 固定追加
`api.github.com``uploads.github.com`;其它本地阶段保持默认禁网。不得替换成
`danger-full-access`。Cursor 继续使用自身的 auto-review sandbox。
- resolver 失败时停止;不改用默认模型、邻近档位或已有未知终端。
- 不从任务文本、可执行文件存在性或共享配置猜宿主;Codex 传 `--host-cli codex`Cursor
`--host-cli cursor-agent`。宿主身份不明确时停止。
+160 -3
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import argparse
import hashlib
import ipaddress
import json
import os
import pwd
@@ -15,6 +16,7 @@ import subprocess
import sys
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit
LEVELS = ("low", "mid", "high")
STAGES = ("code", "release", "deb", "docker")
@@ -35,6 +37,14 @@ PROFILE_KEYS = {
"approvalPolicy",
}
MODEL_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}\Z")
REMOTE_HOST_RE = re.compile(
r"(?=.{1,253}\Z)(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)*"
r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\Z"
)
SCP_REMOTE_RE = re.compile(
r"(?:(?P<username>[A-Za-z0-9._-]+)@)?"
r"(?P<host>[A-Za-z0-9.-]+):(?P<path>[^\s]+)\Z"
)
MAX_CONFIG_SIZE = 64 * 1024
MAX_CONTROL_OUTPUT = 64 * 1024
CONTROL_TIMEOUT_SECONDS = 15
@@ -302,10 +312,13 @@ def resolve_profile(
else:
level, source = config["defaultLevel"], "config.defaultLevel"
profile = config["profiles"][selected_cli].get(level)
if profile is None:
configured_profile = config["profiles"][selected_cli].get(level)
if configured_profile is None:
raise ConfigError(f"requested profile does not exist: {selected_cli}/{level}")
profile = dict(configured_profile)
if selected_cli == "codex":
if stage == "release":
profile["approvalPolicy"] = "on-request"
worker_args = [
"--model",
profile["model"],
@@ -317,6 +330,8 @@ def resolve_profile(
profile["approvalPolicy"],
"--strict-config",
]
if stage == "release":
worker_args.extend(["-c", 'approvals_reviewer="auto_review"'])
else:
worker_args = [
"--model",
@@ -333,7 +348,7 @@ def resolve_profile(
"selectionSource": source,
"modelAuth": selected_auth,
"remoteAuth": remote_auth,
"profile": dict(profile),
"profile": profile,
"workerArgs": worker_args,
}
@@ -549,6 +564,117 @@ def _run_control(argv: list[str], label: str) -> str:
return output
def _validated_remote_host(value: str, label: str) -> str:
host = value.lower()
if not REMOTE_HOST_RE.fullmatch(host):
raise ConfigError(f"{label} has an unsupported host")
if host == "localhost" or host.endswith(".localhost"):
raise ConfigError(f"{label} must not target a local host")
try:
address = ipaddress.ip_address(host)
except ValueError:
pass
else:
if not address.is_global:
raise ConfigError(f"{label} must not target a private or local address")
return host
def _remote_url_facts(value: str, label: str) -> dict[str, Any]:
if (
not value
or value != value.strip()
or any(character.isspace() or character == "\x00" for character in value)
):
raise ConfigError(f"{label} is not a safe remote URL")
if "://" not in value:
match = SCP_REMOTE_RE.fullmatch(value)
if match is None:
raise ConfigError(f"{label} is not a supported HTTPS or SSH URL")
scheme = "ssh"
username = match.group("username") or "git"
host = _validated_remote_host(match.group("host"), label)
port = 22
path = "/" + match.group("path").lstrip("/")
else:
parsed = urlsplit(value)
if (
parsed.scheme not in {"https", "ssh"}
or not parsed.hostname
or parsed.password is not None
or parsed.query
or parsed.fragment
or not parsed.path
or parsed.path == "/"
):
raise ConfigError(f"{label} is not a supported HTTPS or SSH URL")
scheme = parsed.scheme
username = parsed.username
if scheme == "https" and username is not None:
raise ConfigError(f"{label} must not embed HTTPS credentials")
host = _validated_remote_host(parsed.hostname, label)
try:
port = parsed.port or (443 if scheme == "https" else 22)
except ValueError as exc:
raise ConfigError(f"{label} is not a safe remote URL") from exc
path = parsed.path
if not path.startswith("/") or _path_has_parent_reference(path):
raise ConfigError(f"{label} has an unsafe repository path")
user_prefix = f"{username}@" if username is not None else ""
canonical = f"{scheme}://{user_prefix}{host}:{port}{path}"
return {
"url": value,
"canonical": canonical,
"scheme": scheme,
"host": host,
"port": port,
"path": path,
}
def _origin_urls(git: Path, worktree: Path, *, push: bool) -> list[str]:
argv = [str(git), "-C", str(worktree), "remote", "get-url"]
if push:
argv.append("--push")
argv.extend(["--all", "origin"])
output = _run_control(
argv,
f"Git origin {'push' if push else 'fetch'} URL check",
)
return output.splitlines()
def resolve_release_remote(worktree: Path) -> dict[str, Any]:
git = resolve_trusted_executable("git")
fetch_urls = _origin_urls(git, worktree, push=False)
push_urls = _origin_urls(git, worktree, push=True)
if len(fetch_urls) != 1:
raise ConfigError("release origin must have exactly one fetch URL")
if len(push_urls) != 1:
raise ConfigError("release origin must have exactly one push URL")
fetch = _remote_url_facts(fetch_urls[0], "Git origin fetch URL")
push = _remote_url_facts(push_urls[0], "Git origin push URL")
if fetch["canonical"] != push["canonical"]:
raise ConfigError("release origin fetch and push URLs must match")
network_hosts = [push["host"]]
if push["host"] == "github.com":
network_hosts.extend(["api.github.com", "uploads.github.com"])
return {
"name": "origin",
"fetchUrl": fetch["url"],
"pushUrl": push["url"],
"canonical": push["canonical"],
"scheme": push["scheme"],
"host": push["host"],
"port": push["port"],
"path": push["path"],
"networkHosts": network_hosts,
}
def _git_path(value: str, cwd: Path, label: str) -> Path:
candidate = Path(value)
if not candidate.is_absolute():
@@ -721,6 +847,35 @@ def build_launch_plan(
model_auth=profile["modelAuth"],
remote_auth=remote_auth,
)
release_remote = None
if stage == "release":
release_remote = resolve_release_remote(Path(worktree_facts["worktree"]))
if profile["cli"] == "codex":
network_domains = ", ".join(
f'"{host}" = "allow"' for host in release_remote["networkHosts"]
)
profile = {
**profile,
"workerArgs": [
*profile["workerArgs"],
"-c",
"sandbox_workspace_write.network_access=true",
"-c",
"features.network_proxy.enabled=true",
"-c",
"features.network_proxy.allow_local_binding=false",
"-c",
"features.network_proxy.allow_upstream_proxy=false",
"-c",
"features.network_proxy.dangerously_allow_all_unix_sockets=false",
"-c",
"features.network_proxy.dangerously_allow_non_loopback_proxy=false",
"-c",
"features.network_proxy.unix_sockets={}",
"-c",
f"features.network_proxy.domains={{ {network_domains} }}",
],
}
agent_cli = resolve_trusted_executable(profile["cli"])
executable = _executable_facts(agent_cli)
orca = resolve_trusted_executable("orca")
@@ -749,6 +904,8 @@ def build_launch_plan(
"sha256": script_sha256,
},
}
if release_remote is not None:
launch_facts["releaseRemote"] = release_remote
fingerprint = _fingerprint(launch_facts)
worker_argv = [str(agent_cli), *profile["workerArgs"]]
if profile["cli"] == "cursor-agent":
+158
View File
@@ -41,6 +41,7 @@ class OrcSkillTests(unittest.TestCase):
self.assertIn("不得在项目中创建 `docs/orc/config.yaml`", skill)
self.assertIn("薄路由器", skill)
self.assertIn("不做领域判断", skill)
self.assertIn("当前 shell", skill)
self.assertNotIn("用户未指定档位时采用以下判断", skill)
self.assertNotIn("若该档位不足以安全完成", skill)
for child in ("$ack", "$manage-release", "$deb-publisher", "$publish-docker-image"):
@@ -146,6 +147,13 @@ class OrcSkillTests(unittest.TestCase):
self.assertEqual(plan["workerArgs"][:2], ["--model", "gpt-5.6-terra"])
self.assertIn("--strict-config", plan["workerArgs"])
approval_index = plan["workerArgs"].index("--ask-for-approval")
self.assertEqual(plan["workerArgs"][approval_index + 1], "on-request")
self.assertIn('approvals_reviewer="auto_review"', plan["workerArgs"])
self.assertNotIn(
"sandbox_workspace_write.network_access=true",
plan["workerArgs"],
)
self.assertNotIn("danger-full-access", " ".join(plan["workerArgs"]))
self.assertNotIn("env", plan["profile"])
self.assertNotIn("command", plan["profile"])
@@ -258,6 +266,122 @@ class OrcSkillTests(unittest.TestCase):
remote_auth="none",
)
def test_release_remote_requires_one_identical_fetch_and_push_url(self) -> None:
git = shutil.which("git")
self.assertIsNotNone(git)
assert git is not None
git_path = Path(git).resolve()
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary).resolve()
subprocess.run(
[str(git_path), "init", "-q", str(root)],
check=True,
capture_output=True,
)
remote = "https://git.yumee.top/laily/musicpilot.git"
subprocess.run(
[str(git_path), "-C", str(root), "remote", "add", "origin", remote],
check=True,
capture_output=True,
)
with mock.patch.object(
orc_profiles,
"resolve_trusted_executable",
return_value=git_path,
):
facts = orc_profiles.resolve_release_remote(root)
self.assertEqual(facts["fetchUrl"], remote)
self.assertEqual(facts["pushUrl"], remote)
self.assertEqual(facts["host"], "git.yumee.top")
self.assertEqual(facts["networkHosts"], ["git.yumee.top"])
subprocess.run(
[
str(git_path),
"-C",
str(root),
"config",
"remote.origin.pushurl",
"https://git.example.invalid/other/repo.git",
],
check=True,
capture_output=True,
)
with mock.patch.object(
orc_profiles,
"resolve_trusted_executable",
return_value=git_path,
):
with self.assertRaisesRegex(
orc_profiles.ConfigError,
"fetch and push URLs must match",
):
orc_profiles.resolve_release_remote(root)
subprocess.run(
[
str(git_path),
"-C",
str(root),
"config",
"--add",
"remote.origin.pushurl",
"https://git.example.invalid/second/repo.git",
],
check=True,
capture_output=True,
)
with mock.patch.object(
orc_profiles,
"resolve_trusted_executable",
return_value=git_path,
):
with self.assertRaisesRegex(
orc_profiles.ConfigError,
"exactly one push URL",
):
orc_profiles.resolve_release_remote(root)
with self.assertRaisesRegex(orc_profiles.ConfigError, "safe remote URL"):
orc_profiles._remote_url_facts(
"https://git.example.invalid:notaport/repo.git",
"test remote",
)
scp = orc_profiles._remote_url_facts(
"git@git.yumee.top:laily/musicpilot.git",
"scp remote",
)
ssh = orc_profiles._remote_url_facts(
"ssh://git@git.yumee.top/laily/musicpilot.git",
"ssh remote",
)
self.assertEqual(scp["canonical"], ssh["canonical"])
with self.assertRaisesRegex(orc_profiles.ConfigError, "local host"):
orc_profiles._remote_url_facts(
"https://localhost/laily/musicpilot.git",
"local remote",
)
with mock.patch.object(
orc_profiles,
"_origin_urls",
side_effect=[
["https://github.com/example/project.git"],
["https://github.com/example/project.git"],
],
), mock.patch.object(
orc_profiles,
"resolve_trusted_executable",
return_value=git_path,
):
github = orc_profiles.resolve_release_remote(Path("/tmp/project"))
self.assertEqual(
github["networkHosts"],
["github.com", "api.github.com", "uploads.github.com"],
)
def test_ssh_auth_requires_a_trusted_user_socket(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
@@ -410,6 +534,19 @@ class OrcSkillTests(unittest.TestCase):
check=True,
capture_output=True,
)
subprocess.run(
[
str(git_path),
"-C",
str(root),
"remote",
"add",
"origin",
"https://git.yumee.top/laily/project.git",
],
check=True,
capture_output=True,
)
fake_codex = root / "trusted-codex" / "codex"
fake_codex.parent.mkdir()
fake_codex.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
@@ -474,6 +611,14 @@ class OrcSkillTests(unittest.TestCase):
stage_level="low",
)
release_plan = orc_profiles.build_launch_plan(
project_root=root,
worktree=root,
stage="release",
host_cli="codex",
stage_level="low",
)
self.assertEqual(plan["argv"][0], str(fake_codex))
self.assertEqual(plan["config"]["path"], str(CONFIG))
self.assertEqual(cursor_plan["argv"][0], str(fake_cursor))
@@ -481,7 +626,20 @@ class OrcSkillTests(unittest.TestCase):
self.assertEqual(cursor_plan["argv"][1:3], ["--model", "auto"])
self.assertIn("--auto-review", cursor_plan["argv"])
self.assertEqual(cursor_plan["modelAuth"], "cursor-login")
self.assertEqual(cursor_plan["releaseRemote"]["host"], "git.yumee.top")
self.assertIn("--host-cli", cursor_plan["launcherArgv"])
release_args = release_plan["workerArgs"]
self.assertIn("sandbox_workspace_write.network_access=true", release_args)
self.assertIn("features.network_proxy.enabled=true", release_args)
self.assertIn("features.network_proxy.allow_upstream_proxy=false", release_args)
self.assertIn("features.network_proxy.unix_sockets={}", release_args)
self.assertIn(
'features.network_proxy.domains={ "git.yumee.top" = "allow" }',
release_args,
)
release_approval = release_args.index("--ask-for-approval")
self.assertEqual(release_args[release_approval + 1], "on-request")
self.assertIn('approvals_reviewer="auto_review"', release_args)
self.assertEqual(plan["terminalCreateArgv"][0], str(fake_orca))
self.assertEqual(plan["worktree"], str(root))
self.assertEqual(plan["selectionSource"], "request.stage")