qm sandbox skills
这一页专拆 QM 如何把 resolution layers、credential broker、device-flow login 和 skill 文件树投影进每一轮 Agent computer。
Sandbox 是 QM 的 turn computer
src/core/orchestrator/sandboxes.ts:28 定义 TurnSandboxContext,里面同时有 actor、session、resolution、connectorEnv、brokeredTools、visibleSkills 和 skillMaterializer。它说明 sandbox 不是纯 shell,而是每轮上下文的承载层。
src/core/orchestrator/sandboxes.ts:60 创建 sandboxes,src/core/orchestrator/sandboxes.ts:131 维护主 sandbox handle,src/core/orchestrator/sandboxes.ts:139 还单独维护 scratch 和 ownerAuth box。
▸TurnSandboxContext 定义了边界输入
28export interface TurnSandboxContext { 29 deps: OrchestratorDeps; 30 input: OrchestratorInput; 31 actor: Principal; 32 session: Session; 33 resolution: Resolution; 34 scopeId: ScopeId; 35 memoryScopeId: ScopeId; 36 transferId: string; 37 turnSessionDir: string; 38 turnFilesDir: string; 39 turnOutboxDir: string; 40 connectorEnv: Record<string, string>; 41 egressTokenForTurn: string | undefined; 42 isolateOwnerKeychain: boolean; 43 ownerAuthAvailable: boolean; 44 ownerAuthEnv: Record<string, string>; 45 ownerEnvCredentialIds: string[]; 46 brokerVended: Map<string, { tool: BrokeredLayerTool; env: Record<string, string> }>; 47 brokeredTools: readonly BrokeredLayerTool[]; 48 quarantinedServices: string[]; 49 brokerCutoverServices: string[]; 50 cutoverModeOf: (service: string) => DeviceFlowCutoverMode; 51 visibleSkills: SkillResolution[]; 52 visibleSkillsForTurn: () => Promise<SkillResolution[]>; 53 skillScopes: ScopeId[]; 54 skillMaterializer: ReturnType<typeof createSkillMaterializer>; 55 residentAuthConnectors: () => ResidentAuthConnector[]; 56 emitGapWork: (phase: GapPhase, start: number, end: number) => void; 57 perf: { credsMs: number }; 58} 59 60export function createTurnSandboxes(ctx: TurnSandboxContext) {
凭据不是静态环境变量,而是按命令路径注入
src/core/orchestrator/sandboxes.ts:93 创建 ownerAuthCommand,src/core/orchestrator/sandboxes.ts:105 用正则检测命令里调用的 brokered tool,src/core/orchestrator/sandboxes.ts:117 拼接 exports。
▸ownerAuthCommand 的审计和 wrapper
93 let ownerAuthCommand: ((command: string) => string) | undefined; 94 if (ownerAuthAvailable) { 95 ownerAuthCommand = (command) => { 96 for (const credentialId of ownerEnvCredentialIds) { 97 deps.auditLog.record({ 98 at: Date.now(), 99 principalId: actor.id, 100 action: "keychain.materialize", 101 resource: `${credentialId} (owner-auth command)`, 102 scopeLabel: scopeId, 103 }); 104 } 105 const invoked = [...brokerVended.values()].filter(({ tool }) => 106 new RegExp(`(^|[\\s;&|()])${tool.binary}(?=$|[\\s;&|()])`).test(command), 107 ); 108 for (const { tool } of invoked) { 109 deps.auditLog.record({ 110 at: Date.now(), 111 principalId: actor.id, 112 action: "credential.materialize", 113 resource: `${tool.service} (ephemeral broker)`, 114 scopeLabel: scopeId, 115 }); 116 } 117 const exports = Object.entries(ownerAuthEnv) 118 .map(([key, value]) => `${key}=${shq(value)}`) 119 .join(" "); 120 const wrappers = invoked 121 .map(({ tool, env }) => { 122 const brokerExports = Object.entries(env) 123 .map(([key, value]) => `${key}=${shq(value)}`) 124 .join(" "); 125 return `${tool.binary}() { ${brokerExports} command ${tool.binary} "$@"; }; `; 126 }) 127 .join(""); 128 return `${exports ? `export ${exports}; ` : ""}${wrappers}${command}`; 129 }; 130 }
src/core/orchestrator/sandboxes.ts:109 记录 credential.materialize,src/core/orchestrator/sandboxes.ts:125 生成 shell function wrapper。这里把“某个工具用了临时凭据”变成可审计事件,而不是让整个 turn 默认拿到所有秘密。
provision 时把 scope、egress 和 outbox 都落到 handle
src/core/orchestrator/sandboxes.ts:190 创建 provision 函数,src/core/orchestrator/sandboxes.ts:198 进入 doProvision,src/core/orchestrator/sandboxes.ts:200 调 deps.sandbox.provision。
▸主 provision 流程
198 const doProvision = async (emit: typeof emitGapWork): Promise<SandboxHandle> => { 199 const provisionStart = Date.now(); 200 const handle = await deps.sandbox.provision(resolution.layers, { 201 env: connectorEnv, 202 egress: resolution.egress, 203 ...(egressTokenForTurn ? { egressToken: egressTokenForTurn } : {}), 204 ...(onSandboxStatus ? { onStatus: onSandboxStatus } : {}), 205 }); 206 box.pending = handle; 207 emit("provision", provisionStart, Date.now()); 208 box.provisionMs = Date.now() - provisionStart; 209 handle.env = { ...handle.env, AGENT_OUTBOX: `${handle.rootDir}/${turnOutboxDir}` }; 210 if (deps.keychain) { 211 const deviceFlowStart = Date.now(); 212 const restoreOwnerId = 213 input.origin.kind === "automation" && input.origin.useOwnerKeychain && !isolateOwnerKeychain 214 ? actor.id 215 : deviceFlowCredOwner(memoryScopeId, actor.id); 216 const resetGenerations = new Map<string, string>(); 217 for (const tool of brokeredTools) { 218 if (cutoverModeOf(tool.service) !== "legacy") continue; 219 const generation = await deps.deviceFlowCutover?.residentResetGeneration(memoryScopeId, tool.service); 220 if (generation) resetGenerations.set(tool.service, generation); 221 } 222 const owned = resetGenerations.size ? await deps.keychain.listByOwner(restoreOwnerId) : []; 223 const resetServices = [...resetGenerations.keys()].filter((service) => 224 owned.some((record) => record.service === service), 225 ); 226 const removeServices = [...new Set([...quarantinedServices, ...resetServices])]; 227 if (removeServices.length) { 228 await removeDeviceFlowLogins({ 229 sandbox: deps.sandbox, 230 handle, 231 keychain: deps.keychain, 232 ownerId: restoreOwnerId, 233 services: removeServices, 234 canonicalRoots: brokeredTools 235 .filter((tool) => removeServices.includes(tool.service)) 236 .flatMap((tool) => tool.roots), 237 }); 238 } 239 try { 240 const restoredServices = await materializeDeviceFlowLogins({ 241 sandbox: deps.sandbox, 242 handle, 243 keychain: deps.keychain, 244 ownerId: restoreOwnerId, 245 ...(quarantinedServices.length ? { excludeServices: quarantinedServices } : {}), 246 }); 247 for (const service of restoredServices) { 248 deps.credentialUsage?.record({ 249 slug: `keychain:${service}`, 250 host: "local", 251 status: cutoverModeOf(service) === "prefer_ephemeral" ? "legacy_retained" : "legacy_restored", 252 scopeLabel: scopeId, 253 principalId: actor.id, 254 }); 255 } 256 for (const [service, generation] of resetGenerations) { 257 await deps.deviceFlowCutover?.markResidentReset(memoryScopeId, service, generation); 258 } 259 } catch (err) { 260 deps.errors?.record({
src/core/orchestrator/sandboxes.ts:202 传 egress policy,src/core/orchestrator/sandboxes.ts:209 写 AGENT_OUTBOX,src/core/orchestrator/sandboxes.ts:240 恢复 device-flow logins。这个路径就是 QM 所说“agent computer”的运行时实现。
Skill 投影用 marker/hash 管理生命周期
src/skills/materialize.ts:35 用 sandbox handle 生成 materializationKey,src/skills/materialize.ts:44 计算 indexHash,src/skills/materialize.ts:57 计算 treeHash。这说明 materializer 关心“当前 sandbox 里已经是什么版本”。
▸hash 和 rendered body
35function materializationKey(handle: SandboxHandle): string { 36 const sandboxId = createHash("sha256").update(handle.id).update("\0").update(handle.rootDir).digest("hex"); 37 return `skills:projection:${sandboxId}`; 38} 39 40export function safeSkillDirName(name: string): string { 41 return assertSafeSkillName(name); 42} 43 44function indexHash(resolved: SkillResolution[]): string { 45 const h = createHash("sha256"); 46 const entries = resolved 47 .filter((r) => r.skill) 48 .map((r) => `${r.skill!.manifest.name}\0${renderedBody(r)}`) 49 .sort(); 50 for (const e of entries) { 51 h.update(e); 52 h.update("\n"); 53 } 54 return h.digest("hex"); 55} 56 57function treeHash(resolution: SkillResolution, bundles: SkillBundle[]): string { 58 const m = resolution.skill!.manifest; 59 const files = [...(m.files ?? [])] 60 .map((f) => `${f.path}\0${f.content}\0${f.executable === true ? "1" : "0"}`) 61 .sort() 62 .join("\0"); 63 const folded = [...bundles] 64 .map((b) => b.hash) 65 .sort() 66 .join("\0"); 67 return createHash("sha256") 68 .update(renderedBody(resolution)) 69 .update("\0") 70 .update(files) 71 .update("\0") 72 .update(folded) 73 .digest("hex"); 74} 75 76function packRoot(resolution: SkillResolution): string | null { 77 return resolution.skill?.pack ? `${SKILL_PACKS_DIR}/${safeSkillDirName(resolution.skill.pack.packId)}` : null; 78} 79 80function renderedBody(resolution: SkillResolution): string { 81 const body = resolution.skill!.manifest.body; 82 const root = packRoot(resolution); 83 return root 84 ? `${body}\n\n## Pack files\nResolve repository-relative shared-file paths against \`${root}/\`; pack files never overwrite the workspace root.` 85 : body; 86}
src/skills/materialize.ts:76 处理 pack root,src/skills/materialize.ts:80 渲染 skill body,src/skills/materialize.ts:84 明确 pack files 不覆盖 workspace root。这个设计避免 skill pack 直接污染工作区。
▸index materialize 的 stale cleanup
210async function materializeSkillIndexUnlocked( 211 sandbox: Sandbox, 212 handle: SandboxHandle, 213 resolved: SkillResolution[], 214): Promise<void> { 215 const want = indexHash(resolved); 216 const names = resolved.flatMap((r) => (r.skill ? [safeSkillDirName(r.skill.manifest.name)] : [])).sort(); 217 const raw = await readMarker(sandbox, handle, INDEX_MARKER, "skills: index probe"); 218 const prev = indexMarkerState(raw); 219 if (prev?.hash === want && samePaths(prev.names, names)) return; 220 const legacyExternalPathsPreserved = prev?.legacyExternalPathsPreserved === true || Boolean(raw && !prev); 221 222 if (raw && !prev) { 223 await sandbox.removeDir(handle, SKILLS_DIR); 224 } else if (prev) { 225 const activeBundlePaths = new Set<string>(); 226 for (const name of names) { 227 const currentRaw = await readMarker( 228 sandbox, 229 handle, 230 `${SKILLS_DIR}/${name}/${TREE_MARKER}`, 231 `skills: tree probe ${name}`, 232 ); 233 const current = treeMarkerState(currentRaw, `${SKILLS_DIR}/${name}`); 234 for (const path of current?.bundlePaths ?? []) activeBundlePaths.add(path); 235 } 236 const activeNames = new Set(names); 237 for (const name of prev.names) { 238 if (activeNames.has(name)) continue; 239 const dir = `${SKILLS_DIR}/${name}`; 240 const staleRaw = await readMarker(sandbox, handle, `${dir}/${TREE_MARKER}`, `skills: tree probe ${name}`); 241 const stale = treeMarkerState(staleRaw, dir); 242 const paths = new Set([`${dir}/SKILL.md`, ...(stale?.skillPaths ?? []), ...(stale?.bundlePaths ?? [])]); 243 for (const path of paths) { 244 if (!activeBundlePaths.has(path)) await sandbox.removeDir(handle, path); 245 } 246 const protectsDirectory = [...activeBundlePaths].some((path) => path.startsWith(`${dir}/`)); 247 if (protectsDirectory) await sandbox.removeDir(handle, `${dir}/${TREE_MARKER}`); 248 else await sandbox.removeDir(handle, dir); 249 } 250 } 251 252 const entries: LayEntry[] = []; 253 for (const r of resolved) { 254 if (!r.skill) continue; 255 entries.push({ 256 path: `${SKILLS_DIR}/${safeSkillDirName(r.skill.manifest.name)}/SKILL.md`, 257 content: renderedBody(r), 258 }); 259 } 260 entries.push({
src/skills/materialize.ts:217 读取 marker,src/skills/materialize.ts:222 处理 legacy marker,src/skills/materialize.ts:237 删除不再 active 的 skill。resident sandbox 场景里,如果不做这层清理,旧 skill 会悄悄留在 Agent 可见路径里。
部署层工具描述符也被严格解析
src/deployment/deployment-layer.ts:54 解析 tool descriptor,src/deployment/deployment-layer.ts:68 限制 id 格式,src/deployment/deployment-layer.ts:89 校验 egress 字段,src/deployment/deployment-layer.ts:96 解析 auth。
▸ToolDescriptor parser
54export function parseToolDescriptor(raw: string, sourcePath: string): ToolDescriptor { 55 let parsed: unknown; 56 try { 57 parsed = JSON.parse(raw); 58 } catch (err) { 59 throw new Error(`${sourcePath} is not valid JSON: ${(err as Error).message}`, { cause: err }); 60 } 61 if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { 62 throw new Error(`${sourcePath} must be a JSON object`); 63 } 64 const d = parsed as Record<string, unknown>; 65 if (typeof d["id"] !== "string" || !d["id"].trim()) { 66 throw new Error(`${sourcePath}: "id" is required and must be a non-empty string`); 67 } 68 if (!TOOL_ID_RE.test(d["id"])) { 69 throw new Error( 70 `${sourcePath}: "id" must match ${TOOL_ID_RE.source} (lowercase alphanumerics and hyphens) — it is interpolated into shell probes`, 71 ); 72 } 73 const out: ToolDescriptor = { id: d["id"] }; 74 75 for (const key of ["label", "advertise"] as const) { 76 if (d[key] !== undefined) { 77 if (typeof d[key] !== "string") throw new Error(`${sourcePath}: "${key}" must be a string`); 78 out[key] = d[key] as string; 79 } 80 } 81 82 if (d["hints"] !== undefined) { 83 if (!Array.isArray(d["hints"]) || d["hints"].some((h) => typeof h !== "string")) { 84 throw new Error(`${sourcePath}: "hints" must be an array of strings`); 85 } 86 out.hints = d["hints"] as string[]; 87 } 88 89 if (d["egress"] !== undefined) { 90 if (!Array.isArray(d["egress"]) || d["egress"].some((h) => typeof h !== "string")) { 91 throw new Error(`${sourcePath}: "egress" must be an array of host strings`); 92 } 93 out.egress = d["egress"] as string[]; 94 } 95 96 if (d["auth"] !== undefined) out.auth = parseAuth(d["auth"], sourcePath); 97 if (d["approvals"] !== undefined) out.approvals = parseApprovals(d["approvals"], sourcePath); 98 99 if (d["install"] !== undefined) { 100 const inst = d["install"]; 101 if (typeof inst !== "object" || inst === null || Array.isArray(inst)) { 102 throw new Error(`${sourcePath}: "install" must be an object`); 103 } 104 const binary = (inst as Record<string, unknown>)["binary"]; 105 if (binary !== undefined && typeof binary !== "string") { 106 throw new Error(`${sourcePath}: "install.binary" must be a string`); 107 } 108 if (binary !== undefined && !TOOL_ID_RE.test(binary as string)) { 109 throw new Error( 110 `${sourcePath}: "install.binary" must match ${TOOL_ID_RE.source} (lowercase alphanumerics and hyphens) — it is interpolated into generated Dockerfile lines`, 111 ); 112 } 113 out.install = binary !== undefined ? { binary: binary as string } : {}; 114 } 115 116 const credentialPaths = out.auth?.credentialPaths ?? [];
src/deployment/deployment-layer.ts:116 开始检查 credentialPaths,src/deployment/deployment-layer.ts:121 拒绝绝对路径或 traversal,src/deployment/deployment-layer.ts:139 检查和内置 credential paths 的重叠。这让 deployment layer 不能随便声明一个会吞掉整个 HOME 的凭据路径。
▸credential path 和 approval pattern 守门
116 const credentialPaths = out.auth?.credentialPaths ?? []; 117 for (const [index, credentialPath] of credentialPaths.entries()) { 118 const { path, kind } = credentialPath; 119 const segments = path.split("/"); 120 if ( 121 !path || 122 path.startsWith("/") || 123 path.startsWith("~") || 124 path.includes("\\") || 125 /\s/.test(path) || 126 segments.includes("..") || 127 segments.includes(".") || 128 segments.includes("") 129 ) { 130 throw new Error( 131 `${sourcePath}: credential path ${JSON.stringify(path)} must be a $HOME-relative path with no traversal`, 132 ); 133 } 134 if (!segments[0]!.startsWith(".")) { 135 throw new Error( 136 `${sourcePath}: credential path ${JSON.stringify(path)} must start with a dotfile or dot-directory segment — non-hidden $HOME paths are durable agent data, not credentials`, 137 ); 138 } 139 const builtIn = BUILT_IN_CREDENTIAL_PATHS.find( 140 (base) => path === base.path || nested(path, base.path) || nested(base.path, path), 141 ); 142 if (builtIn && (path !== builtIn.path || kind !== builtIn.kind)) { 143 throw new Error( 144 `${sourcePath}: credential path ${JSON.stringify(path)} (${kind}) overlaps the built-in credential path ${JSON.stringify(builtIn.path)} (${builtIn.kind}) — declare the exact built-in path with its correct kind or a disjoint one`, 145 ); 146 } 147 const other = credentialPaths.find( 148 (entry, otherIndex) => 149 otherIndex !== index && (path === entry.path || nested(path, entry.path) || nested(entry.path, path)), 150 ); 151 if (other) { 152 throw new Error( 153 `${sourcePath}: credential paths ${JSON.stringify(path)} and ${JSON.stringify(other.path)} overlap — declare disjoint paths`, 154 ); 155 } 156 } 157 const binary = out.install?.binary ?? out.id; 158 if (out.auth?.broker && !POSIX_FUNCTION_NAME_RE.test(binary)) { 159 throw new Error( 160 `${sourcePath}: a brokered tool's binary ${JSON.stringify(binary)} must match ${POSIX_FUNCTION_NAME_RE.source} — vended credentials wrap it in a shell function, and not every sandbox shell accepts hyphenated function names`, 161 ); 162 } 163 for (const [i, approval] of (out.approvals ?? []).entries()) { 164 const compiled = compileApproval(binary, approval); 165 try { 166 new RegExp(compiled.pattern, "i"); 167 } catch (e) { 168 throw new Error(`${sourcePath}: approvals[${i}] is not a valid regex: ${(e as Error).message}`, { cause: e }); 169 } 170 if (compiled.pattern.length > MAX_APPROVAL_PATTERN_LEN) { 171 throw new Error(`${sourcePath}: approvals[${i}] pattern is too long (max ${MAX_APPROVAL_PATTERN_LEN} chars)`); 172 } 173 if (approvalPatternTooSlow(compiled.pattern)) { 174 throw new Error( 175 `${sourcePath}: approvals[${i}] pattern is too slow to evaluate — it may cause catastrophic backtracking`, 176 ); 177 } 178 if (approval.pattern !== undefined && !rawApprovalTargetsTool(binary, approval.pattern)) { 179 throw new Error( 180 `${sourcePath}: approvals[${i}].pattern must refer to its own tool binary by starting with \\b${binary}\\b and may not use a top-level alternative`, 181 ); 182 } 183 }
src/deployment/deployment-layer.ts:157 推导 binary,src/deployment/deployment-layer.ts:163 编译 approvals,src/deployment/deployment-layer.ts:178 要求 approval pattern 以自己的 tool binary 开头。这个约束防止部署工具用 approvals 规则偷偷影响别的命令。
可借鉴点
▸少信任目录,多信任 parser
src/deployment/deployment-layer.ts:54 和 src/skills/materialize.ts:128 都把路径安全放在解析层,而不是寄希望于调用方。
▸resident sandbox 需要清理协议
src/skills/materialize.ts:236 的 activeNames 和 src/skills/materialize.ts:243 的 stale path 删除,就是长期运行机器避免积灰的关键。
风险
▸凭据 wrapper 依赖 shell 语义
src/core/orchestrator/sandboxes.ts:125 生成 shell function,src/deployment/deployment-layer.ts:157 又限制 brokered tool binary 名字。这个约束有效,但也意味着非 POSIX 或特殊 shell 环境要单独验证。