diff --git a/bin/lc b/bin/lc index c866571..bd5288c 100755 --- a/bin/lc +++ b/bin/lc @@ -714,6 +714,33 @@ def _targets(reg, argv, cmd): return names +def _default_target(reg): + """只要「默认上游」这一个名字,解析不了就干净地失败。 + + 不能复用 `_targets()`:那个函数把 argv 当上游名解析,而 `lc code` 的 argv 是 + 要原样透传给 Codex 的参数(`--sandbox read-only` 之类)。但两条失败路径和 + 措辞必须一致——原来 `cmd_code` 一条都没有,`reg.get("default")` 是 None 时 + 直接进了 subprocess.call 的参数列表,以 `TypeError: expected str ... not + NoneType` 的 traceback 收场(issue #47)。这个状态不难到达:`lc rm` 掉最后 + 一个上游会把 default 置回 None,而网关还在跑着。 + + 第三条是「default 指向一个已经不存在的上游」(手改 registry 之后)。它不会 + 炸,但更坏:Codex 对不存在的 profile 是**静默回落**到默认 model 的,表现成 + 「以为在用 A,其实在用 B」——这个项目在别处一直把这种静默回落当成缺陷。 + """ + ups = reg.get("upstreams") or {} + if not ups: + die("registry.json 里还没有上游,先跑 `lc init`") + avail = ", ".join(sorted(ups)) + name = reg.get("default") + if not name: + die("没有默认上游,用 `lc use ` 选一个。可用: %s" % avail) + if name not in ups: + die("默认上游 '%s' 不在 registry 里了,用 `lc use ` 重新选一个。" + "可用: %s" % (name, avail)) + return name + + def cmd_test(argv): reg = load_registry() rc = 0 @@ -776,7 +803,7 @@ def cmd_code(argv): reg = load_registry() if not gw_alive(reg): die("网关没起来,先跑 `lc up`") - profile = reg.get("default") + profile = _default_target(reg) sys.exit(subprocess.call(["codex", "--profile", profile] + list(argv), env=codex_env())) diff --git a/scripts/test-lc-commands.sh b/scripts/test-lc-commands.sh index cd831a5..63ca181 100644 --- a/scripts/test-lc-commands.sh +++ b/scripts/test-lc-commands.sh @@ -5,6 +5,7 @@ # - `lc up` 的两条失败路径(issue #34 compose 退出码 / issue #35 等待预算) # - 网关端口的唯一真源(issue #40:registry.json 的 gateway_port 必须一路生效到 # compose 的端口映射、codex 的 base_url 和 `lc test`,而不是各读各的) +# - `lc code` 解析不了默认上游时干净失败(issue #47:None 一路进 subprocess) # # 第三梯队按 issue 里的分级刻意写得轻:test / e2e 本身就是测试入口,这里不去测 # 「测试跑得对不对」,只测它们**解析不了目标时干净失败**;logs 只测参数拼装; @@ -675,6 +676,63 @@ else cat "$WORK/log-e2e-env" fail "e2e 压根没走到启动 Codex 那一步,这条断言等于没测" fi + +echo "[6f] lc code 解析不了默认上游时干净失败(issue #47)" +# 和 [4b] 同一个形态,只是 lc code 走的不是 _targets()——原来 reg["default"] 是 +# None 会一路进 subprocess.call,以 `TypeError: expected str ... not NoneType` +# 收场。先决条件是**网关活着**(否则被 [6a] 那条提前短路),所以这一节必须放在 +# 网关桩还没停之前。 +cp "$REG" "$WORK/reg.saved-code" +set_default() { # set_default + python3 - "$REG" "$1" <<'PY' +import json, sys +reg = json.load(open(sys.argv[1], encoding="utf-8")) +reg["default"] = json.loads(sys.argv[2]) +json.dump(reg, open(sys.argv[1], "w", encoding="utf-8"), indent=2, ensure_ascii=False) +PY +} + +set_default 'null' +: > "$CODEX_LOG" +if run_fail "$WORK/log-code-nodef" code; then + assert "lc code 说清了没有默认上游" "$WORK/log-code-nodef" "没有默认上游" + assert "并给出怎么选" "$WORK/log-code-nodef" "lc use " + assert "也列出可用的上游" "$WORK/log-code-nodef" "alpha, beta" +fi +# 这条比「非零退出」重要:非零退出靠一段 traceback 也能满足,`TypeError` 不出现 +# 才是这个 issue 的不变量(run_fail 已经查过 Traceback 那一行)。 +refute "lc code 没有 TypeError" "$WORK/log-code-nodef" "TypeError" +if [ -s "$CODEX_LOG" ]; then + cat "$CODEX_LOG" + fail "没有默认上游却还是把 Codex 启起来了" +else + pass "没有默认上游时压根没启动 Codex" +fi + +# default 指向一个已经不存在的上游(手改过 registry):不会炸,但更坏——Codex 对 +# 不存在的 profile 是静默回落到默认 model 的,表现成「以为在用 A 其实在用 B」。 +set_default '"ghost"' +: > "$CODEX_LOG" +if run_fail "$WORK/log-code-stale" code; then + assert "lc code 挡下失效的默认上游" "$WORK/log-code-stale" "默认上游 'ghost' 不在 registry 里" + assert "并列出可用的上游" "$WORK/log-code-stale" "alpha, beta" +fi +if [ -s "$CODEX_LOG" ]; then + cat "$CODEX_LOG" + fail "默认上游已失效,却带着一个不存在的 profile 把 Codex 启起来了" +else + pass "默认上游失效时没有拿不存在的 profile 去启动 Codex" +fi + +echo "[6g] 空注册表时 lc code 指向 lc init,和 lc test / lc e2e 一个措辞" +echo "{\"default\": null, \"gateway_port\": $PORT, \"upstreams\": {}}" > "$REG" +: > "$CODEX_LOG" +if run_fail "$WORK/log-code-empty" code; then + assert "lc code 在空注册表时指向 lc init" "$WORK/log-code-empty" "lc init" + refute "空注册表时不建议 lc use" "$WORK/log-code-empty" "lc use " +fi +cp "$WORK/reg.saved-code" "$REG" + stop_gw # ─────────────────────────────────────────────────────────────────────────