1
00:00:00,100 --> 00:00:06,432
本内容改编自小山学堂《学 AI 产品，从入门到精通》，为二次演绎配音版。

2
00:00:06,382 --> 00:00:10,600
这是 Codex 系列的第八集，讲执行层的一处设计。

3
00:00:10,600 --> 00:00:16,177
一条命令进统一入口之后，怎么按特征分叉到三条不同的路。

4
00:00:16,177 --> 00:00:18,004
为什么值得关心。

5
00:00:18,004 --> 00:00:23,882
模型要装依赖，发出一次 npm install；同一轮里又开 vim 改文档。

6
00:00:23,882 --> 00:00:33,088
如果每种执行各写一套审批和沙箱，那么 Guardian、网络代理和审批缓存都得复制三遍，改一处漏两处。

7
00:00:33,088 --> 00:00:38,125
Codex 的答案是把入口收成两个工具，把差异藏进门之后。

8
00:00:38,125 --> 00:00:39,855
对外只有两个。

9
00:00:39,855 --> 00:00:46,742
exec_command 开进程，write_stdin 往已有进程写，空写就是一次轮询。

10
00:00:46,742 --> 00:00:52,668
内部跟踪的是 process_id，模型侧的参数名叫 session_id。

11
00:00:52,668 --> 00:00:58,858
管理器只负责准备请求，审批、选沙箱、重试全部交给编排器。

12
00:00:58,858 --> 00:01:03,762
出处是 unified_exec 下 mod.rs 的第十二到十七行。

13
00:01:03,762 --> 00:01:05,721
一句话先放在这儿。

14
00:01:05,721 --> 00:01:17,836
模型只看见两个工具，进门之后，tty、远程环境和一百五十毫秒的窗口，会把同一条命令送到 PTY、pipe、exec-server，或者送进重试门。

15
00:01:17,836 --> 00:01:20,793
这一集就沿着这条时间线走。

16
00:01:20,793 --> 00:01:32,632
先看进门的第一道分叉，拉起路径怎么选；再看第二道分叉，一百五十毫秒的窗口怎么决定一次拒绝能不能被认出来；然后看重试门和中断语义。

17
00:01:32,632 --> 00:01:38,942
这两条分叉加起来，就是你调一次 exec_command 时真正发生的事。

18
00:01:39,100 --> 00:01:41,934
先看第一道分叉，拉起路径。

19
00:01:41,884 --> 00:01:44,841
本地拉起按两个开关三选一。

20
00:01:44,841 --> 00:01:46,944
tty 为真，走 PTY。

21
00:01:46,944 --> 00:01:50,826
tty 为假但 stdin 开着，走带 stdin 的 pipe。

22
00:01:50,826 --> 00:01:53,855
两个都不满足，走不带 stdin 的 pipe。

23
00:01:53,855 --> 00:01:59,949
远程环境，或者带 shell snapshot 的请求，不走本地 spawn，它们走 exec-server。

24
00:01:59,949 --> 00:02:03,266
Windows 的受限令牌是单独一条后端。

25
00:02:03,266 --> 00:02:08,579
出处是 sandboxing 下 spawn.rs 的第九十七到一百二十七行。

26
00:02:08,579 --> 00:02:11,211
这里有个容易被带偏的地方。

27
00:02:11,211 --> 00:02:16,019
模块注释里写的拉起 PTY，只覆盖 tty 等于 true 那一支。

28
00:02:16,019 --> 00:02:18,963
默认的工具调用常常是 pipe。

29
00:02:18,963 --> 00:02:26,656
也就是说，你要完整的终端能力，模型必须显式打开 tty，光调 exec_command 是不够的。

30
00:02:26,656 --> 00:02:28,795
还有一个细节值得记住。

31
00:02:28,795 --> 00:02:35,766
环境变量会钉死一批值，TERM 设成 dumb，PAGER 设成 cat，交互程序先被削一层。

32
00:02:35,766 --> 00:02:40,983
这不是缺陷，是刻意的降能力，让默认路径尽量可预测。

33
00:02:40,983 --> 00:02:43,026
再补一个具体对照。

34
00:02:43,026 --> 00:02:51,512
模型发一条 npm install，没有开 tty，拿到的是不带 stdin 的 pipe，命令跑完就结束，输出一次性回给模型。

35
00:02:51,512 --> 00:02:58,687
同一条命令如果开了 tty，走的是 PTY，它会被当成一个终端会话对待，可以持续读写。

36
00:02:58,687 --> 00:03:06,632
第三条路是远程场景，请求里带了 shell snapshot，这时候根本不在本地起进程，而是交给 exec-server。

37
00:03:06,632 --> 00:03:11,776
三条路的审批和沙箱是同一套，这就是入口统一的价值。

38
00:03:11,776 --> 00:03:14,360
为什么这个形状长期成立。

39
00:03:14,360 --> 00:03:18,266
政策逻辑集中在入口，进程形态可以换。

40
00:03:18,266 --> 00:03:23,471
将来换语言重写，仍然是入口统一、拉起方式按特征分。

41
00:03:23,471 --> 00:03:27,581
PTY 具体怎么实现，可以留在它自己的仓库里。

42
00:03:27,720 --> 00:03:32,898
第二道分叉更重要，它决定一次沙箱拒绝有没有机会被认出来。

43
00:03:32,848 --> 00:03:35,059
先看它解决什么问题。

44
00:03:35,059 --> 00:03:39,831
沙箱拒了写 /etc/hosts，stderr 里是 Operation not permitted。

45
00:03:39,831 --> 00:03:46,081
运行时如果把这条当成命令写错，模型会去改源码、换路径、加 sudo。

46
00:03:46,081 --> 00:03:54,038
只有认出来这是拒绝，它才有机会按策略再跑一次，或者把拒绝正文喂给模型去申请权限。

47
00:03:54,038 --> 00:03:55,769
机制是这样的。

48
00:03:55,769 --> 00:04:05,865
本地进程接住之后，只有在三种情况下才做拒绝检查：退出通道里已经有码、通道关了、或者在一百五十毫秒内退出。

49
00:04:05,865 --> 00:04:12,656
超过这个窗口，只挂一个后台任务等退出，把还活着的进程直接交回去。

50
00:04:12,656 --> 00:04:14,146
关键后果。

51
00:04:14,146 --> 00:04:17,836
编排器的重试依赖这里返回 SandboxDenied。

52
00:04:17,836 --> 00:04:24,194
进程活过一百五十毫秒，编排器已经拿到 Ok，后面再死就进不了第二次 spawn。

53
00:04:24,194 --> 00:04:31,958
出处是 unified_exec 下 process.rs 的第三十八行，以及第三百四十九到三百六十七行。

54
00:04:31,958 --> 00:04:34,374
为什么必须设这个窗口。

55
00:04:34,374 --> 00:04:40,468
一次性命令可以等到结束再判定，但持久进程必须有截止时间。

56
00:04:40,468 --> 00:04:46,778
漏掉这个截止，一条跑了几秒才失败的命令会被当成拒绝再裸跑一次。

57
00:04:46,778 --> 00:04:52,848
而此时副作用已经写到磁盘上，第二次是另一份进程，源码里没有回滚。

58
00:04:52,848 --> 00:04:57,451
一句话记住，灯亮才可能重试，灯灭就入库。

59
00:04:57,600 --> 00:05:06,131
exec-server 这条路径有同一段超时，但检查函数自己还要先等二十毫秒，让输出通知有机会到达。

60
00:05:06,081 --> 00:05:13,762
这个细节很能说明工程态度，宁可多等二十毫秒，也不愿把一次迟到的输出误判成拒绝。

61
00:05:13,762 --> 00:05:15,901
判定还有三道短路。

62
00:05:15,901 --> 00:05:19,411
第一道，进程还没退出，直接放过。

63
00:05:19,411 --> 00:05:25,072
第二道，已经是 SandboxType 的 None，并且执行器没有报拒绝，也放过。

64
00:05:25,072 --> 00:05:27,836
其余情况才跑共享的启发式。

65
00:05:27,836 --> 00:05:32,259
出处是 process.rs 的第二百九十到三百二十四行。

66
00:05:32,259 --> 00:05:34,531
启发式这一步要说明白。

67
00:05:34,531 --> 00:05:41,069
它是靠退出码和 stderr 文本去猜这是不是沙箱拒绝，不是靠内核给的确定信号。

68
00:05:41,069 --> 00:05:49,963
所以整个窗口的设计目标不是精确，而是给编排器一个足够早、足够便宜的判断，让它决定要不要重试。

69
00:05:49,963 --> 00:05:53,028
再看两条具体命令的命运差别。

70
00:05:53,028 --> 00:06:01,237
写 hosts 文件被沙箱拦下，进程几乎立刻退出，退出码非零，stderr 里是那个熟悉的不允许操作。

71
00:06:01,237 --> 00:06:07,764
这一条落在窗口内，启发式成立，编排器拿到拒绝信号，五道门开始起作用。

72
00:06:07,764 --> 00:06:18,942
换成一条编译命令，跑了三秒才被拦下，它活过了窗口，入库、交回 process_id，后面再退出时，拒绝只能收成一条回执。

73
00:06:18,942 --> 00:06:23,185
同一类错误，两种命运，区别只在退出时间。

74
00:06:23,185 --> 00:06:25,168
这个取舍很典型。

75
00:06:25,168 --> 00:06:30,228
用一段超时和一组启发式，换一个可运行的重试决策。

76
00:06:30,228 --> 00:06:38,377
代价是迟到的拒绝认不出来，收益是绝大多数一次性命令能在同一个请求里就得到第二次机会。

77
00:06:38,520 --> 00:06:41,198
活过窗口的进程怎么处理。

78
00:06:41,148 --> 00:06:43,660
先入库，再开始 yield。

79
00:06:43,660 --> 00:06:45,883
这个顺序是有讲究的。

80
00:06:45,883 --> 00:06:51,917
打断这一轮的时候，不能因为最后一个 Arc 被丢掉，就把后台进程一起杀掉。

81
00:06:51,917 --> 00:06:55,980
所以先把它登记进管理器，再交回给调用方。

82
00:06:55,980 --> 00:07:01,484
yield 如果一直等到进程已经退出，管理器会再跑一次拒绝检查。

83
00:07:01,484 --> 00:07:04,561
但这时候编排器早就返回 Ok 了。

84
00:07:04,561 --> 00:07:13,395
这个错误不会再触发第二次 spawn，而是直接回给 handler，收成一条带正文的工具回执，process_id 置空。

85
00:07:13,395 --> 00:07:23,528
出处是 process_manager.rs 的第五百三十五到五百五十六行，以及 exec_command.rs 的第三百八十四到四百零七行。

86
00:07:23,528 --> 00:07:25,823
这里有个语义值得留意。

87
00:07:25,823 --> 00:07:32,650
回执里有正文，说明这次拒绝还是被看见了，只是不再是同步路径上的拒绝。

88
00:07:32,650 --> 00:07:38,552
模型拿到的仍然是一条可用的错误信息，只是不再享受自动重试。

89
00:07:38,552 --> 00:07:41,220
所以完整的时间线是这样的。

90
00:07:41,220 --> 00:07:52,698
一百五十毫秒内退出并被认出，走重试门；活过窗口，入库并交回 process_id；后面才退出，拒绝收成回执，不再重试。

91
00:07:52,850 --> 00:07:55,191
接下来看重试门本身。

92
00:07:55,141 --> 00:07:58,987
编排器只认一种错误，SandboxErr 的 Denied。

93
00:07:58,987 --> 00:08:01,920
认出来之后，还要过五道门。

94
00:08:01,920 --> 00:08:05,177
第一道，模块头自己声明会升级。

95
00:08:05,177 --> 00:08:14,829
unified_exec 在 mod.rs 的第七到八行写明，被拒之后按策略用 SandboxType 的 None 再试，并且靠缓存不再问一遍。

96
00:08:14,829 --> 00:08:23,399
第二道，策略是 Never 或者 OnRequest 时，默认不要无沙箱重试，把带原文的拒绝直接表面给调用方。

97
00:08:23,399 --> 00:08:32,088
第三道，档案里有 deny-read 时，绕过沙箱会把这些拒绝读静默放行，所以 unsandboxed 这条路要关掉。

98
00:08:32,088 --> 00:08:39,396
第四道，Guardian 的 strict auto-review 把第一次批准只覆盖沙箱内的尝试，不覆盖无沙箱那一次。

99
00:08:39,396 --> 00:08:43,939
第五道，第二次真的允许 unsandboxed 时，落到 None。

100
00:08:43,939 --> 00:08:48,939
另外 UnlessTrusted 策略下，如果已经批准过，就不再问第二次。

101
00:08:48,939 --> 00:09:06,840
出处横跨三个文件，unified_exec.rs 的第一百五十九到一百六十一行、sandboxing.rs 的第三百三十到三百三十七行和第二百六十九到二百七十八行、orchestrator.rs 的第四百一十一到四百一十五行与第四百四十四到四百六十行。

102
00:09:06,840 --> 00:09:09,532
把这五道门的作用各说一句。

103
00:09:09,532 --> 00:09:15,482
第一道是资格，模块必须自己声明会升级，否则编排器根本不考虑重试。

104
00:09:15,482 --> 00:09:23,354
第二道是默认态度，Never 和 OnRequest 下不做无沙箱重试，宁可把带原文的报错交给调用方。

105
00:09:23,354 --> 00:09:31,672
第三道是档案约束，deny-read 的场景下绕过沙箱等于把拒绝读静默放行，所以这条路必须关掉。

106
00:09:31,672 --> 00:09:38,414
第四道是审查范围，Guardian 的严格模式下，用户那一次批准只覆盖沙箱内的尝试。

107
00:09:38,414 --> 00:09:42,369
第五道才是真正的执行，落到 None 再跑一次。

108
00:09:42,369 --> 00:09:45,121
你能改的边界其实很清楚。

109
00:09:45,121 --> 00:09:51,251
把策略拨到 Never，或者打开 deny-read，主路上的无沙箱重试就被关掉了。

110
00:09:51,251 --> 00:09:55,902
用户选中的隔离级别，不该被运行时悄悄改掉。

111
00:09:56,040 --> 00:10:01,554
再讲一个很容易做错的地方，用户按 Esc 的时候到底该杀什么。

112
00:10:01,504 --> 00:10:03,824
协议入口是 Interrupt。

113
00:10:03,824 --> 00:10:08,043
它的语义是中止当前任务，不杀后台 terminal。

114
00:10:08,043 --> 00:10:12,117
要杀全部后台，另有 CleanBackgroundTerminals 这一条。

115
00:10:12,117 --> 00:10:20,278
配合前面说的先入库再 yield，这一轮令牌被取消的时候，进程的 Arc 还在管理器里，所以终端还在。

116
00:10:20,278 --> 00:10:21,781
还有个细节。

117
00:10:21,781 --> 00:10:26,997
pipe 会话收不到普通按键，只有控制字符三会走 interrupt。

118
00:10:26,997 --> 00:10:30,964
这是 PTY 和 pipe 在交互能力上的又一处差异。

119
00:10:30,964 --> 00:10:33,247
为什么这条长期成立。

120
00:10:33,247 --> 00:10:36,348
停思考和停终端是两件事。

121
00:10:36,348 --> 00:10:40,747
取消只应该停掉等待，不应该杀掉已经登记的进程。

122
00:10:40,747 --> 00:10:46,793
否则用户按一下 Esc，正在跑的 vim 就没了，下一轮找不到这个 session。

123
00:10:46,793 --> 00:10:48,788
还有一处对比值得提。

124
00:10:48,788 --> 00:10:54,317
同样是取消，杀掉全部后台终端是另一个独立的协议入口。

125
00:10:54,317 --> 00:10:58,800
也就是说，用户想彻底清理，必须显式走那一条。

126
00:10:58,800 --> 00:11:02,622
设计者没有让 Esc 顺手承担清理职责。

127
00:11:02,622 --> 00:11:12,177
这种把语义拆细的做法，在多入口的系统里尤其重要，因为同一个按键在不同上下文里被期待的行为并不一样。

128
00:11:12,177 --> 00:11:15,555
灯亮才可能重试，Esc 只停思考。

129
00:11:15,555 --> 00:11:20,014
这两句放在一起，就是这一集的执行层画像。

130
00:11:20,160 --> 00:11:23,679
横向看另外两家怎么处理持久终端。

131
00:11:23,629 --> 00:11:28,978
DeepSeek 的 Harness 把它做成独立工具族，六个终端工具加 jobs。

132
00:11:28,978 --> 00:11:33,677
开、写、读、信号、关、列，各自是一个工具。

133
00:11:33,677 --> 00:11:39,074
后台发送复用 ctx.jobs，收集走 job output，停止走 job kill。

134
00:11:39,074 --> 00:11:48,954
系统提示还写明，只有需要跨调用保留终端状态或者交互 stdin 时才用终端，一次性工作优先用 shell 或读写工具。

135
00:11:48,954 --> 00:11:56,153
Codex 把开和写收成两个工具，读合并进下一次 write_stdin 或者一次空轮询。

136
00:11:56,153 --> 00:12:02,379
两边另一个差别是，DSH 没有对位的沙箱拒绝后由编排器自动无沙箱重试。

137
00:12:02,379 --> 00:12:06,334
失败之后谁负责再跑一次，两边答案不同。

138
00:12:06,334 --> 00:12:08,569
Grok 又是另一种放法。

139
00:12:08,569 --> 00:12:15,168
它没有 unified_exec 这个模块，而是把持久性做成会话级的后端选择。

140
00:12:15,168 --> 00:12:24,999
复用父会话、ACP 客户端终端、本地持久、本地非持久，选完后端就按这个形态跑整场，子 agent 复用父后端。

141
00:12:24,999 --> 00:12:32,896
Codex 把同一个问题放在单次 exec_command 上，进程活过 yield 就发回 process_id。

142
00:12:32,896 --> 00:12:39,735
两边都承认一次性 bash -c 保不住工作目录和交互状态，只是落地位置不同。

143
00:12:39,735 --> 00:12:42,728
收尾，四条可以带走的原则。

144
00:12:42,728 --> 00:12:45,697
一，入口统一，差异内移。

145
00:12:45,697 --> 00:12:52,283
对外只暴露最小的一组工具，把拉起方式、审批和沙箱的差异藏在编排器里。

146
00:12:52,283 --> 00:12:55,408
这样政策改一处，不会漏两处。

147
00:12:55,408 --> 00:12:58,473
二，给持久进程设截止时间。

148
00:12:58,473 --> 00:13:03,004
一次性命令可以等结束再判定，持久进程不行。

149
00:13:03,004 --> 00:13:10,216
没有这个截止，迟到几秒的失败会被当成早期拒绝，造成无法回滚的第二次执行。

150
00:13:10,216 --> 00:13:12,668
三，重试必须过门。

151
00:13:12,668 --> 00:13:18,822
自动裸跑不是默认选项，它要受策略、档案和审查模式共同约束。

152
00:13:18,822 --> 00:13:22,788
用户选的隔离级别不能被运行时悄悄改掉。

153
00:13:22,788 --> 00:13:25,288
四，取消不等于杀死。

154
00:13:25,288 --> 00:13:30,047
停思考、停等待、停进程，是三种不同语义。

155
00:13:30,047 --> 00:13:35,877
协议层面要把它们分开，否则一次误操作会带走用户还在用的会话。

156
00:13:35,877 --> 00:13:37,680
最后留一道练习。

157
00:13:37,680 --> 00:13:44,038
同一条 npm install，先走 UnlessTrusted，再把策略拨到 Never，然后换成 sleep 2。

158
00:13:44,038 --> 00:13:53,148
推演一下，哪一次会第二次 spawn，哪一次回执里还有 process_id，以及为什么灯灭之后编排器就看不见拒绝了。

159
00:13:53,148 --> 00:13:55,252
这一集就到这里。

