# 跨进程契约必须显式传 flag，不靠继承的 process.env

**Date**: 2026-05-19
**Module**: internal-app-platform / NestJS subprocess pattern
**Related**: PR #447 系列 / [2026-05-19-gitea-webhook-must-be-per-repo-or-org-drift-kills-deploys.md](2026-05-19-gitea-webhook-must-be-per-repo-or-org-drift-kills-deploys.md)

## 症状

PR #447 部署完，员工 push 触发 `itadmin-ffoa` 部署：
- DB status: PENDING → HEALTHY ✓（PR #447 修复生效）
- 容器: Up（健康跑着）
- URL `https://itadmin-ffoa.apps.ffworkspace.test.faradayfuturecn.com/` → **HTTP 200 空 body**

跟前面 carousel 一模一样的症状（[详见 gitea-webhook learning](2026-05-19-gitea-webhook-must-be-per-repo-or-org-drift-kills-deploys.md)）：nginx 兜底返空 200，**没经过 Caddy**。

## 根因

deploy-container.sh 写出的 Caddy site 配置用的是**生产域名**：

```
http://itadmin-ffoa.apps.ffworkspace.faradayfuture.com {  ← 生产
    reverse_proxy ffoa-app-itadmin-ffoa:3000
}
```

而员工访问的是 test 域名 `apps.ffworkspace.test.faradayfuturecn.com`（.cn），Caddy 没匹配的 site block → 兜底空响应。

`deploy-container.sh` 读 `$INTERNAL_APP_PUBLIC_DOMAIN` env：

```bash
APPS_DOMAIN="${INTERNAL_APP_PUBLIC_DOMAIN:-apps.ffworkspace.faradayfuture.com}"
```

test 服务器 `.env` 文件**显式**配了 `INTERNAL_APP_PUBLIC_DOMAIN=apps.ffworkspace.test.faradayfuturecn.com`。
但 `pm2 env 0` 查 backend 进程 env **拿不到这个变量**——脚本子进程继承自 backend，所以也拿不到 → fallback 生产域名。

## 真正的元根因：NestJS ConfigService 不写 process.env

NestJS 的 `ConfigService` 用 dotenv 读 `.env` 文件，但**只往 ConfigService 自己缓存放，不
往 process.env 写**（除非 ConfigModule 显式 `expandVariables: true` + 特殊配置）。

后果：
- `this.config.get<string>('INTERNAL_APP_PUBLIC_DOMAIN')` → 拿到 .env 的值 ✓
- `process.env.INTERNAL_APP_PUBLIC_DOMAIN` → 拿到 pm2 daemon 启动时 shell 的值（通常 undefined）✗

如果代码用 `process.env.XXX` 拿配置（而不是 ConfigService），会拿到 undefined → fallback 默认值。
**这是个隐式契约陷阱**：本地开发跑 `npm run start:dev` 时，shell 里通常有完整 env，`process.env` 能拿到正确值；pm2 启动时 shell 不一定有，就坏了。

`exec()` / `execFile()` 起的子进程**继承 `process.env`**，不继承 ConfigService（它是 nest 进程内的对象）。所以子进程也拿不到真值。

## 修复

**让子进程契约显式化**——通过 CLI flag 传，而不是隐式继承 env：

`container-host.service.ts` (NestJS service):
```ts
private readonly appsDomain =
  process.env.INTERNAL_APP_PUBLIC_DOMAIN ?? 'apps.ffworkspace.faradayfuture.com';
// ...
const flags = [
  // ...
  `--apps-domain ${this.shellEscape(this.appsDomain)}`,  // 显式传
];
```

`deploy-container.sh`:
```bash
while ...; do
  case "$1" in
    --apps-domain) APPS_DOMAIN_FLAG="$2"; shift 2 ;;
    # ...
  esac
done
# flag 优先于 env
if [[ -n "$APPS_DOMAIN_FLAG" ]]; then
  APPS_DOMAIN="$APPS_DOMAIN_FLAG"
fi
```

这样调用方（backend ConfigService）是配置权威，子进程不再有"猜默认值"的机会。

## 通用原则：跨进程边界 = 契约面 = 必须显式

只要数据要跨**进程边界**传递（`exec` / `spawn` / SSH / HTTP），就当作**契约面**对待：
- ✅ 显式 CLI flag / HTTP body / 命名参数
- ✅ 输入校验（必填项缺失即报错）
- ❌ 依赖继承的 env / 全局变量 / 默认值

判别准则：**"如果调用方 env 全空，子进程能不能拿到正确值？"**——不能 → 改成显式传。

### 反例（不止本案）

| 场景 | 隐式（错） | 显式（对） |
|---|---|---|
| 部署脚本拿域名 (本案) | `$INTERNAL_APP_PUBLIC_DOMAIN` env | `--apps-domain` flag |
| Worker 拿 DB URL | 共享 .env 文件 | message payload 带 connection string |
| CI 调外部 service | runner env var | secret manifest 显式注入 |
| Lambda 拿 region | `AWS_REGION` global | event 里带 region |

## 反例（已修正）

`deploy-container.sh` 接 4 个核心 flag（slug / repo / runtime / branch），把 apps-domain 留给 env。
设计时心智模型是"全局配置 = env，调用参数 = flag"。但**全局配置跨进程边界后就不全局了**——
NestJS ConfigService 隔离 + pm2 daemon env 不传 双重隔离让 env 失效。

教训：**"全局"是相对进程的，跨进程必须重写为显式参数**。

## 元规则

跟同 PR 系列另 5 篇 learning 同源（都是"显式枚举对外部世界的隐式假设"系列）：

- [postDeployHint](2026-05-19-tool-return-value-beats-description-for-ai-followup-script.md) — AI 行为指令走返回值
- [logs build fallback](2026-05-19-logs-tool-needs-build-stage-fallback.md) — 多阶段流水线诊断要跨阶段
- [per-repo webhook 兜底](2026-05-19-gitea-webhook-must-be-per-repo-or-org-drift-kills-deploys.md) — 全局点必须本地兜底
- [spy env-gated 短路](2026-05-19-spy-mocks-must-cover-all-env-gated-short-circuits.md) — 测试 spy 列代码路径不列 assertion
- [webhook status 推进](2026-05-19-webhook-deploy-event-without-db-status-update.md) — 事件流 ≠ 状态机
- **本文** — 跨进程边界必须显式

7 个 bug 一个根：**隐式契约**是脆弱的。AI-first 项目尤其要把契约面摆到桌面上——AI 看
不见的全局约束就是定时炸弹。
