qm multiplayer agent harness
QM 不是单个 coding agent,而是一套让 Slack、Web、Cron 和多种 agent runtime 共用的组织级 Agent 工作台。源码里最值得拆的是它怎么把人、scope、harness、sandbox、skills 和凭据串成一个可租约化、可审计的 turn。
它解决什么,凭什么值得拆
QM 的 README 把它称为“multiplayer agent harness for work”,源码对应的是一个组织级 agent control plane:Web turn 入口先在 src/api/app-turn.ts:56 刷新身份并解析 actor,随后在 src/api/app-turn.ts:102 通过项目 roster 版本保护共享上下文,最后把 surface、conversation、runtime 和权限信息拼成 orchestrator 输入。
它值得拆的原因不是 stars 数,而是边界足够复杂:src/core/orchestrator.ts:189 创建的 orchestrator 同时接入 memory、skills、credential broker、security posture、attachments 和 delivery;src/runs/worker.ts:22 又把 turn 变成可租约、可心跳、可释放的后台 run。
▸核心数据
▸源码阅读路线
第一条路线看 runtime:src/harness/harness-router.ts:12 决定 harness/model,src/api/app-turn.ts:122 在 Web turn 中执行 provider 和模型可用性检查,src/runs/worker.ts:59 再把排队时间和 attempt 注入 orchestrator。
第二条路线看隔离:src/core/orchestrator/sandboxes.ts:198 用 resolution layers provision sandbox,src/workspace/workspace-store.ts:16 用 safeJoin 防止 workspace path escape,src/skills/materialize.ts:210 负责把可见 skills 投影进 sandbox。
运行时路由不是配置开关,而是安全边界
src/harness/harness-router.ts:19 先读取部署批准的 harness 集合,src/harness/harness-router.ts:31 再把 org 默认值和 scope 覆盖合成继承链;这意味着用户临时指定 model/harness 不是自由字符串,而要通过 src/harness/harness-router.ts:48 的 approved + supported 检查。
▸RuntimeChoice 的收敛路径
12export function resolveRuntimeChoice( 13 config: Pick<ScopedConfigStore, "getApprovedHarnesses" | "getRuntimeSelection" | "getBaseModel">, 14 orgScopeId: ScopeId, 15 scope: ScopeId, 16 fallback: RuntimeChoice, 17 requested?: Partial<RuntimeChoice>, 18): RuntimeChoice { 19 const approved = config.getApprovedHarnesses() ?? [fallback.harnessId]; 20 const orgStored = config.getRuntimeSelection(orgScopeId); 21 const orgLegacy = config.getBaseModel(orgScopeId); 22 const configuredOrg = 23 orgStored && isHarnessId(orgStored.harnessId) 24 ? { harnessId: orgStored.harnessId, modelId: orgStored.modelId } 25 : { harnessId: fallback.harnessId, modelId: orgLegacy ?? fallback.modelId }; 26 const firstApproved = approved.find(isHarnessId) ?? fallback.harnessId; 27 const safeFallback = 28 approved.includes(fallback.harnessId) && modelSupportedByHarness(fallback.modelId, fallback.harnessId) 29 ? fallback 30 : { harnessId: firstApproved, modelId: defaultModelForHarness(firstApproved, fallback.modelId) }; 31 const org = 32 approved.includes(configuredOrg.harnessId) && 33 modelSupportedByHarness(configuredOrg.modelId, configuredOrg.harnessId) 34 ? configuredOrg 35 : safeFallback; 36 const scopedStored = scope === orgScopeId ? null : config.getRuntimeSelection(scope); 37 const scopedLegacy = scope === orgScopeId ? null : config.getBaseModel(scope); 38 let inherited = org; 39 if (scopedStored && isHarnessId(scopedStored.harnessId)) { 40 inherited = { harnessId: scopedStored.harnessId, modelId: scopedStored.modelId }; 41 } else if (scopedLegacy) { 42 inherited = { harnessId: fallback.harnessId, modelId: scopedLegacy }; 43 } 44 const choice = 45 requested?.harnessId || requested?.modelId 46 ? { harnessId: requested.harnessId ?? inherited.harnessId, modelId: requested.modelId ?? inherited.modelId } 47 : inherited; 48 if (!approved.includes(choice.harnessId) || !modelSupportedByHarness(choice.modelId, choice.harnessId)) { 49 if (requested?.harnessId || requested?.modelId) 50 throw new NonRetryableTurnError(`runtime ${choice.harnessId}/${choice.modelId} is not approved`); 51 return org; 52 } 53 return choice; 54}
src/harness/harness-router.ts:44 把 requested runtime 合入 inherited runtime,但 src/harness/harness-router.ts:49 会把未批准的请求变成 NonRetryableTurnError。这个设计让 Web UI 可以暴露模型选择,同时不会绕过组织批准的 harness 白名单。
▸Web turn 入口先裁剪权限,再运行模型
56 async turn(req: TurnRequest): Promise<TurnResult> { 57 await deps.identity.refresh(); 58 const actor: Principal = deps.identity.resolve(req.actor); 59 let projectAudience: Principal[] | undefined; 60 let projectName: string | undefined; 61 let projectVersion: string | undefined; 62 let sessionParticipantIds: string[] | undefined; 63 const conversationRef = req.conversation.channelRef; 64 const projectGroup = req.conversation.kind === "group" && !!conversationRef && isProjectGroupRef(conversationRef); 65 const projectId = projectGroup ? projectIdFromGroupRef(conversationRef) : null; 66 67 if (projectGroup) { 68 if (!deps.identity.isInternal(actor)) { 69 return { status: "refused", reason: "you're not a member of that context" }; 70 } 71 if (!projectId) return { status: "refused", reason: "you're not a member of that context" }; 72 const project = await deps.projects?.get(projectId); 73 if ( 74 !project || 75 project.orgId !== orgIdOf() || 76 !deps.identity.isInternal(deps.identity.classify(project.ownerId)) 77 ) { 78 return { status: "refused", reason: "you're not a member of that context" }; 79 } 80 const activeMemberIds = project.memberIds.filter((memberId) => 81 deps.identity.isInternal(deps.identity.classify(memberId)), 82 ); 83 if (!activeMemberIds.includes(actor.id)) 84 return { status: "refused", reason: "you're not a member of that context" }; 85 projectAudience = await Promise.all( 86 activeMemberIds.map(async (memberId) => { 87 if (memberId === actor.id) return actor; 88 const principal = deps.identity.classify(memberId); 89 const member = await deps.directory.get(memberId).catch(() => null); 90 return member?.displayName ? { ...principal, displayName: member.displayName } : principal; 91 }), 92 ); 93 projectName = project.name; 94 projectVersion = String(project.updatedAt); 95 sessionParticipantIds = [...activeMemberIds]; 96 } else if (req.surface === "web" && req.conversation.kind !== "dm") { 97 if (!conversationRef || !(await mayUseSharedScope(req.conversation.kind, conversationRef, actor))) { 98 return { status: "refused", reason: "you're not a member of that context" }; 99 } 100 }
src/api/app-turn.ts:67 对 project group 做内部成员校验,src/api/app-turn.ts:80 刷 active member,src/api/app-turn.ts:96 又对非 DM Web conversation 检查 shared scope。这里的路线说明 QM 不是“先让 Agent 决定”,而是在 Agent 前先确定人和上下文是否匹配。
▸模型可用性和 provider keys 一起校验
122 const org = scopeId("org", orgIdOf()); 123 const targetScope = 124 req.conversation.kind === "dm" 125 ? scopeId("personal", actor.id) 126 : scopeId(req.conversation.kind, req.conversation.channelRef ?? threadRef); 127 const fallbackHarness = isHarnessId(deps.harnessId) ? deps.harnessId : "pi"; 128 const runtimeFallback = deps.runtimeFallback ?? { 129 harnessId: fallbackHarness, 130 modelId: defaultModelForHarness(fallbackHarness), 131 }; 132 let orgRuntime; 133 let configuredRuntime; 134 let runtime; 135 try { 136 orgRuntime = await resolveRuntimeChoiceDurable(deps.config, org, org, runtimeFallback); 137 configuredRuntime = 138 targetScope === org 139 ? orgRuntime 140 : await resolveRuntimeChoiceDurable(deps.config, org, targetScope, runtimeFallback); 141 runtime = 142 req.harness || req.model 143 ? await resolveRuntimeChoiceDurable(deps.config, org, targetScope, runtimeFallback, { 144 ...(req.harness && isHarnessId(req.harness) ? { harnessId: req.harness } : {}), 145 ...(req.model ? { modelId: req.model } : {}), 146 }) 147 : configuredRuntime; 148 } catch (error) { 149 return { status: "refused", reason: errMessage(error) }; 150 } 151 if (req.harness && !isHarnessId(req.harness)) { 152 return { status: "refused", reason: `runtime ${req.harness} is not approved` }; 153 } 154 const configuredKeys = deps.providerKeys ?? 155 deps.modelProviders ?? { anthropic: false, openai: false, openrouter: false }; 156 let providers = deps.modelProviders; 157 if (deps.modelCredentials) { 158 providers = modelProviderAvailabilityFor( 159 runtime.harnessId, 160 configuredKeys, 161 await deps.modelCredentials.availability(), 162 ); 163 } else if (deps.providerKeys) { 164 providers = modelProviderAvailabilityFor(runtime.harnessId, configuredKeys); 165 } 166 if (providers && !modelServiceable(runtime.modelId, providers)) { 167 return { 168 status: "refused", 169 reason: "that model isn't available on this deployment (its provider isn't configured)", 170 }; 171 } 172 const configuredWebuiModels = await deps.config.getWebuiModelsDurable(org); 173 let enabledWebuiModels: string[] | null = null; 174 if (configuredWebuiModels?.length) { 175 enabledWebuiModels = [...new Set([...configuredWebuiModels, orgRuntime.modelId])]; 176 } else if (providers?.openrouter) { 177 enabledWebuiModels = [ 178 ...new Set([ 179 ...selectableCatalogForHarness( 180 await selectableModelCatalog(deps.modelCredentialFetch), 181 runtime.harnessId, 182 ).map((model) => model.id), 183 ...(orgRuntime.harnessId === runtime.harnessId ? [orgRuntime.modelId] : []), 184 ]), 185 ]; 186 } 187 const invalidModelOption = 188 validateWebTurnModelOptions(req, enabledWebuiModels, providers) ?? 189 webTurnRuntimeModelRefusal(runtime.modelId, orgRuntime.modelId, configuredWebuiModels); 190 if (invalidModelOption) return { status: "refused", reason: invalidModelOption };
src/api/app-turn.ts:136 先解析 org runtime,src/api/app-turn.ts:141 再解析 target scope runtime,src/api/app-turn.ts:166 用 provider availability 判断模型是否 serviceable。这对企业部署很关键:配置里允许某 harness,不代表每个 provider key 都已经可用。
后台 run 靠租约心跳避免多实例踩踏
QM 的 worker 不是简单 while true。src/runs/worker.ts:22 的 processRun 要求 run 已经持有 lease token,src/runs/worker.ts:29 定时 heartbeat,src/runs/worker.ts:37 统计连续 lease lost,超过 src/runs/worker.ts:38 的阈值才取消本地 turn。
▸Run processing 包含取消信号和最终 attempt
22export async function processRun(deps: ProcessDeps, run: Run, opts?: { background?: boolean }): Promise<TurnResult> { 23 const token = run.leaseToken; 24 if (token === null) throw new Error(`processRun called with an unleased run ${run.id}`); 25 const intervalMs = deps.heartbeatIntervalMs ?? Math.max(1_000, Math.floor(deps.leaseTtlMs / 3)); 26 const cancel = new AbortController(); 27 let consecutiveLost = 0; 28 let leaseLost = false; 29 const beat = setInterval(() => { 30 void deps.runs 31 .heartbeat(run.id, token, deps.leaseTtlMs) 32 .then((alive) => { 33 if (alive) { 34 consecutiveLost = 0; 35 return; 36 } 37 consecutiveLost += 1; 38 if (consecutiveLost >= LEASE_LOST_CONSECUTIVE && !leaseLost) { 39 leaseLost = true; 40 clearInterval(beat); 41 console.warn( 42 `[worker] run ${run.id} lost its lease after ${consecutiveLost} consecutive beats; cancelling the in-process turn`, 43 ); 44 cancel.abort(); 45 } 46 }) 47 .catch((err: unknown) => { 48 consecutiveLost = 0; 49 console.warn(`[worker] heartbeat failed for run ${run.id} (transient, ignored): ${errMessage(err)}`); 50 }); 51 }, intervalMs); 52 let beatStopped = false; 53 const stopBeat = (): void => { 54 if (beatStopped) return; 55 beatStopped = true; 56 clearInterval(beat); 57 }; 58 try { 59 const queueMs = run.startedAt !== null ? Math.max(0, run.startedAt - run.createdAt) : undefined; 60 const result = await deps.orchestrator.handleTurn({ 61 ...run.request, 62 origin: resolveTurnOrigin(run.request), 63 runId: run.id, 64 attempt: run.attempts, 65 finalAttempt: errorParks(run, deps.runs.maxClaims), 66 background: opts?.background ?? false, 67 cancel: cancel.signal, 68 ...(queueMs !== undefined ? { queueMs } : {}), 69 }); 70 stopBeat(); 71 await deps.runs.complete(run.id, token, result); 72 return result; 73 } catch (err) { 74 stopBeat(); 75 await deps.runs.fail(run.id, token, errMessage(err), { 76 retry: !(err instanceof NonRetryableTurnError), 77 }); 78 throw err; 79 } finally { 80 stopBeat(); 81 } 82}
src/runs/worker.ts:60 调 orchestrator.handleTurn 时会带 runId、attempt、finalAttempt、background 和 cancel signal;src/runs/worker.ts:71 完成 run,src/runs/worker.ts:75 失败时决定是否 retry。这种结构使长任务、自动化和恢复逻辑可以共用同一条执行语义。
▸Worker loop 有 transient claim backoff
101export function createWorker(deps: WorkerDeps): Worker { 102 const workerId = deps.workerId ?? `w-${randomUUID().slice(0, 8)}`; 103 const pollMs = deps.pollMs ?? 50; 104 let stopped = false; 105 let loopDone: Promise<void> | null = null; 106 let inFlight: { runId: string; leaseToken: string; threadRef: string } | null = null; 107 let releasedLeaseToken: string | null = null; 108 let releasing: Promise<void> | null = null; 109 110 async function loop(): Promise<void> { 111 let claimFailures = 0; 112 while (!stopped) { 113 if (deps.canClaim && !deps.canClaim()) { 114 await sleep(pollMs); 115 continue; 116 } 117 let run: Run | null; 118 try { 119 run = await deps.runs.claim(workerId, deps.leaseTtlMs); 120 claimFailures = 0; 121 } catch (e) { 122 claimFailures += 1; 123 if (claimFailures >= CLAIM_FAIL_CRASH_CONSECUTIVE) throw e; 124 swallow("worker: claim failed (transient, retrying)", e); 125 await sleep(Math.min(pollMs * 2 ** Math.min(claimFailures, 5), 5_000)); 126 continue; 127 } 128 if (!run) { 129 await sleep(pollMs); 130 continue; 131 } 132 if (stopped) { 133 if (run.leaseToken !== null) 134 await deps.runs 135 .releaseLease(run.id, run.leaseToken) 136 .catch((e) => swallow("worker: post-stop claim handback failed", e)); 137 break; 138 } 139 inFlight = 140 run.leaseToken !== null ? { runId: run.id, leaseToken: run.leaseToken, threadRef: run.sessionId } : null; 141 deps.onClaimed?.(); 142 try { 143 await processRun(deps, run, { background: true }); 144 } catch (e) { 145 swallow("worker: background run crashed", e); 146 } finally { 147 inFlight = null; 148 } 149 } 150 } 151 152 return { 153 start() { 154 if (loopDone) return; 155 stopped = false; 156 loopDone = loop(); 157 }, 158 busy() { 159 return inFlight !== null; 160 }, 161 releaseInFlight() { 162 const held = inFlight; 163 if (!held || held.leaseToken === releasedLeaseToken) return Promise.resolve(); 164 if (releasing) return releasing; 165 releasing = (async () => { 166 try { 167 if (await deps.runs.heartbeat(held.runId, held.leaseToken, deps.leaseTtlMs)) { 168 const session = await deps.sessions.getByThread(held.threadRef); 169 if (session) await deps.sessions.forceReleaseLease(session.id); 170 await deps.runs.releaseLease(held.runId, held.leaseToken); 171 } 172 releasedLeaseToken = held.leaseToken; 173 } catch (e) { 174 swallow("worker: releaseInFlight failed", e); 175 } finally { 176 releasing = null; 177 } 178 })(); 179 return releasing; 180 }, 181 async stop(drainMs = STOP_DRAIN_MS) { 182 stopped = true; 183 await Promise.race([loopDone, sleep(drainMs, { unref: true })]); 184 loopDone = null; 185 }, 186 }; 187}
src/runs/worker.ts:119 从 RunStore claim,src/runs/worker.ts:123 只有连续失败到阈值才崩溃,src/runs/worker.ts:161 还提供 releaseInFlight。它适合 blue-green 或多实例部署,因为 in-flight run 可以被显式释放而不是僵在某个进程里。
沙箱承载凭据、技能和文件系统的交汇点
src/core/orchestrator/sandboxes.ts:60 的 createTurnSandboxes 很像 turn 的硬件抽象层。它拿到 actor、session、resolution layers、connectorEnv、skillScopes、brokeredTools,再把这些材料推到 sandbox handle 上。
▸凭据 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:97 会对 owner-auth command 记录 keychain materialize 审计,src/core/orchestrator/sandboxes.ts:105 检测命令里调用了哪些 brokered tool,src/core/orchestrator/sandboxes.ts:120 再生成 shell wrapper。这里的重点是凭据不是永久塞进环境,而是按实际调用路径记录和注入。
▸Sandbox provision 带 resolution layers 和 egress token
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:200 把 resolution.layers 传给 sandbox.provision,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,形成“scope workspace + credential + outbox”的 turn computer。
▸Skill materialization 用 hash 避免重复铺文件
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:44 计算 indexHash,src/skills/materialize.ts:57 计算 treeHash,src/skills/materialize.ts:80 会把 pack root 提示追加到 rendered body。这个细节说明 QM 把 skill 当成可投影、可版本化的文件树,而不是每轮盲目复制目录。
▸Workspace path escape 被最小层拦住
16function safeJoin(baseDir: string, relPath: string): string { 17 const target = resolve(baseDir, relPath); 18 const rel = relative(baseDir, target); 19 if (rel.startsWith("..") || isAbsolute(rel)) { 20 throw new Error(`path escapes workspace: ${relPath}`); 21 } 22 return target; 23} 24 25export function createLocalWorkspaceStore(rootDir: string): WorkspaceStore { 26 const base = resolve(rootDir, "workspaces"); 27 28 function scopeDir(scopeId: ScopeId): string { 29 return join(base, scopeStorageKey(scopeId)); 30 } 31 32 const store: WorkspaceStore = { 33 scopeDir, 34 async ensureScope(scopeId) { 35 await mkdir(scopeDir(scopeId), { recursive: true }); 36 }, 37 async read(scopeId, relPath) { 38 const path = safeJoin(scopeDir(scopeId), relPath); 39 try { 40 return await readFile(path, "utf8"); 41 } catch { 42 return null; 43 } 44 }, 45 async readBytes(scopeId, relPath) { 46 const path = safeJoin(scopeDir(scopeId), relPath); 47 try { 48 return await readFile(path); 49 } catch { 50 return null; 51 } 52 }, 53 async write(scopeId, relPath, data) { 54 const path = safeJoin(scopeDir(scopeId), relPath); 55 await mkdir(dirname(path), { recursive: true }); 56 await writeFile(path, data); 57 }, 58 async remove(scopeId, relPath) { 59 await rm(safeJoin(scopeDir(scopeId), relPath), { force: true }); 60 }, 61 async list(scopeId) { 62 try { 63 const entries = await readdir(scopeDir(scopeId), { recursive: true, withFileTypes: true }); 64 return entries.filter((e) => e.isFile()).map((e) => join(e.parentPath, e.name)); 65 } catch { 66 return []; 67 } 68 }, 69 }; 70 return store;
src/workspace/workspace-store.ts:16 的 safeJoin 是很小但关键的底座:src/workspace/workspace-store.ts:19 拒绝向上跳出 workspace,src/workspace/workspace-store.ts:53 写入前创建父目录。多人 scope 和 Agent 写文件时,这类小函数比大口号更重要。
测试覆盖的是权限语义,不只是函数返回值
我最喜欢的测试证据在 cron scope shared:test/cron-scope-shared.test.ts:36 验证 scopeShared cron 会以 owner 身份运行但携带成员 audience,test/cron-scope-shared.test.ts:70 验证 owner 离开 managed group 后不能继续读,test/cron-scope-shared.test.ts:100 验证 scopeShared owner 离开当前 roster 会被禁用。
▸Scope shared 的正向测试
35describe("runTrigger: scopeShared unions the owner's keychain into the scope run", () => { 36 it("fires AS the owner, flags ownerKeychainUnion, carries the member audience, and delivers to the channel", async () => { 37 const { deps, seen } = captureDeps(); 38 const out = await runTrigger(deps, { 39 owner: "U-carol", 40 ownerScopeId: scopeId("channel", "C-eng"), 41 input: "compose", 42 fireKey: "ss1", 43 surface: "cron", 44 runAs: "scopeShared", 45 members, 46 destination: toChannel, 47 }); 48 assert.equal(out.ran, true); 49 assert.equal(seen.length, 1); 50 const req = seen[0]!; 51 assert.equal(req.ownerKeychainUnion, true, "the fire flags the owner∪scope keychain union"); 52 assert.equal(req.actor.externalId, "U-carol", "runs as the owner, not a substituted scope member"); 53 assert.equal(req.conversation.kind, "channel"); 54 assert.equal(req.conversation.audience?.length, 2); 55 assert.equal((await deps.deliveries.pending("slack")).length, 1); 56 }); 57 58 it("an owner cron does NOT set the union flag", async () => { 59 const { deps, seen } = captureDeps(); 60 await runTrigger(deps, { 61 owner: "U-carol", 62 ownerScopeId: scopeId("personal", "U-carol"), 63 input: "x", 64 fireKey: "o1", 65 surface: "cron", 66 }); 67 assert.equal(seen[0]!.ownerKeychainUnion, undefined); 68 });
test/cron-scope-shared.test.ts:51 断言 ownerKeychainUnion 被设置,test/cron-scope-shared.test.ts:52 断言 actor 仍是 owner,test/cron-scope-shared.test.ts:55 断言 delivery 入队。这个组合正好覆盖“用 owner 凭据、发到共享 scope”的危险边界。
▸离开 roster 的反向测试
70 it("an owner-mode task cannot read a managed group after leaving its current roster", async () => { 71 const { deps, seen } = captureDeps(); 72 deps.currentScopeMembers = async () => [members[1]!]; 73 const out = await runTrigger(deps, { 74 owner: "U-owner", 75 ownerScopeId: scopeId("group", "web-project-p1"), 76 input: "x", 77 fireKey: "owner-revoked", 78 surface: "cron", 79 }); 80 assert.equal(out.authzFailed, false); 81 assert.equal(out.ran, true); 82 assert.match(out.note ?? "", /no longer a member/); 83 assert.equal(seen.length, 0); 84 }); 85 86 it("a scopeFloor cron does NOT set the union flag (it runs at the scope floor, not the owner's creds)", async () => { 87 const { deps, seen } = captureDeps(); 88 await runTrigger(deps, { 89 owner: "U-carol", 90 ownerScopeId: scopeId("channel", "C-eng"), 91 input: "x", 92 fireKey: "f1", 93 surface: "cron", 94 runAs: "scopeFloor", 95 members, 96 }); 97 assert.equal(seen[0]!.ownerKeychainUnion, undefined); 98 }); 99 100 it("disables scopeShared when its owner left the current roster", async () => { 101 const { deps, seen } = captureDeps(); 102 deps.currentScopeMembers = async () => [members[1]!]; 103 const out = await runTrigger(deps, { 104 owner: "U-owner", 105 ownerScopeId: scopeId("channel", "C-eng"), 106 input: "x", 107 fireKey: "ss-revoked", 108 surface: "cron", 109 runAs: "scopeShared", 110 members, 111 }); 112 assert.equal(out.authzFailed, true); 113 assert.equal(out.ran, false); 114 assert.match(out.note ?? "", /no longer a current scope member/); 115 assert.equal(seen.length, 0); 116 });
test/cron-scope-shared.test.ts:80 和 test/cron-scope-shared.test.ts:112 分别覆盖 owner-mode 与 scopeShared 的拒绝路径。对一个 multiplayer agent harness 来说,这比单测 prompt formatting 更有价值,因为它证明权限跟成员资格变化联动。
技术亮点
▸多 harness 可替换,但不是任意可选
src/harness/harness-router.ts:84 用 adapters map 组合多个 harness,src/harness/harness-router.ts:99 发现 session 切换 harness 时会 reset prior 和 current session。这个设计比单纯 provider switch 更稳,能避免不同 runtime 共享一段脏 session 状态。
▸Run lease 是多实例部署的基本盘
src/runs/worker.ts:167 releaseInFlight 前会再次 heartbeat,src/runs/worker.ts:170 才 releaseLease。这个顺序让 worker 停机、滚动升级和手动释放不会轻易释放别的进程已经接管的 run。
▸Skill 投影考虑 pack 和 stale cleanup
src/skills/materialize.ts:222 处理旧 marker,src/skills/materialize.ts:237 清理不再 active 的 skill name,src/skills/materialize.ts:252 重新 lay 当前 entries。这里能看出作者在处理长期 resident sandbox,而不只是一轮临时容器。
风险与缺口
▸复杂度已经接近平台级系统
src/core/orchestrator.ts:129 暴露 OrchestratorInput/Deps,src/core/orchestrator.ts:189 一次创建会绑定大量依赖。好处是能力完整,坏处是新贡献者很难只靠 README 找到行为入口。
▸凭据与 resident sandbox 需要更强运维纪律
src/core/orchestrator/sandboxes.ts:158 有 scrubOwnerAuthHandle,src/core/orchestrator/sandboxes.ts:164 会 removeDeviceFlowLogins,但任何 resident computer 体系都要求部署者理解备份、恢复、隔离和审计边界。
▸适合组织内部,不适合轻量个人玩具
src/api/app-turn.ts:67 到 src/api/app-turn.ts:100 的成员与 scope 检查说明它面向多人组织场景;如果只是单人本地 coding agent,QM 的控制面可能显得太重。