fix(orc): support trusted release shell workflow
This commit is contained in:
@@ -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 指向另一个实例时,停止对应平台阶段并报告:
|
||||
|
||||
|
||||
@@ -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 host;GitHub 额外允许其固定 API
|
||||
与 release upload host,其它本地阶段默认继续禁网。
|
||||
|
||||
## Worker 档位
|
||||
|
||||
|
||||
+19
-7
@@ -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 CLI;Gitea/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。实际启动会
|
||||
重新校验 fingerprint;release 阶段允许受信任的 `$manage-release` 直接使用当前 shell
|
||||
已有的 Git/Forge 登录,resolver 不检查或复制认证文件。显式 token/SSH 认证仍只注入所选
|
||||
环境变量。不得改写 resolver 返回的 worker argv,也不得把终端创建 argv 的绝对 Orca
|
||||
路径换成项目 `PATH` 解析。
|
||||
- 不把模型档位当作权限。`high` 不自动获得更多文件、凭据、网络或远端写权限。
|
||||
- 不执行 `orca orchestration reset`,除非用户明确要求放弃全部相关运行时状态。
|
||||
|
||||
|
||||
@@ -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`。宿主身份不明确时停止。
|
||||
|
||||
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user