diff --git a/apps/presentation/dashboard/smoke/workspace-scope-browser-smoke.mjs b/apps/presentation/dashboard/smoke/workspace-scope-browser-smoke.mjs index 34a295e7fc..f5f745b114 100644 --- a/apps/presentation/dashboard/smoke/workspace-scope-browser-smoke.mjs +++ b/apps/presentation/dashboard/smoke/workspace-scope-browser-smoke.mjs @@ -25,11 +25,22 @@ const written = spawnSync(python, ["-c", [ ].join("\n"), codex], { cwd: repoRoot, encoding: "utf8" }); assert.equal(written.status, 0, written.stderr); -const server = spawn(python, ["-c", "import sys; from loopx.entrypoint import main; sys.exit(main())", - "chat", "--no-open", "--port", String(port), "--codex-bin", codex, "--scan-root", workspace, "--global-registry"], -{ cwd: root, env: { ...process.env, HOME: join(root, "home"), PYTHONPATH: repoRoot }, stdio: ["ignore", "pipe", "pipe"] }); let serverError = ""; -for (const stream of [server.stdout, server.stderr]) stream.on("data", (chunk) => { serverError += String(chunk); }); +function startServer(grant) { + const child = spawn(python, ["-c", "import sys; from loopx.entrypoint import main; sys.exit(main())", + "chat", "--no-open", "--port", String(port), "--codex-bin", codex, "--scan-root", workspace, + "--global-registry", "--project-workspace-grant", grant], + { cwd: root, env: { ...process.env, HOME: join(root, "home"), PYTHONPATH: repoRoot, LOOPX_USAGE_PING: "0" }, stdio: ["ignore", "pipe", "pipe"] }); + for (const stream of [child.stdout, child.stderr]) stream.on("data", (chunk) => { serverError += String(chunk); }); + return child; +} +let server = startServer("workspace_read"); +async function stopServer() { + if (server.exitCode !== null) return; + const exited = new Promise((resolveExit) => server.once("exit", resolveExit)); + server.kill(); + await exited; +} // The first-run usage notice is unrelated to this journey; keep evidence focused. async function capture(target, name) { if (!screenshots) return; @@ -61,6 +72,16 @@ try { await page.getByLabel("发送消息").fill(question); await page.keyboard.press("Enter"); await page.getByText("Runtime response.").first().waitFor({ timeout: 15_000 }); + // Upgrade a persisted read-only workspace through the same ordinary Scope entry. + await stopServer(); + server = startServer("workspace_write"); + await waitForHttp(url).catch((error) => { throw new Error(`${error.message}\n${serverError}`); }); + await page.reload({ waitUntil: "networkidle" }); + await page.getByText("工作区对话 · 读写").waitFor({ timeout: 15_000 }); + await page.getByText(question).first().waitFor({ timeout: 15_000 }); + await page.getByLabel("发送消息").fill("继续整理素材"); + await page.keyboard.press("Enter"); + await page.getByText("Runtime response.").nth(1).waitFor({ timeout: 15_000 }); await capture(page, "ordinary-workspace-conversation.png"); const projectRequests = sessionRequests.filter((body) => body.context_kind === "project"); @@ -98,6 +119,6 @@ try { console.log("workspace scope browser smoke ok"); } finally { await browser?.close(); - server.kill(); + await stopServer(); await rm(root, { force: true, recursive: true }); } diff --git a/apps/presentation/dashboard/src/data/chat-model.ts b/apps/presentation/dashboard/src/data/chat-model.ts index e39d350406..66bb18b38e 100644 --- a/apps/presentation/dashboard/src/data/chat-model.ts +++ b/apps/presentation/dashboard/src/data/chat-model.ts @@ -1,7 +1,7 @@ import type { GoalDraft } from "../../../../../loopx/control_plane/collaboration/goal_draft.js"; export type LoopXModeSettings = { agent_id: string; token_budget: number }; /** A host-granted workspace an ordinary conversation may be scoped to. */ -export type ChatProject = { project_ref: string; title: string; grant: "workspace_read" }; +export type ChatProject = { project_ref: string; title: string; grant: "workspace_read" | "workspace_write" }; export type ChatTodo = { todo_id: string | null; diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index 90967bc04c..e48c2b1452 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -2385,7 +2385,7 @@ const privateAgentTargetSchema = privateAgentSessionSchema.extend({target_ref: z const privateConversationSchema = z.object({ binding_id: z.string(), app_ref: z.string(), context_kind: z.enum(["project", "steward"]), project_ref: z.string(), project_title: z.string(), context_available: z.boolean(), executor_endpoint_id: z.string(), - grant: z.enum(["workspace_read", "portfolio_read"]), goal_count: z.number().int().default(0), listener_status: z.string(), + grant: z.enum(["workspace_read", "workspace_write", "portfolio_read"]), goal_count: z.number().int().default(0), listener_status: z.string(), pending_count: z.number().int(), recovery_count: z.number().int(), agent_candidates: z.array(privateAgentSessionSchema).default([]), agent_targets: z.array(privateAgentTargetSchema).default([]), }); @@ -2395,10 +2395,10 @@ export type PrivateConversation = z.infer; export async function fetchPrivateConversations() { return privateConversationsSchema.parse(await requestJson("/api/chat/lark/private-conversations")); } -export async function connectPrivateConversation(appRef: string, projectRef: string, executor: string, contextKind: "project" | "steward" = "project") { +export async function connectPrivateConversation(appRef: string, projectRef: string, executor: string, contextKind: "project" | "steward" = "project", projectGrant: "workspace_read" | "workspace_write" = "workspace_write") { return privateConversationsSchema.parse(await requestJson("/api/chat/lark/private-conversations", { method: "POST", headers: {"Content-Type": "application/json"}, - body: JSON.stringify({app_ref: appRef, project_ref: projectRef, executor_endpoint_id: executor, context_kind: contextKind}), + body: JSON.stringify({app_ref: appRef, project_ref: projectRef, executor_endpoint_id: executor, context_kind: contextKind, project_grant: projectGrant}), })); } export async function disconnectPrivateConversation(bindingId: string, revision: number) { diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index d5a924c4a6..5707c8a412 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -1155,6 +1155,7 @@ const en = { "header.scopeWorkspacesUnavailable": "Workspaces could not be read", "workspace.conversation": "Workspace conversation · {grant}", "workspace.grantRead": "read-only", + "workspace.grantWrite": "read and write", "workspace.grantRevoked": "no longer authorized", "workspace.unknownTitle": "Unavailable workspace", "workspace.unavailable": "This host no longer authorizes this workspace. The history is kept; new messages will be rejected until it is authorized again.", @@ -2445,6 +2446,7 @@ const zhCN: Record = { "header.scopeWorkspacesUnavailable": "暂时无法读取工作区", "workspace.conversation": "工作区对话 · {grant}", "workspace.grantRead": "只读", + "workspace.grantWrite": "读写", "workspace.grantRevoked": "已不再授权", "workspace.unknownTitle": "不可用的工作区", "workspace.unavailable": "此宿主已不再授权这个工作区。历史记录会保留;重新授权前,新消息会被拒绝。", diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx index 109052f7b1..5c26d77e7d 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx @@ -1880,7 +1880,7 @@ export function PersonalWorkspacePage({ ], value: selectedWorkspaceRef ?? stewardScopeValue, } : null} - workspaceGrantLabel={selectedWorkspaceRef ? t(workspaceUnavailable ? "workspace.grantRevoked" : "workspace.grantRead") : null} + workspaceGrantLabel={selectedWorkspaceRef ? t(workspaceUnavailable ? "workspace.grantRevoked" : selectedWorkspaceProject?.grant === "workspace_write" ? "workspace.grantWrite" : "workspace.grantRead") : null} managerChatOpen={managerChatOpen} managerChannelBinding={managerChannelBinding} managerRuntime={managerRuntime} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/private-conversation-panel.tsx b/apps/presentation/dashboard/src/features/personal-workspace/private-conversation-panel.tsx index f98159343b..ecdefdcae6 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/private-conversation-panel.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/private-conversation-panel.tsx @@ -17,6 +17,7 @@ export function PrivateConversationPanel() { const [project, setProject] = useState(""); const [executor, setExecutor] = useState(""); const [role, setRole] = useState<"project" | "steward">("project"); + const [projectGrant, setProjectGrant] = useState<"workspace_read" | "workspace_write">("workspace_write"); const [error, setError] = useState(""); const [busy, setBusy] = useState(false); @@ -42,6 +43,9 @@ export function PrivateConversationPanel() { return () => {active = false; clearInterval(timer);}; }, []); + const effectiveProjectGrant = executor === "codex" && projects.find(item => item.project_ref === project)?.grant === "workspace_write" + ? projectGrant : "workspace_read"; + function listenerLabel(state: string) { const labels: Record = {starting: ["启动中", "Starting"], listening: ["实时连接就绪", "Live connection ready"], standby: ["等待已有监听服务", "Waiting for the existing listener"], retrying: ["重连中", "Reconnecting"], @@ -54,12 +58,20 @@ export function PrivateConversationPanel() { try {await operation(); await refresh();} catch (error) {setError(String(error));} finally {setBusy(false);} } + function selectApp(appRef: string) { + setApp(appRef); + const saved = rows.find(row => row.app_ref === appRef); + if (saved) { + setProject(saved.project_ref); setExecutor(saved.executor_endpoint_id); setRole(saved.context_kind); + setProjectGrant(saved.grant === "workspace_write" ? "workspace_write" : "workspace_read"); + } else setProjectGrant("workspace_write"); + } return

{zh ? "本人私聊 · 项目助手与管家" : "Owner private Chat · Project assistant and steward"}

-

{zh ? "每个 App 单独核验本人。项目助手只读讨论工作区,不创建隐藏 Goal。管家从空 portfolio 开始,只管理在此入口明确确认的新委托。" : "Verify the owner independently for each App. Project Chat discusses the workspace without hidden Goals. A steward starts with an empty portfolio and manages only new commissions explicitly confirmed here."}

+

{zh ? "每个 App 单独核验本人。项目助手默认支持工作区读写,按项目规则与 skills 执行编辑;可选只读。普通聊天不创建隐藏 Goal。管家从空 portfolio 开始,只管理在此入口明确确认的新委托。" : "Verify the owner independently for each App. Project Chat defaults to workspace writes under project rules and skills; read-only remains available without hidden Goals. A steward starts with an empty portfolio and manages only new commissions explicitly confirmed here."}

{rows.map(row =>
{row.app_ref} · {row.context_available ? row.project_title : (zh ? "工作区不可用" : "Workspace unavailable")} -

{row.context_kind === "steward" ? (zh ? `LoopX 管家 · ${row.goal_count === 0 ? "暂无已授权的新委托;没有继承旧目标。" : `${row.goal_count} 个已确认的新委托`}` : `LoopX steward · ${row.goal_count} new confirmed commissions; no inherited Goals.`) : (zh ? "普通项目助手 · 只读对话" : "Project assistant · Read-only Chat")}

+

{row.context_kind === "steward" ? (zh ? `LoopX 管家 · ${row.goal_count === 0 ? "暂无已授权的新委托;没有继承旧目标。" : `${row.goal_count} 个已确认的新委托`}` : `LoopX steward · ${row.goal_count} new confirmed commissions; no inherited Goals.`) : (row.grant === "workspace_write" ? (zh ? "普通项目助手 · 已授权工作区读写" : "Project assistant · Workspace writes authorized") : (zh ? "普通项目助手 · 只读对话" : "Project assistant · Read-only Chat"))}

{row.executor_endpoint_id} · {zh ? "监听状态" : "Listener"}: {listenerLabel(row.listener_status)}

{zh ? `待处理或回复:${row.pending_count}` : `Pending execution or reply: ${row.pending_count}`}

{row.recovery_count > 0 ?

{zh ? "存在尚未确认的发送回执。服务会读取原回执恢复;不要重新发送同一任务。检查 App 登录、权限和原会话后刷新状态。" : "A send receipt is unconfirmed. The service reads the original receipt to recover; avoid resending the same task. Check this App login, permissions and original Session, then refresh status."}

: null} @@ -68,7 +80,7 @@ export function PrivateConversationPanel() {
)} {rows.length === 0 ?

{zh ? "尚未连接本人私聊。" : "No owner private Chat connected."}

: null} - @@ -82,7 +94,12 @@ export function PrivateConversationPanel() { -

{zh ? "从手机发送文字开始;后续消息进入原会话队列。/status 查看工作区、角色与持久排队状态,/help 查看用法与解绑入口,/stop 停止当前聊天执行,/new 开启新会话。图片、文件会明确提示暂不支持。" : "Send text from your phone to begin; follow-ups queue in the same Session. /status shows the workspace, role and durable queue, /help explains commands and where to unbind, /stop stops the current Chat Turn, /new starts a new conversation. Images and files receive an explicit unsupported response."}

diff --git a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md index ea1fd3c1c3..4ecb31bd2a 100644 --- a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md +++ b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md @@ -30,9 +30,9 @@ the TypeScript owner decides context identity and scope. Native Codex resume ret the original upstream thread and workspace. HTTP/protocol fixtures qualify that continuity and denial behavior; they do not establish real model adoption. -The App grant is workspace reading for the local owner. The App scope alone does -not qualify Lark private-message admission, installed or mobile journeys, or -authorize edits. A revoked grant keeps the history readable and blocks new +The local App grant covers the selected owner workspace under its current host +permissions. Scope selection alone does not qualify Lark private-message +admission, installed or mobile journeys. A revoked grant keeps history readable and blocks new messages until the host grants it again. The independent Lark checkpoint below qualifies its source implementation separately. @@ -51,8 +51,7 @@ shared request owner, and a replay retains the original Session/Turn target. A lost admission correlation cannot move a request to a newer Session. Admission feedback and final delivery use separate durable provider intents; an ambiguous write is read back without blind resend. Unsupported media receives an explicit -notice. This grant remains workspace reading, without a Goal, portfolio or peer -execution authority. +notice. The workspace grant conveys no Goal, portfolio or peer execution authority. The packaged settings journey, source revocation/recovery, duplicate events, native queue/stop and two-App isolation have synthetic-provider regression and @@ -74,6 +73,42 @@ Synthetic product previews: [desktop](../../assets/personal-workspace/private-pr [narrow](../../assets/personal-workspace/private-project-conversations-narrow.png), [revoked workspace](../../assets/personal-workspace/private-project-workspace-revoked.png). +## Ordinary workspace writes: default and revocation checkpoint + +Ordinary project Chat defaults to `workspace_write` for host-declared roots. The +Core context owner derives the actual Codex `workspace-write` sandbox on start +and exact-thread resume; this is conversational work, without Task/Goal mode or +manager permissions. The project prompt follows workspace `AGENTS.md` and skills, +permits requested bounded edits, and keeps durable operations with their existing +owners. A write grant does not activate Material Lifecycle or certify a project +adapter's intake/ranking workflow. + +`loopx chat --project-workspace-grant workspace_read` restricts the host to +read-only, including Lark bindings. Settings → Lark defaults a Codex project App +to workspace writes within that host grant and exposes an explicit read-only +choice. Other executors remain read-only until their host policy is qualified. +The readback, `/status` and `/help` show the effective grant. Selecting an existing +App restores its persisted setting. Changing a binding's grant requires a new +identity and Session, invalidates the old Session for new work, and requires new +Agent target grants; it cannot silently elevate an attached host or another App. +Host grant removal or downgrade is rechecked before admission and resume. Ordinary +local Scope reuses only the same typed project context; a host grant change opens +a new Session while retaining old history and rejecting new work on the old +Session. + +Typed Core/HTTP/native-host regressions qualify default writes, explicit read-only, +workspace identity, independent App grants, old-Session rejection and exact-thread +resume. Earlier source evidence records a Codex canary editing and reading back a +synthetic note while preserving prior text and creating no Goal. This historical +host/filesystem evidence is separate from current synthetic-protocol regression +checks and does not qualify live Lark, material intake or a release. +Committed same-claim replay is separate from new admission: after a lost response +and target revocation, the original host can recover its committed receipt; new +claims and external result publication remain denied. Current validation uses real +Core HTTP, file storage and the claim broker with synthetic Codex/provider +transport; it does not rerun a live-model edit or phone journey. Installed/Lark +journeys and broader IM interactions remain open. + ## Bound steward private Chat: explicit new commissions Settings → Lark can now select a steward role independently of ordinary project diff --git a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.zh-CN.md b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.zh-CN.md index 36c1c34752..92fba2707c 100644 --- a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.zh-CN.md +++ b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.zh-CN.md @@ -25,11 +25,11 @@ grant 授权 peer delegation。 store,TypeScript 负责上下文身份及范围。原生 Codex 恢复保留原 upstream thread 和工作区;HTTP/协议 fixture 验证连续性与拒绝行为,不证明真实模型采用了上下文。 -App grant 仅面向本机 owner 的工作区读取。App 范围入口本身不证明 Lark 私聊 -admission、安装或手机旅程已通过,也不授权修改文件;grant 撤销后历史仍可读, +App grant 仅面向本机 owner 明确授权的工作区操作。App 范围入口本身不证明 Lark 私聊 +admission、安装或手机旅程已通过;grant 撤销后历史仍可读, 新消息在宿主重新授权前被阻止。下方独立检查点说明 Lark 的源码实现资格。 -本机 owner 的只读工作区入口现已接入独立核验的 Lark App 私聊绑定。既有设置 → +本机 owner 的工作区入口现已接入独立核验的 Lark App 私聊绑定。既有设置 → Lark 页面选择一个非默认 App、当前可用工作区和宿主 executor,读回监听状态、待回复 数量及恢复缺口;重新绑定不会把旧 Session 移到其它工作区。 @@ -42,7 +42,7 @@ provider 读回确认回复;发生没有 receipt 的不确定写入时不盲 后续消息持久排队、exact stop,以及重启后原会话恢复和已确认回复不重复发送。 这些是合成 provider/协议验收;真实原生 Codex 另行验证独立线程与上下文隔离。 真实 Lark 收发、安装候选、手机旅程、注册 Agent 选择、媒体/增量/权限回调,以及 -更广的长期协调仍未验收。这一普通项目绑定保持只读,新增管家入口见下一检查点。 +更广的长期协调仍未验收。普通项目权限以当前 workspace grant 为准,新增管家入口保持独立授权。 私聊配置复用设置 → Lark;App 范围仍是本机普通对话的唯一入口。未存储 App 身份 的旧群聊 profile 保留原 profile-hash 监听锁键。没有私聊绑定时不增加鉴权;有绑定 @@ -53,6 +53,31 @@ extension,typed binding owner 继续保持 provider-neutral。 ![窄屏私聊设置](../../assets/personal-workspace/private-project-conversations-narrow.png) ![工作区撤权读回](../../assets/personal-workspace/private-project-workspace-revoked.png) +## 普通工作区读写:默认值与撤权检查点 + +普通项目 Chat 对宿主声明的工作区默认使用 `workspace_write`。Core context owner +在启动和原线程恢复时导出真实 Codex `workspace-write` 沙箱;仍是普通对话,不借 +Task/Goal 模式或 manager 权限。项目 prompt 要求读取工作区 AGENTS.md 与适用 skills, +执行明确请求所需的有界编辑;持久状态继续走既有 owner。读写 grant 本身不激活 +Material Lifecycle,也不证明项目 adapter 的 intake/ranking 已接通。 + +`loopx chat --project-workspace-grant workspace_read` 将宿主及 Lark 项目绑定限制为 +只读。设置 → Lark 的 Codex 项目 App 默认读写,并保留只读选项;其它 executor 在 +宿主策略完成验收前保持只读。设置读回、/status 和 /help 显示实际 grant,重新选择 +已配置 App 会恢复持久配置。改变 grant 会创建新绑定及 Session,拒绝旧会话的新工作, +直连 Agent 必须重新授权;不能偷偷提高 attached 宿主或另一 App 的权限。宿主撤权或 +降为只读后,入站受理及恢复重新核验授权。本机 Scope 只复用相同 typed 项目上下文; +宿主授权变化时创建新会话,保留旧历史,拒绝在旧会话上执行新工作。 + +typed Core、HTTP 与原生宿主回归覆盖默认读写、明确只读、工作区身份、App 独立授权、 +旧会话拒绝和原线程恢复。既有源码证据记录了 Codex canary 编辑并读回合成笔记、 +保留原文且不创建 Goal;这项历史宿主/文件系统证据与本次合成协议回归分开, +不代表真实 Lark 写入、素材 intake 或发布完成。 +同 claim 已领取回执恢复与新任务准入分开:响应丢失后撤权,原宿主可读回已提交回执; +新领取及外部结果发送仍拒绝。验证使用真实 Core HTTP、文件存储、领取 broker 与 +合成 Codex/provider;不声称本次运行真实模型编辑或手机旅程。安装与真实 Lark 旅程、 +更多 IM 交互仍未关闭。 + ## 本人私聊管家:明确的新委托 设置 → Lark 可为独立 App 选择管家角色。既有 typed conversation binding diff --git a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md index 4e6e7dd736..d18cb35f2f 100644 --- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md +++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md @@ -415,6 +415,14 @@ Chat, session, collaboration and presentation owners as R2/R3 integration work. Do not call a candidate catalog, spinner or queued inbox receipt a completed worker handoff. This checkpoint does not lower R1–R3 or G1 gates. +The [ordinary workspace write checkpoint](app-conversation-and-async-inbox-v0.md#ordinary-workspace-writes-default-and-revocation-checkpoint) +adds default project writes under the host grant, explicit read-only launch/App +settings and actual Codex sandbox enforcement on start/resume. It reuses the typed +conversation owner and has no hidden Goal or manager permissions. Synthetic +Core/HTTP cases and a real host note-edit canary do not establish installed Lark +workflow readiness or activate a project's Material Lifecycle adapter. R3/S5 +still require original-audience write/readback, revoke/recovery and IM qualification. + ### Attention-cost acceptance: create, connect, collaborate, understand The [public-safe golden-query pack](../../product/use-cases/steward/golden-queries.md) diff --git a/docs/assets/personal-workspace/ordinary-workspace-conversation-narrow.png b/docs/assets/personal-workspace/ordinary-workspace-conversation-narrow.png index 31b468edde..9f43788419 100644 Binary files a/docs/assets/personal-workspace/ordinary-workspace-conversation-narrow.png and b/docs/assets/personal-workspace/ordinary-workspace-conversation-narrow.png differ diff --git a/docs/assets/personal-workspace/ordinary-workspace-conversation.png b/docs/assets/personal-workspace/ordinary-workspace-conversation.png index 2dbc91ee58..29b78c6847 100644 Binary files a/docs/assets/personal-workspace/ordinary-workspace-conversation.png and b/docs/assets/personal-workspace/ordinary-workspace-conversation.png differ diff --git a/docs/assets/personal-workspace/ordinary-workspace-grant-rejection.png b/docs/assets/personal-workspace/ordinary-workspace-grant-rejection.png index c4911c3714..28adb373f7 100644 Binary files a/docs/assets/personal-workspace/ordinary-workspace-grant-rejection.png and b/docs/assets/personal-workspace/ordinary-workspace-grant-rejection.png differ diff --git a/loopx/canary/module_metric_baseline.json b/loopx/canary/module_metric_baseline.json index bcaeb848a7..467b089e9b 100644 --- a/loopx/canary/module_metric_baseline.json +++ b/loopx/canary/module_metric_baseline.json @@ -32,7 +32,8 @@ }, "loopx/chat_runtime.py": { "any_count": 35, - "dict_any_count": 0 + "dict_any_count": 0, + "lines": 2006 }, "loopx/chat_server.py": { "any_count": 25, diff --git a/loopx/capabilities/native_chat/conversation_bindings.py b/loopx/capabilities/native_chat/conversation_bindings.py index 4a3100d167..d54c932999 100644 --- a/loopx/capabilities/native_chat/conversation_bindings.py +++ b/loopx/capabilities/native_chat/conversation_bindings.py @@ -42,7 +42,12 @@ def _core(operation: str, params: dict[str, Any]) -> dict[str, Any]: return result def configure(self, *, transport_ref: str, project_ref: str, - executor_endpoint_id: str, context_kind: str = "project") -> dict[str, Any]: + executor_endpoint_id: str, context_kind: str = "project", + project_grant: str | None = None) -> dict[str, Any]: + if project_grant is None: + project_grant = self.projects.workspace_grant if context_kind == "project" and executor_endpoint_id == "codex" else "workspace_read" + if project_grant not in {"workspace_read", "workspace_write"} or (context_kind != "project" and project_grant != "workspace_read"): + raise ValueError("workspace write authorization is only available for project Chat") observation = self.observe(transport_ref) candidate = { "schema_version": "loopx_chat_conversation_binding_v0", @@ -50,13 +55,13 @@ def configure(self, *, transport_ref: str, project_ref: str, "provider_ref": observation["provider_ref"], "operator_ref": observation["operator_ref"], "context_kind": context_kind, "project_ref": project_ref, "executor_endpoint_id": executor_endpoint_id, - "grant": "workspace_read" if context_kind == "project" else "portfolio_read", "enabled": True, + "grant": project_grant if context_kind == "project" else "portfolio_read", "enabled": True, **({"goal_ids": []} if context_kind == "steward" else {}), } with exclusive_file_lock(self.path, operation="configure_chat_conversation_binding"): current = self.read() previous = next((row for row in current["bindings"] if row["transport_ref"] == transport_ref), None) - if previous and all(previous.get(key) == candidate.get(key) for key in ["context_kind", "project_ref", "executor_endpoint_id", "provider_ref", "operator_ref"]): + if previous and all(previous.get(key) == candidate.get(key) for key in ["context_kind", "project_ref", "executor_endpoint_id", "provider_ref", "operator_ref", "grant"]): if previous.get("agent_targets"): candidate["agent_targets"] = previous["agent_targets"] if (context_kind == "steward" and previous and all(previous.get(key) == candidate.get(key) diff --git a/loopx/capabilities/native_chat/project_context.py b/loopx/capabilities/native_chat/project_context.py index ca9c352bcb..a0a4b7364b 100644 --- a/loopx/capabilities/native_chat/project_context.py +++ b/loopx/capabilities/native_chat/project_context.py @@ -21,9 +21,22 @@ "Do not create an implicit Goal or borrow the global manager identity." ) +PROJECT_WORK_OBJECTIVE = ( + "Carry out the owner's explicit requests in the selected workspace, preserving " + "this Session's context. The explicit workspace write grant permits bounded " + "file edits and project workflows. Read and follow the workspace AGENTS.md " + "and applicable project skills. Use existing typed owners for durable state; " + "do not bypass material authority, intake, ranking or readback gates. The grant " + "does not create a LoopX Goal, scheduling, delegation or portfolio access. " + "Do not create an implicit Goal or borrow the global manager identity." +) + class ChatProjectContexts: - def __init__(self, roots: list[Path]) -> None: + def __init__(self, roots: list[Path], *, workspace_grant: str = "workspace_write") -> None: + if workspace_grant not in {"workspace_read", "workspace_write"}: + raise ValueError("unsupported project workspace grant") + self.workspace_grant = workspace_grant # Remember the owner's spelling as well as its initial canonical target. # A later symlink retarget must not redirect an accepted Session. self.roots = [(root.expanduser().absolute(), root.expanduser().resolve()) for root in roots] @@ -37,7 +50,7 @@ def available(self) -> list[dict[str, str]]: ref = hashlib.sha256(str(canonical).encode("utf-8")).hexdigest()[:24] contexts[ref] = {"kind": "project_workspace", "project_ref": ref, "workspace_path": str(canonical), "audience": "local_owner", - "grant": "workspace_read"} + "grant": self.workspace_grant} return list(contexts.values()) def resolve(self, project_ref: str, *, session_context: dict[str, Any] | None = None) -> dict[str, Any]: @@ -72,7 +85,7 @@ def session_context(self, session: dict[str, Any]) -> dict[str, Any]: if session.get("channel_id") != selected["channel_id"]: raise ValueError("project conversation channel mismatch") return {"project": Path(selected["context"]["workspace_path"]), - "objective": PROJECT_CONVERSATION_OBJECTIVE, + "objective": PROJECT_WORK_OBJECTIVE if selected["context"]["grant"] == "workspace_write" else PROJECT_CONVERSATION_OBJECTIVE, "title": Path(selected["context"]["workspace_path"]).name} def open_bound(self, binding_id: str, source: dict[str, Any], *, executor: str, channel_id: str | None) -> dict[str, Any]: diff --git a/loopx/chat_agent.py b/loopx/chat_agent.py index bf8e5ce92f..42d6b1435e 100644 --- a/loopx/chat_agent.py +++ b/loopx/chat_agent.py @@ -325,6 +325,7 @@ def _turn_prompt( context_summary: str = "", execution_mode: bool = False, runtime_profile: str = "restricted", + project_work: bool = False, ) -> str: envelope = { "schema_version": CHAT_AGENT_RESPONSE_SCHEMA_VERSION, @@ -341,6 +342,8 @@ def _turn_prompt( "Use the existing branch and worktree. Commit or push only when the operator task explicitly requests it. " "Keep changes bounded to the confirmed Task and stop at any permission, identity, or destructive-operation gate. " if execution_mode + else "You are the project assistant inside LoopX Chat. Execute the owner's explicit workspace requests using the project's AGENTS.md and applicable skills. " + if project_work else "You are the planning agent inside LoopX Chat. Work only from the project root. " ) trusted_manager_limits = ( @@ -353,9 +356,17 @@ def _turn_prompt( "Use read-only repository commands only when the operator explicitly asks for repository facts or when evidence is required to answer accurately. " "Do not use tools for ordinary conversation, exact-wording requests, or status questions that can be answered from the supplied LoopX context. " "Do not edit files, mutate LoopX state, create commits, send messages, or request elevated access. " - if not execution_mode and runtime_profile != "trusted_owner" + if not execution_mode and runtime_profile != "trusted_owner" and not project_work else "" ) + if project_work: + planning_limits = ( + "The owner explicitly authorized workspace writes for this App. Perform bounded reversible edits and validation required by the current request. " + "This is ordinary project work without a Goal: do not create a hidden Goal, schedule work, discover a portfolio, or assume manager authority. " + "Use existing typed owners for durable state and obey project material lifecycle and public/private rules. " + "Read skill instructions before using them; a missing authority or source is a concrete gap, never permission to invent a store or import history. " + "Commit, publish or send external messages only when the owner explicitly requests them. " + ) protected_action_contract = ( "For a protected operation (merge, release, deploy, delete, or payment), interpret the operator's semantic intent. " "Set protected_action only when the dominant request is to perform exactly one operation now and the operator supplied a concrete target. " @@ -428,6 +439,7 @@ class CodexChatAgentSession: process_tree_owned: bool = False runtime_profile: str = "restricted" sandbox: str = "read-only" + project_context: dict[str, str] | None = None model: str | None = None reasoning_effort: str | None = None response_timeout_sec: float = 30.0 @@ -474,6 +486,7 @@ def start( isolate_process_tree: bool = False, runtime_profile: str = "restricted", sandbox: str | None = None, + project_context: dict[str, str] | None = None, codex_home: Path | None = None, model: str | None = None, reasoning_effort: str | None = None, @@ -493,7 +506,18 @@ def start( root = work_dir.resolve() if runtime_profile not in {"restricted", "trusted_owner"}: raise ValueError("unsupported Codex Chat runtime profile") - if execution_mode: + if project_context is not None: + from .control_plane.effect_runtime import effect_runtime_result + + if execution_mode or goal_id is not None or runtime_profile != "restricted": + raise ValueError("ordinary project runtime cannot borrow Goal or manager authority") + policy = effect_runtime_result("collaboration.project.session_identity", {"context": project_context}) + if Path(policy["context"]["workspace_path"]).resolve() != root: + raise ValueError("project runtime workspace does not match its context") + selected_sandbox = policy["sandbox"] + if sandbox is not None and sandbox != selected_sandbox: + raise ValueError("project sandbox does not match its workspace grant") + elif execution_mode: if runtime_profile != "restricted": raise ValueError( "trusted_owner is only valid for the non-execution manager runtime" @@ -573,6 +597,7 @@ def start( process_tree_owned=isolate_process_tree, runtime_profile=runtime_profile, sandbox=selected_sandbox, + project_context=policy["context"] if project_context is not None else None, model=model, reasoning_effort=reasoning_effort, model_catalog_compatibility_applied=_compatibility_catalog_path is not None, @@ -671,6 +696,7 @@ def start( isolate_process_tree=isolate_process_tree, runtime_profile=runtime_profile, sandbox=selected_sandbox, + project_context=project_context, codex_home=runtime_home, model=model, reasoning_effort=reasoning_effort, @@ -809,7 +835,7 @@ def _check_server_gate(self, message: dict[str, Any]) -> bool: raise CodexChatAgentError( "Codex app-server requested host approval", gate=_approval_gate( - "Codex requested host approval during a read-only chat turn." + "Codex requested host approval beyond this Chat session's configured grant." ), ) return False @@ -959,6 +985,7 @@ def send( context_summary=self.context_summary, execution_mode=self.execution_mode, runtime_profile=self.runtime_profile, + project_work=self.project_context is not None and self.sandbox == "workspace-write", ), } ] diff --git a/loopx/chat_runtime.py b/loopx/chat_runtime.py index 2694a636dd..2fc330457a 100644 --- a/loopx/chat_runtime.py +++ b/loopx/chat_runtime.py @@ -121,6 +121,7 @@ def start( execution_mode: bool = False, runtime_profile: str = "restricted", sandbox: str | None = None, + project_context: dict[str, str] | None = None, codex_home: Path | None = None, model: str | None = None, reasoning_effort: str | None = None, @@ -138,6 +139,7 @@ def start( execution_mode=execution_mode, runtime_profile=runtime_profile, sandbox=sandbox, + project_context=project_context, resume_thread_id=resume_thread_id, codex_home=codex_home, model=model, @@ -410,7 +412,10 @@ def _start_adapter( project_coordination: bool = False, loopx_tools: bool = False, executor_model: Mapping[str, str | None] | None = None, + project_context: dict[str, str] | None = None, ) -> ChatRuntimeAdapter: + if project_context is not None and project_context.get("grant") == "workspace_write" and agent_id != "codex": + raise ValueError("the selected executor cannot enforce workspace write authorization") if ( manager_runtime is not None and manager_runtime.get("runtime_profile") == "trusted_owner" @@ -474,6 +479,7 @@ def _start_adapter( if manager_profile is not None else None ), + project_context=project_context, model=model_config.get("model"), reasoning_effort=model_config.get("reasoning_effort"), dynamic_tools=( @@ -641,6 +647,10 @@ def open_session( agent_id=agent_id, channel_id=selected_channel, ) + # Reuse only the same typed project identity. A changed host grant + # starts a new Session while the old context and history remain intact. + if latest is not None and project_context is not None and latest.get("project_context") != project_context: + latest = None if latest is not None and latest.get("session_mode") == CHAT_SESSION_MODE_ATTACHED: return latest, True if capability is None: @@ -661,6 +671,7 @@ def open_session( execution_mode=selected_channel.startswith("task."), project_coordination=conversation_scope({"channel_id": selected_channel, "goal_id": goal_id})["kind"] == "owner_goal", manager_runtime=manager_runtime, + project_context=project_context, executor_model=alloc.manager_executor_model(manager_executor_allocation), ) persisted = self.store.create_session( @@ -912,6 +923,7 @@ def _ensure_adapter_locked( executor_model=(alloc.manager_executor_model(model_allocation) if model_allocation is not None else alloc.restored_executor_model(session)), manager_runtime=manager_runtime, + project_context=session.get("project_context"), ) if session.get("upstream_mode") == CODEX_GOAL_CHAT_MODE: try: diff --git a/loopx/chat_server.py b/loopx/chat_server.py index bec518dbe7..dc898da8df 100644 --- a/loopx/chat_server.py +++ b/loopx/chat_server.py @@ -1556,6 +1556,7 @@ def serve_chat( open_browser: bool = False, verbose: bool = False, enable_goal_subagent_configuration: bool = False, + project_workspace_grant: str = "workspace_write", ) -> None: if not is_loopback_host(host): raise ValueError("loopx chat requires a loopback --host such as 127.0.0.1") @@ -1609,7 +1610,7 @@ def serve_chat( server.runtime_controller = ChatRuntimeController( store=server.chat_store, registry_path=resolved_registry_path, - project_contexts=ChatProjectContexts(resolved_scan_roots), + project_contexts=ChatProjectContexts(resolved_scan_roots, workspace_grant=project_workspace_grant), manager_scope_resolver=lambda session: ( server.runtime_controller.project_contexts.conversation_bindings.steward_scope(session) if isinstance(session.get("steward_context"), dict) else authorized_manager_goal_ids( @@ -1687,7 +1688,7 @@ def _wake_goal_context(session): ).start() url = f"http://{host}:{port}{DEFAULT_CHAT_PATH}" print(f"Serving LoopX Chat at {url}", flush=True) - print("Agent boundary: local adapters, read-only sandbox, approval policy never", flush=True) + print(f"Agent boundary: local adapters, project grant {project_workspace_grant}, approval policy never", flush=True) print("Todo writes: preview-locked on loopback", flush=True) if enable_goal_subagent_configuration: print("Goal sub-agent configuration: preview-locked opt-in enabled", flush=True) diff --git a/loopx/cli_commands/support_control.py b/loopx/cli_commands/support_control.py index 8559c5758a..43852e338d 100644 --- a/loopx/cli_commands/support_control.py +++ b/loopx/cli_commands/support_control.py @@ -622,6 +622,7 @@ def handle_support_control_command( if bool(getattr(args, "replace_existing_loopx_chat", False)): replace_existing_loopx_chat(args.host, args.port) serve_chat( + project_workspace_grant=args.project_workspace_grant, registry_path=chat_registry_path, runtime_root_override=args.runtime_root, scan_roots=scan_roots, diff --git a/loopx/cli_commands/support_control_chat.py b/loopx/cli_commands/support_control_chat.py index fbbed1408a..8af58a7869 100644 --- a/loopx/cli_commands/support_control_chat.py +++ b/loopx/cli_commands/support_control_chat.py @@ -67,6 +67,12 @@ def register_chat_and_dashboard_commands( help="Specific public file or directory to scan. Repeatable.", ) chat_parser.add_argument("--limit", type=int, default=20) + chat_parser.add_argument( + "--project-workspace-grant", + choices=("workspace_read", "workspace_write"), + default="workspace_write", + help="Ordinary project Chat workspace access. Defaults to workspace_write; workspace_read prevents App write grants.", + ) chat_parser.add_argument( "--global-registry", action="store_true", diff --git a/loopx/control_plane/collaboration/conversation_binding.ts b/loopx/control_plane/collaboration/conversation_binding.ts index 7a0603851b..6743e9d380 100644 --- a/loopx/control_plane/collaboration/conversation_binding.ts +++ b/loopx/control_plane/collaboration/conversation_binding.ts @@ -87,7 +87,8 @@ function validateAgentTargetObservation(target: JsonObject, value: unknown, work function binding(value: unknown): JsonObject { const row = requireJsonObject(value, "conversation binding"); if (row.schema_version !== BINDING_SCHEMA || !["project", "steward"].includes(String(row.context_kind)) - || row.grant !== (row.context_kind === "project" ? "workspace_read" : "portfolio_read") || row.enabled !== true) { + || (row.context_kind === "project" ? !["workspace_read", "workspace_write"].includes(String(row.grant)) : row.grant !== "portfolio_read") + || (row.grant === "workspace_write" && row.executor_endpoint_id !== "codex") || row.enabled !== true) { throw new EffectRuntimeRequestError("unsupported conversation binding"); } const targets = row.agent_targets === undefined ? [] : row.agent_targets; @@ -155,8 +156,9 @@ export function planConversationBinding(params: JsonObject): JsonObject { if (params.operation === "configure") { const candidate = binding(params.binding); observed(candidate, params.observation); - if (!Array.isArray(params.available_projects) - || !params.available_projects.map(normalizeProjectContext).some(row => row.project_ref === candidate.project_ref)) { + const project = Array.isArray(params.available_projects) + ? params.available_projects.map(normalizeProjectContext).find(row => row.project_ref === candidate.project_ref) : undefined; + if (!project || (candidate.grant === "workspace_write" && project.grant !== "workspace_write")) { throw new EffectRuntimeRequestError("workspace grant is unavailable"); } const previous = current.bindings.find(row => row.transport_ref === candidate.transport_ref); @@ -228,8 +230,11 @@ export function resolveBoundConversation(params: JsonObject): JsonObject { if (!Array.isArray(params.available_projects)) throw new EffectRuntimeRequestError("workspace grants unavailable"); const projects = params.available_projects.map(normalizeProjectContext).filter(project => project.project_ref === row.project_ref); if (projects.length !== 1) throw new EffectRuntimeRequestError("workspace grant is unavailable or ambiguous"); + if (row.grant === "workspace_write" && projects[0].grant !== "workspace_write") { + throw new EffectRuntimeRequestError("workspace write grant is no longer available"); + } const context = {...projects[0], audience: "bound_owner", binding_id: id, source_ref: source, - provider_ref: row.provider_ref, operator_ref: row.operator_ref, + provider_ref: row.provider_ref, operator_ref: row.operator_ref, grant: row.grant, ...(row.context_kind === "steward" ? {kind: "bound_steward", grant: "portfolio_read", goal_ids: row.goal_ids} : {})}; if (params.session_context !== undefined) { const saved = requireJsonObject(params.session_context, "bound Session context"); diff --git a/loopx/control_plane/collaboration/conversation_scope.ts b/loopx/control_plane/collaboration/conversation_scope.ts index 00591ff04b..eb59413be9 100644 --- a/loopx/control_plane/collaboration/conversation_scope.ts +++ b/loopx/control_plane/collaboration/conversation_scope.ts @@ -6,12 +6,13 @@ export function normalizeProjectContext(value: unknown): Record const ref = context.project_ref, workspace = context.workspace_path; if (typeof ref !== "string" || !/^[a-f0-9]{24}$/.test(ref) || typeof workspace !== "string" || !workspace || context.kind !== "project_workspace" - || !["local_owner", "bound_owner"].includes(String(context.audience)) || context.grant !== "workspace_read" + || !["local_owner", "bound_owner"].includes(String(context.audience)) + || !["workspace_read", "workspace_write"].includes(String(context.grant)) || (workspace[0] !== "/" && !/^[A-Za-z]:[\\/]/.test(workspace))) { throw new Error("invalid project conversation context"); } const normalized: Record = {kind: "project_workspace", project_ref: ref, workspace_path: workspace, - audience: String(context.audience), grant: "workspace_read"}; + audience: String(context.audience), grant: String(context.grant)}; if (context.audience === "bound_owner") { for (const field of ["binding_id", "source_ref", "provider_ref", "operator_ref"]) { const value = context[field]; @@ -24,7 +25,8 @@ export function normalizeProjectContext(value: unknown): Record export function projectConversationIdentity(input: Record): Record { const context = normalizeProjectContext(input.context); - return {context, channel_id: context.audience === "local_owner" + return {context, sandbox: context.grant === "workspace_write" ? "workspace-write" : "read-only", + channel_id: context.audience === "local_owner" ? `project.${context.project_ref}` : `project.external.${context.binding_id}.${context.source_ref}`}; } diff --git a/loopx/control_plane/collaboration/project_conversation.ts b/loopx/control_plane/collaboration/project_conversation.ts index 8e20bd7fbb..3ae6c40bf8 100644 --- a/loopx/control_plane/collaboration/project_conversation.ts +++ b/loopx/control_plane/collaboration/project_conversation.ts @@ -4,7 +4,7 @@ import { requireNonEmptyString } from "../runtime_decode.ts"; import {normalizeProjectContext} from "./conversation_scope.ts"; /** A workspace observation is supplied by the host, never by a model or transport. - * Its read grant does not enroll a Goal, discover a portfolio or authorize work. + * Its workspace grant does not enroll a Goal, discover a portfolio or authorize peer work. */ function normalized(value: unknown): JsonObject { try {return normalizeProjectContext(value);} diff --git a/loopx/extensions/lark/private_conversation_api.py b/loopx/extensions/lark/private_conversation_api.py index 767d621e16..641d653312 100644 --- a/loopx/extensions/lark/private_conversation_api.py +++ b/loopx/extensions/lark/private_conversation_api.py @@ -33,7 +33,7 @@ def _private_conversation_connect(self) -> None: from ...chat_lark_api import build_lark_goal_topic_runtime_snapshot try: body = self._read_json() - if set(body) - {"context_kind"} != {"app_ref", "project_ref", "executor_endpoint_id"}: + if set(body) - {"context_kind", "project_grant"} != {"app_ref", "project_ref", "executor_endpoint_id"}: raise ValueError("select an App, authorized workspace and executor") profile = str(body["app_ref"]) existing = _active_profile_configs(build_lark_goal_topic_runtime_snapshot( @@ -51,7 +51,8 @@ def _private_conversation_connect(self) -> None: raise ValueError("the selected executor is unavailable") self.server.runtime_controller.project_contexts.conversation_bindings.configure( transport_ref=profile, project_ref=str(body["project_ref"]), executor_endpoint_id=endpoint, - context_kind=str(body.get("context_kind", "project"))) + context_kind=str(body.get("context_kind", "project")), + project_grant=str(body["project_grant"]) if "project_grant" in body else None) self.server.lark_goal_topic_runtime.refresh() except (ValueError, OSError, KeyError) as exc: self._send_error(str(exc), status=400) diff --git a/loopx/extensions/lark/private_conversations.py b/loopx/extensions/lark/private_conversations.py index 80363be9b6..f37cbb0a62 100644 --- a/loopx/extensions/lark/private_conversations.py +++ b/loopx/extensions/lark/private_conversations.py @@ -316,6 +316,8 @@ def _status_text(snapshot: dict[str, Any], *, help_requested: bool) -> str: text += ("\n\n/agents 授权 Agent · /project 返回项目 · /help 用法" if attached else "\n\n/stop 停止当前聊天 · /new 新会话 · /help 用法") if help_requested: + if not steward and not attached and snapshot.get("grant") == "workspace_write": + text += "\n按项目规则和 skills 执行当前指令;读写授权不会创建 Goal 或提高原宿主权限。" text += ("\n\n/status 查看当前状态;/help 查看用法。" "\n/agents 查看本 App 已授权的 Agent;使用列表中的完整 /agent 命令选择,/project 返回项目对话。") if not attached: diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index f9888ad4d8..0f18d8bb9e 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -271,7 +271,7 @@ }, { "site": "loopx/capabilities/native_chat/conversation_bindings.py::.ChatConversationBindings.agent_observation::codec_read:load_registry#1", - "line": 141, + "line": 146, "column": 20, "kind": "codec_read", "api": "load_registry", @@ -471,7 +471,7 @@ }, { "site": "loopx/chat_server.py::.serve_chat::codec_read:load_registry#1", - "line": 1571, + "line": 1572, "column": 16, "kind": "codec_read", "api": "load_registry", @@ -479,7 +479,7 @@ }, { "site": "loopx/chat_server.py::.serve_chat._wake_goal_context::codec_read:load_registry#1", - "line": 1676, + "line": 1677, "column": 20, "kind": "codec_read", "api": "load_registry", @@ -791,7 +791,7 @@ }, { "site": "loopx/cli_commands/quota.py::._dispatch_quota_turn_start_hooks::codec_read:load_registry#1", - "line": 310, + "line": 311, "column": 37, "kind": "codec_read", "api": "load_registry", diff --git a/tests/control_plane_ts/conversation_binding.test.ts b/tests/control_plane_ts/conversation_binding.test.ts index cce439e9e7..dc5fc99862 100644 --- a/tests/control_plane_ts/conversation_binding.test.ts +++ b/tests/control_plane_ts/conversation_binding.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import {planConversationBinding, resolveBoundConversation, planBoundConversationRequest, stewardCommand, authorizeStewardCreation, resolveConversationAgentTarget} from "../../loopx/control_plane/collaboration/conversation_binding.ts"; -import {resolveConversationScope} from "../../loopx/control_plane/collaboration/conversation_scope.ts"; +import {resolveConversationScope, projectConversationIdentity} from "../../loopx/control_plane/collaboration/conversation_scope.ts"; const project = {kind: "project_workspace", project_ref: "a".repeat(24), workspace_path: "/authorized/notes", audience: "local_owner", grant: "workspace_read"}; @@ -16,6 +16,36 @@ const current = {schema_version: "loopx_chat_conversation_bindings_v0", revision const request = {current, expected_revision: 0, operation: "configure", binding: row, observation, available_projects: [project]}; +test("explicit project writes remain App-bound and cannot exceed the host grant, another executor or an existing Session", () => { + const original = planConversationBinding(request).state as typeof current; + const use = {current: original, binding_id: row.binding_id, source_ref: "e".repeat(24), + sender_ref: row.operator_ref, private_human_message: true, observation, available_projects: [project]}; + const read = resolveBoundConversation(use); + assert.equal(projectConversationIdentity({context: read.context}).sandbox, "read-only"); + assert.equal(projectConversationIdentity({context: {...project, grant: "workspace_write"}}).sandbox, "workspace-write"); + const writable = [{...project, grant: "workspace_write"}]; + assert.throws(() => planConversationBinding({...request, current: original, expected_revision: 1, + binding: {...row, grant: "workspace_write"}, available_projects: writable}), /new binding identity/); + const write = {...row, binding_id: "f".repeat(24), grant: "workspace_write"}; + const next = planConversationBinding({...request, current: original, expected_revision: 1, binding: write, available_projects: writable}).state; + assert.throws(() => planConversationBinding({...request, binding: write}), /workspace/); + assert.throws(() => resolveBoundConversation({...use, current: next, binding_id: write.binding_id}), /write grant/); + use.available_projects = writable; + const selected = resolveBoundConversation({...use, current: next, binding_id: write.binding_id}); + assert.equal(projectConversationIdentity({context: selected.context}).sandbox, "workspace-write"); + assert.deepEqual(resolveConversationScope({goal_id: null, channel_id: selected.channel_id, + project_context: selected.context, origin: "lark"}), {kind: "project_workspace", goal_ids: [], private_conversation: false}); + assert.throws(() => resolveBoundConversation({...use, current: next}), /no longer authorized/); + assert.throws(() => resolveBoundConversation({...use, current: next, binding_id: write.binding_id, + session_context: read.context}), /context changed/); + for (const bad of [{...write, executor_endpoint_id: "claude-code"}, + {...write, context_kind: "steward", goal_ids: []}, {...write, grant: "danger-full-access"}]) { + assert.throws(() => planConversationBinding({...request, binding: bad}), /unsupported/); + } + assert.throws(() => resolveBoundConversation({...use, current: next, binding_id: write.binding_id, + sender_ref: "c".repeat(24)}), /audience/); +}); + test("binding independently verifies the owner and does not create a Goal", () => { const result = planConversationBinding(request); assert.equal(result.changed, true); diff --git a/tests/test_chat_conversation_bindings.py b/tests/test_chat_conversation_bindings.py index 9dfcda123d..fc9c3d2106 100644 --- a/tests/test_chat_conversation_bindings.py +++ b/tests/test_chat_conversation_bindings.py @@ -1,5 +1,6 @@ """Bound external project Chat uses Core Session, queue, and exact stop.""" import time +import json import pytest @@ -7,6 +8,69 @@ from loopx.capabilities.native_chat.conversation_bindings import ChatConversationBindings +def test_explicit_project_write_reaches_native_host_and_resume_without_goal_and_revokes_old_session(ordinary): # noqa: F811 + from loopx.chat_runtime import ChatRuntimeController + from loopx.chat_store import ChatSessionStore + + store, runtime, contexts, _, capture, fake, workspace = ordinary + observations = {profile: {"transport_ref": profile, "provider_ref": provider * 24, + "operator_ref": operator * 24, "verified": True} + for profile, provider, operator in [("notes-app", "c", "d"), ("other-app", "e", "f")]} + bindings = ChatConversationBindings(root=store.root, project_contexts=contexts, observe=lambda p: observations[p]) + contexts.conversation_bindings = bindings + ref = contexts.available()[0]["project_ref"] + def configure(profile, grant="workspace_read"): + return bindings.configure(transport_ref=profile, project_ref=ref, executor_endpoint_id="codex", project_grant=grant) + def open_bound(controller, row): + return controller.open_session(goal_id=None, agent_id="codex", work_dir=workspace, objective="untrusted", + mode="resume_latest", conversation_binding_id=row["binding_id"], source_context={ + "source_ref": "a" * 24, "sender_ref": row["operator_ref"], "private_human_message": True})[0] + read = configure("notes-app") + old = open_bound(runtime, read) + other = open_bound(runtime, configure("other-app")) + contexts.workspace_grant = "workspace_write" + write = configure("notes-app", "workspace_write") + assert write["binding_id"] != read["binding_id"] + assert configure("notes-app", "workspace_write")["binding_id"] == write["binding_id"] + with pytest.raises(ValueError, match="no longer authorized"): + runtime.enqueue_turn(session_id=old["session_id"], client_turn_id="stale", message="edit", + work_dir=workspace, objective="ignored", origin="lark") + one = open_bound(runtime, write) + assert one["session_id"] != old["session_id"] and one["goal_id"] is None + assert one["project_context"]["grant"] == "workspace_write" + adapter = runtime.adapters[one["session_id"]] + assert not adapter.session.execution_mode and adapter.session.runtime_profile == "restricted" + assert adapter.session.sandbox == "workspace-write" + turn, _ = runtime.enqueue_turn(session_id=one["session_id"], client_turn_id="edit", message="整理笔记:更新明确指定的文件", + work_dir=workspace, objective="ignored", origin="lark") + assert runtime.wait_for_turn(session_id=one["session_id"], turn_id=turn["turn_id"], timeout_sec=10)["status"] == "completed" + original = one["upstream_thread_id"] + runtime.close() + restarted = ChatRuntimeController(store=ChatSessionStore(store.root.parent), codex_bin=str(fake), + project_contexts=contexts, registry_path=workspace / "no-registry.json") + try: + resumed = open_bound(restarted, write) + assert resumed["session_id"] == one["session_id"] and resumed["upstream_thread_id"] == original + requests = [json.loads(line) for line in capture.read_text().splitlines()] + starts = [r["params"] for r in requests if r.get("method") == "thread/start"] + assert [r["sandbox"] for r in starts] == ["read-only", "read-only", "workspace-write"] + resumes = [r["params"] for r in requests if r.get("method") == "thread/resume"] + assert resumes[-1]["sandbox"] == "workspace-write" and resumes[-1]["threadId"] == original + prompts = [r["params"]["input"][0]["text"] for r in requests if r.get("method") == "turn/start"] + assert "project assistant" in prompts[-1] and "Do not edit files" not in prompts[-1] + assert "AGENTS.md" in prompts[-1] and "hidden Goal" in prompts[-1] + assert other["project_context"]["grant"] == "workspace_read" + downgraded = configure("notes-app") + assert downgraded["binding_id"] != write["binding_id"] + with pytest.raises(ValueError, match="no longer authorized"): + open_bound(restarted, write) + assert open_bound(restarted, downgraded)["session_id"] != one["session_id"] + assert store.turn_for_client(old["session_id"], "stale") is None + assert not (workspace / "ACTIVE_GOAL_STATE.md").exists() + finally: + restarted.close() + + def test_two_bound_audiences_continue_independently_and_use_core_queue_and_stop(ordinary): # noqa: F811 store, runtime, contexts, _, _, _, workspace = ordinary observations = { diff --git a/tests/test_chat_ordinary_project.py b/tests/test_chat_ordinary_project.py index 7a2eec5e03..6f1bf5a247 100644 --- a/tests/test_chat_ordinary_project.py +++ b/tests/test_chat_ordinary_project.py @@ -26,7 +26,7 @@ def ordinary(tmp_path, monkeypatch): fake = tmp_path / "codex" fake.write_text(source) fake.chmod(0o700) - contexts = ChatProjectContexts([workspace]) + contexts = ChatProjectContexts([workspace], workspace_grant="workspace_read") store = ChatSessionStore(tmp_path / "runtime") runtime = ChatRuntimeController(store=store, codex_bin=str(fake), project_contexts=contexts, registry_path=tmp_path / "no-registry.json") @@ -120,3 +120,68 @@ def test_retargeted_symlink_does_not_rebind_a_project_grant(tmp_path): link.symlink_to(second, target_is_directory=True) with pytest.raises(ValueError, match="outside"): contexts.resolve(ref) + + +def test_default_project_host_grant_is_write_and_read_only_launch_is_enforced(ordinary): + from loopx.capabilities.native_chat.conversation_bindings import ChatConversationBindings + + store, runtime, contexts, request, capture, _, workspace = ordinary + # The fixture intentionally launched read-only. The ordinary product default + # is write-capable, and both observed settings and actual host must agree. + assert ChatProjectContexts([workspace]).available()[0]["grant"] == "workspace_write" + contexts.workspace_grant = "workspace_write" + status, projects = request("/api/chat/projects") + assert status == 200 and projects["projects"][0]["grant"] == "workspace_write" + _, opened = request("/api/chat/sessions", {"context_kind": "project", "project_ref": projects["projects"][0]["project_ref"]}) + assert opened["goal_id"] is None + assert runtime.adapters[opened["session_id"]].session.sandbox == "workspace-write" + row = json.loads(capture.read_text().splitlines()[-1]) + assert row["method"] == "thread/start" and row["params"]["sandbox"] == "workspace-write" + proof = {"transport_ref": "notes-app", "provider_ref": "c" * 24, "operator_ref": "d" * 24, "verified": True} + bindings = ChatConversationBindings(root=store.root, project_contexts=contexts, observe=lambda _: proof) + contexts.conversation_bindings = bindings + binding = bindings.configure(transport_ref="notes-app", project_ref=projects["projects"][0]["project_ref"], executor_endpoint_id="codex") + assert binding["grant"] == "workspace_write" + contexts.workspace_grant = "workspace_read" + assert request(f"/api/chat/sessions/{opened['session_id']}/turns", {"message": "edit", "client_turn_id": "host-downgraded"})[0] == 400 + with pytest.raises(ValueError, match="write grant"): + bindings.resolve(binding_id=binding["binding_id"], source_ref="a" * 24, sender_ref=proof["operator_ref"], private_human_message=True) + with pytest.raises(ValueError, match="workspace grant"): + bindings.configure(transport_ref="notes-app", project_ref=projects["projects"][0]["project_ref"], executor_endpoint_id="codex", project_grant="workspace_write") + assert bindings.read()["bindings"][0] == binding + runtime.close() + + +def test_local_scope_opens_new_session_after_host_grant_changes(ordinary): + store, runtime, contexts, request, capture, _, _ = ordinary + ref = contexts.available()[0]["project_ref"] + body = {"context_kind": "project", "project_ref": ref} + status, original = request("/api/chat/sessions", body) + assert status == 201 + sessions = [original["session_id"]] + try: + for grant, sandbox in [("workspace_write", "workspace-write"), ("workspace_read", "read-only")]: + contexts.workspace_grant = grant + status, opened = request("/api/chat/sessions", body) + assert status == 201, opened + sid = opened["session_id"] + assert sid not in sessions and opened["goal_id"] is None + assert runtime.adapters[sid].session.sandbox == sandbox + assert store.load_session(sessions[-1])["project_context"]["grant"] != grant + status, denied = request(f"/api/chat/sessions/{sessions[-1]}/turns", + {"message": "old permission", "client_turn_id": "stale-grant"}) + assert status == 400 and "grant changed" in denied["error"] + assert store.turn_for_client(sessions[-1], "stale-grant") is None + assert request("/api/chat/sessions", body)[1]["session_id"] == sid + status, accepted = request(f"/api/chat/sessions/{sid}/turns", + {"message": "Continue under current permission", "client_turn_id": f"current-{grant}"}) + assert status == 202, accepted + assert runtime.wait_for_turn(session_id=sid, turn_id=accepted["turn_id"], timeout_sec=10)["status"] == "completed" + sessions.append(sid) + requests = [json.loads(line) for line in capture.read_text().splitlines()] + assert len([row for row in requests if row.get("method") == "thread/start"]) == 3 + assert not any(row.get("method") == "thread/resume" for row in requests) + assert len(store.list_sessions()) == 3 + assert store.messages(sessions[1]) and store.messages(sessions[2]) + finally: + runtime.close() diff --git a/tests/test_lark_private_agents.py b/tests/test_lark_private_agents.py index 3410275ad8..8afab03a6c 100644 --- a/tests/test_lark_private_agents.py +++ b/tests/test_lark_private_agents.py @@ -121,9 +121,13 @@ def test_committed_host_claim_replays_after_audience_revocation(ordinary): # no send(provider, transport, "select", f"/agent {grant['target_ref']}") send(provider, transport, "ask", "committed private question") row = next(row for row in transport.core.pending() if row["message"] == "committed private question") - committed = claim(store, runtime, sid, "stable-claim") - assert committed["claimed"] and committed["turn"]["turn_id"] == row["turn_id"] - assert committed["turn"]["claim_id"] == "stable-claim" + send(provider, transport, "queued", "not yet claimed") + queued = next(row for row in transport.core.pending() if row["message"] == "not yet claimed") + with pytest.raises(ConnectionError, match="response lost"): + committed = claim(store, runtime, sid, "stable-claim") + assert committed["claimed"] and committed["turn"]["turn_id"] == row["turn_id"] + assert committed["turn"]["claim_id"] == "stable-claim" + raise ConnectionError("synthetic response lost after the durable claim") bindings = transport.bindings bindings.change_agent_target(binding_id=bid, expected_revision=bindings.read()["revision"], target_ref=grant["target_ref"]) # A different claim id still cannot take over the committed Turn. @@ -143,6 +147,10 @@ def test_committed_host_claim_replays_after_audience_revocation(ordinary): # no transport.reconcile() # Revocation still withholds the private result from the original App. assert not any("Recovered answer" in text for _, text in provider.writes) + # The recovery exception grants no authority to start queued or new work. + assert not claim(store, runtime, sid, "fresh-claim")["claimed"] + assert store.load_turn(sid, queued["turn_id"])["status"] == "failed" + assert send(provider, transport, "new-after-revoke", "new private work")[0]["status"] == "command_rejected" finally: runtime.close() diff --git a/tests/test_lark_private_status.py b/tests/test_lark_private_status.py index 9945eed5d2..c9ed975711 100644 --- a/tests/test_lark_private_status.py +++ b/tests/test_lark_private_status.py @@ -178,3 +178,19 @@ def test_unavailable_execution_evidence_is_not_presented_as_ready(unknown): "active_turn_observation_available": unknown != "missing"} text = _status_text(snapshot, help_requested=False) assert "暂不可" in text and "可以继续对话" not in text + + +def test_explicit_write_status_matches_binding_without_opening_a_model_session(ordinary): # noqa: F811 + store, runtime, provider, transport = connect(ordinary) + try: + runtime.project_contexts.workspace_grant = "workspace_write" + transport.bindings.configure(transport_ref="notes-app", project_ref=runtime.project_contexts.available()[0]["project_ref"], + executor_endpoint_id="codex", project_grant="workspace_write") + transport.admit("notes-app", provider.event("notes-app", "write-status", "/help")) + assert transport.reconcile() == 1 + assert "当前工作区可读写" in provider.writes[-1][1] + assert "只读授权" not in provider.writes[-1][1] + assert "项目规则和 skills" in provider.writes[-1][1] + assert store.list_sessions() == [] + finally: + runtime.close()