From 9c19bb50dc17a117334b3c938edfb07f01bb0fd4 Mon Sep 17 00:00:00 2001 From: yyjeqhc <1772413353@qq.com> Date: Wed, 26 Aug 2026 07:52:25 +0800 Subject: [PATCH] Separate Session collaboration authority --- crates/webcodex-cli/src/lib.rs | 8 ++- .../src/webcodex_cli/connect/oauth.rs | 1 + .../webcodex_cli/connect/shared_key_oauth.rs | 4 ++ docs/AUTH_MODEL.md | 15 +++-- docs/AUTH_MODEL.zh-CN.md | 8 ++- src/auth/mod.rs | 1 + src/auth/project_share.rs | 6 +- src/auth/scopes.rs | 55 +++++++++++++++---- src/auth/shared_key.rs | 5 +- src/oauth_http/project_share.rs | 2 +- src/oauth_http/scope_registry.rs | 1 + src/oauth_http/shared_key_bridge.rs | 2 + src/oauth_http/tests/clients.rs | 1 + src/oauth_http/tests/project_share.rs | 4 +- src/oauth_http/tests/shared_key_bridge.rs | 1 + src/pairing_http.rs | 3 + src/runtime_console_http.rs | 53 +++++++++++++++--- src/tool_runtime/kernel.rs | 17 ++++-- src/tool_runtime/metadata.rs | 1 + src/tool_runtime/tool_definition/sessions.rs | 14 ++--- 20 files changed, 157 insertions(+), 45 deletions(-) diff --git a/crates/webcodex-cli/src/lib.rs b/crates/webcodex-cli/src/lib.rs index 352d1448..f7fe1842 100644 --- a/crates/webcodex-cli/src/lib.rs +++ b/crates/webcodex-cli/src/lib.rs @@ -56,7 +56,13 @@ use webcodex_cli::{ OpsSmokePreflightOptions, ServerStatusOptions, ServiceControl, StatusOptions, AGENT_SERVICE_UNIT, DEFAULT_LOG_LINES, SERVER_SERVICE_FILE, SERVER_SERVICE_UNIT, }; -const SETUP_GPT_SCOPES: &[&str] = &["runtime:read", "project:read", "project:write", "job:run"]; +const SETUP_GPT_SCOPES: &[&str] = &[ + "runtime:read", + "session:collaborate", + "project:read", + "project:write", + "job:run", +]; const SETUP_AGENT_SCOPES: &[&str] = &[ "agent:register", "agent:poll", diff --git a/crates/webcodex-cli/src/webcodex_cli/connect/oauth.rs b/crates/webcodex-cli/src/webcodex_cli/connect/oauth.rs index 950bafc5..d7b78160 100644 --- a/crates/webcodex-cli/src/webcodex_cli/connect/oauth.rs +++ b/crates/webcodex-cli/src/webcodex_cli/connect/oauth.rs @@ -32,6 +32,7 @@ const OAUTH_SECRET_DISCLOSED_FILE_PREFIX: &str = ".oauth-client-secret-disclosed // client until this list is explicitly reviewed and changed. const HOSTED_CONNECT_OAUTH_SCOPES: &[&str] = &[ "runtime:read", + "session:collaborate", "project:read", "project:write", "job:run", diff --git a/crates/webcodex-cli/src/webcodex_cli/connect/shared_key_oauth.rs b/crates/webcodex-cli/src/webcodex_cli/connect/shared_key_oauth.rs index 5b26aea1..bf74a372 100644 --- a/crates/webcodex-cli/src/webcodex_cli/connect/shared_key_oauth.rs +++ b/crates/webcodex-cli/src/webcodex_cli/connect/shared_key_oauth.rs @@ -15,6 +15,7 @@ const LOCAL_MCP_SCOPE: &str = "mcp:local"; const CODING_AGENT_SCOPE: &str = "coding_agent:run"; const BRIDGE_BASELINE_SCOPES: &[&str] = &[ "runtime:read", + "session:collaborate", "project:read", "project:write", "job:run", @@ -32,6 +33,7 @@ const BRIDGE_OPTIONAL_COMPUTER_SCOPES: &[&str] = &[ // retain a narrower non-empty baseline subset plus all optional Computer scopes. const BRIDGE_COMPUTER_ENABLED_SCOPES: &[&str] = &[ "runtime:read", + "session:collaborate", "project:read", "project:write", "job:run", @@ -623,6 +625,7 @@ mod tests { async fn ordinary_connect_oauth_provisions_then_reuses_same_shared_key_client() { let scopes = vec![ "runtime:read", + "session:collaborate", "project:read", "project:write", "job:run", @@ -648,6 +651,7 @@ mod tests { "redirect_uri": "https://chatgpt.example/callback", "allowed_scopes": [ "runtime:read", + "session:collaborate", "project:read", "project:write", "job:run", diff --git a/docs/AUTH_MODEL.md b/docs/AUTH_MODEL.md index bf43ffe1..f471d46e 100644 --- a/docs/AUTH_MODEL.md +++ b/docs/AUTH_MODEL.md @@ -85,8 +85,8 @@ A shared key is not an admin credential, not a managed user identity, and not production IAM. It has no independent per-device revocation: rotate the shared secret for the whole group, or use managed credentials. -Its default principal carries `runtime:read`, `project:read`, `project:write`, -`job:run`, `computer:read`, `computer:control`, and the bounded Agent-transport +Its default principal carries `runtime:read`, `session:collaborate`, `project:read`, +`project:write`, `job:run`, `computer:read`, `computer:control`, and the bounded Agent-transport scopes `agent:register`, `agent:poll`, `agent:result`, and `agent:job_update`. It does not carry account-management or admin scopes. @@ -133,7 +133,10 @@ Use `wc_pat_xxx` for: Supply it to CLI commands with `--token-file ` rather than `--token`, so the value stays out of shell history and process lists. Scope the PAT to the workflow. For example, a GPT Action that inspects and edits projects may need -`runtime:read`, `project:read`, `project:write`, and `job:run`. +`runtime:read`, `project:read`, `project:write`, and `job:run`; add +`session:collaborate` only when it must post, resolve, complete, replace, withdraw, +or close Workflow Session collaboration state. `runtime:read` alone remains +observation-only for that collaboration state. ## `wc_agent_xxx` (Runner token) @@ -181,15 +184,15 @@ An OAuth client's `allowed_scopes` is a registration-time delegation ceiling and is never automatically widened when WebCodex adds a new permission such as `computer:control` or `computer:launch`. The omitted-or-empty default is the explicit closed legacy permission set that existed before application launch; it -does not track the global supported-scope registry. `computer:launch` and future -permission scopes therefore require explicit opt-in. First-party operators may explicitly replace an active client's +does not track the global supported-scope registry. `session:collaborate`, +`computer:launch`, and future permission scopes therefore require explicit opt-in. First-party operators may explicitly replace an active client's complete allow-list with `POST /api/oauth/clients/update_scopes`. A real change atomically revokes that client's existing access tokens, refresh tokens, and outstanding authorization codes, so the client must complete OAuth authorization again before using the new scope set. `computer:display_read`, `computer:pointer_control`, `computer:clipboard_read`, and `computer:clipboard_write` follow that rule: all are outside the frozen legacy default and are available only through explicit client scope opt-in. -`webcodex connect --auth oauth` is the ordinary hosted shared-key OAuth bridge. The OAuth client is owned by the direct shared key's SHA-256 group hash, and authorization codes, access tokens, and refresh tokens carry that same `shared_key_hash` subject binding. Direct shared-key bearer authority remains the explicit baseline `runtime:read`, `project:read`, `project:write`, `job:run`, `computer:read`, and `computer:control`. A fresh ordinary OAuth client starts with that full baseline, while an existing protected client may legitimately carry any non-empty unique subset of that baseline. `--oauth-computer-permissions` changes only the OAuth client ceiling by appending the fixed closed set `computer:launch`, `computer:display_read`, `computer:pointer_control`, `computer:clipboard_read`, and `computer:clipboard_write` to the client's existing baseline subset; it never restores a baseline scope that was previously absent. A Computer-enabled ceiling is valid only when it contains the complete five-scope optional set, has at least one baseline scope, and contains no scope outside those two explicit closed universes. It never derives from the global OAuth registry and never includes `account:manage`, `admin`, `job:detach`, any `agent:*` transport scope, or future scopes. A ceiling is not a grant: all five optional Computer permissions are unchecked on the WebCodex authorize page, and the authorization code receives only baseline scopes present in the current OAuth request, optional scopes explicitly selected through the fixed permission bundles, and protocol-only `offline_access` when requested. Access tokens and refresh rotation preserve that exact grant set. Launch selection requires the request to contain both `computer:read` and `computer:launch`; the launch permission bundle itself still adds only `computer:launch`. Other optional permissions likewise require their complete runtime request prerequisites, and WebCodex never fills in a missing requested scope. Reconnect without the flag never widens an existing baseline client; revoked/missing-client replacement preserves the protected baseline subset recorded in `previous_allowed_scopes`. If explicit opt-in actually changes an existing shared-key-owned client's ceiling, WebCodex atomically revokes its access tokens, refresh tokens, and outstanding codes so browser authorization must run again. The Runner continues using the direct shared key, OAuth access tokens remain forbidden on Agent transport, and `--auth managed-oauth` remains the separate managed-user flow. +`webcodex connect --auth oauth` is the ordinary hosted shared-key OAuth bridge. The OAuth client is owned by the direct shared key's SHA-256 group hash, and authorization codes, access tokens, and refresh tokens carry that same `shared_key_hash` subject binding. Direct shared-key bearer authority remains the explicit baseline `runtime:read`, `session:collaborate`, `project:read`, `project:write`, `job:run`, `computer:read`, and `computer:control`. A fresh ordinary OAuth client starts with that full baseline, while an existing protected client may legitimately carry any non-empty unique subset of that baseline. `--oauth-computer-permissions` changes only the OAuth client ceiling by appending the fixed closed set `computer:launch`, `computer:display_read`, `computer:pointer_control`, `computer:clipboard_read`, and `computer:clipboard_write` to the client's existing baseline subset; it never restores a baseline scope that was previously absent. A Computer-enabled ceiling is valid only when it contains the complete five-scope optional set, has at least one baseline scope, and contains no scope outside those two explicit closed universes. It never derives from the global OAuth registry and never includes `account:manage`, `admin`, `job:detach`, any `agent:*` transport scope, or future scopes. A ceiling is not a grant: all five optional Computer permissions are unchecked on the WebCodex authorize page, and the authorization code receives only baseline scopes present in the current OAuth request, optional scopes explicitly selected through the fixed permission bundles, and protocol-only `offline_access` when requested. Access tokens and refresh rotation preserve that exact grant set. Launch selection requires the request to contain both `computer:read` and `computer:launch`; the launch permission bundle itself still adds only `computer:launch`. Other optional permissions likewise require their complete runtime request prerequisites, and WebCodex never fills in a missing requested scope. Reconnect without the flag never widens an existing baseline client; revoked/missing-client replacement preserves the protected baseline subset recorded in `previous_allowed_scopes`. If explicit opt-in actually changes an existing shared-key-owned client's ceiling, WebCodex atomically revokes its access tokens, refresh tokens, and outstanding codes so browser authorization must run again. The Runner continues using the direct shared key, OAuth access tokens remain forbidden on Agent transport, and `--auth managed-oauth` remains the separate managed-user flow. The shared-key authorize page evaluates optional Computer availability only for an exact shared-key-owned client whose `owner_shared_key_hash` matches the submitted key and whose matching Runner group is online. Each permission is considered available only when one same online Runner advertises the full capability requirement; capabilities are never unioned across Runners. POST recomputes that availability, so a capability loss between GET and consent fails closed without creating a code. These checks are backend capability checks only: authorization performs no hidden display observation, pointer/clipboard effect, launch, or OS-permission probe. Native/OS preflight remains the runtime tool call's responsibility. For OAuth-authenticated MCP callers, `tools/list` is projected through the same runtime scope policy as `tools/call`, so high-privilege Computer tools are absent when the actual token grant lacks their required scopes; the runtime gate remains authoritative even for a forged direct call. diff --git a/docs/AUTH_MODEL.zh-CN.md b/docs/AUTH_MODEL.zh-CN.md index f5b9b671..dba93f24 100644 --- a/docs/AUTH_MODEL.zh-CN.md +++ b/docs/AUTH_MODEL.zh-CN.md @@ -107,7 +107,9 @@ agent 连接 token。 用 `--token-file ` 提供给 CLI 命令,而不是 `--token`,以免进入 shell 历史。 按工作流限制 PAT scope。例如,一个要检查和编辑项目的 GPT Action 可能需要 -`runtime:read`、`project:read`、`project:write`、`job:run`。 +`runtime:read`、`project:read`、`project:write`、`job:run`;只有需要 post、resolve、 +complete、replace、withdraw 或 close Workflow Session 协作状态时才额外授予 +`session:collaborate`。仅有 `runtime:read` 对这类协作状态保持只读观察能力。 ## `wc_agent_xxx`(Runner 令牌) @@ -146,13 +148,13 @@ Server 支持 authorization-code grant、token 撤销与 OAuth metadata。动态 [部署指南](DEPLOYMENT.zh-CN.md#oauth2)。 OAuth client 的 `allowed_scopes` 是 client 注册时确定的委派权限上限;WebCodex 后续 -增加 `computer:control` 之类的新权限时,不会自动给历史 client 扩权。first-party +增加 `session:collaborate`、`computer:control` 之类的新权限时,不会自动给历史 client 扩权。first-party operator 可以通过 `POST /api/oauth/clients/update_scopes` 显式替换 active client 的 完整 allowlist。allowlist 真正变化时,Server 会在同一事务里撤销该 client 现有的 access token、refresh token 与尚存 authorization code,因此 client 必须重新完成 OAuth 授权,才能使用新的 scope 集合。 -`webcodex connect --auth oauth` 是普通 hosted shared-key OAuth bridge。OAuth client 归属于 direct shared key 的 SHA-256 group hash,authorization code、access token、refresh token 都保留同一个 `shared_key_hash` subject binding。direct shared-key bearer authority 始终保持显式 baseline:`runtime:read`、`project:read`、`project:write`、`job:run`、`computer:read`、`computer:control`。fresh ordinary OAuth client 从完整 baseline 开始;已有受保护 client 则可以合法持有该 baseline 的任意 non-empty、unique subset。只有 connect 显式传 `--oauth-computer-permissions`,OAuth client ceiling 才在**现有 baseline subset**上追加固定 closed set:`computer:launch`、`computer:display_read`、`computer:pointer_control`、`computer:clipboard_read`、`computer:clipboard_write`;绝不会恢复此前缺失的 baseline scope。Computer-enabled ceiling 只有在完整包含这五个 optional scopes、至少保留一个 baseline scope,且不存在两个显式 closed universe 之外的 scope 时才合法。该集合绝不从全局 OAuth registry 自动派生,也永远不包含 `account:manage`、`admin`、`job:detach`、任何 `agent:*` transport scope 或未来新增 scope。client ceiling 不是实际 grant:WebCodex authorize 页面五项 optional Computer permission 默认全部未勾选,authorization code 只包含本次 request 中实际授予的 baseline scope、用户明确选择后由固定 bundle 映射的 optional scope,以及本次协议需要的 `offline_access`。access token 与 refresh rotation 逐字保留该实际 grant,不重新扩到 client ceiling。Launch selection 要求本次 request 同时包含 `computer:read` 与 `computer:launch`,但 launch permission bundle 自身仍只新增 `computer:launch`;其他 optional permission 同样要求其完整 runtime request prerequisite,Server 不会替 client 偷偷补 request 未包含的 scope。普通 reconnect 不会扩已有 baseline client;revoked/missing client replacement 会按 `previous_allowed_scopes` 保留受保护 baseline subset。只有显式 opt-in 真正改变 shared-key-owned client ceiling 时,才原子撤销其 access/refresh token 与未使用 authorization code,要求重新完成浏览器授权。Runner 继续使用 direct shared key,OAuth access token 永远不能用于 Agent transport;`--auth managed-oauth` 仍是独立 managed-user 流程。 +`webcodex connect --auth oauth` 是普通 hosted shared-key OAuth bridge。OAuth client 归属于 direct shared key 的 SHA-256 group hash,authorization code、access token、refresh token 都保留同一个 `shared_key_hash` subject binding。direct shared-key bearer authority 始终保持显式 baseline:`runtime:read`、`session:collaborate`、`project:read`、`project:write`、`job:run`、`computer:read`、`computer:control`。fresh ordinary OAuth client 从完整 baseline 开始;已有受保护 client 则可以合法持有该 baseline 的任意 non-empty、unique subset。只有 connect 显式传 `--oauth-computer-permissions`,OAuth client ceiling 才在**现有 baseline subset**上追加固定 closed set:`computer:launch`、`computer:display_read`、`computer:pointer_control`、`computer:clipboard_read`、`computer:clipboard_write`;绝不会恢复此前缺失的 baseline scope。Computer-enabled ceiling 只有在完整包含这五个 optional scopes、至少保留一个 baseline scope,且不存在两个显式 closed universe 之外的 scope 时才合法。该集合绝不从全局 OAuth registry 自动派生,也永远不包含 `account:manage`、`admin`、`job:detach`、任何 `agent:*` transport scope 或未来新增 scope。client ceiling 不是实际 grant:WebCodex authorize 页面五项 optional Computer permission 默认全部未勾选,authorization code 只包含本次 request 中实际授予的 baseline scope、用户明确选择后由固定 bundle 映射的 optional scope,以及本次协议需要的 `offline_access`。access token 与 refresh rotation 逐字保留该实际 grant,不重新扩到 client ceiling。Launch selection 要求本次 request 同时包含 `computer:read` 与 `computer:launch`,但 launch permission bundle 自身仍只新增 `computer:launch`;其他 optional permission 同样要求其完整 runtime request prerequisite,Server 不会替 client 偷偷补 request 未包含的 scope。普通 reconnect 不会扩已有 baseline client;revoked/missing client replacement 会按 `previous_allowed_scopes` 保留受保护 baseline subset。只有显式 opt-in 真正改变 shared-key-owned client ceiling 时,才原子撤销其 access/refresh token 与未使用 authorization code,要求重新完成浏览器授权。Runner 继续使用 direct shared key,OAuth access token 永远不能用于 Agent transport;`--auth managed-oauth` 仍是独立 managed-user 流程。 shared-key authorize 页面只对 exact shared-key-owned client 做 optional Computer picker,并要求 `owner_shared_key_hash` 与提交的 shared key 精确匹配、对应 Runner group 在线。每个 permission 只有在**同一个在线 Runner**同时满足完整 capability requirement 时才显示 available,绝不把多个 Runner 的 capability 做 union;POST 会重新计算,因此 GET 后 capability 消失会 fail closed 且不创建 code。这里的 capability 只代表 WebCodex backend 当前支持,不代表 OS/native permission 或操作一定成功;authorize 过程中不会执行隐藏 display observation、pointer/clipboard effect、launch 或 OS-permission probe,runtime tool call 仍负责最新 native/OS preflight。 diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 2ec30cf1..cbe3ab17 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -75,6 +75,7 @@ pub use scopes::{ SCOPE_COMPUTER_CLIPBOARD_WRITE, SCOPE_COMPUTER_CONTROL, SCOPE_COMPUTER_DISPLAY_READ, SCOPE_COMPUTER_LAUNCH, SCOPE_COMPUTER_POINTER_CONTROL, SCOPE_COMPUTER_READ, SCOPE_JOB_RUN, SCOPE_MCP_LOCAL, SCOPE_PROJECT_READ, SCOPE_PROJECT_WRITE, SCOPE_RUNTIME_READ, + SCOPE_SESSION_COLLABORATE, }; #[cfg(test)] pub use scopes::{SCOPE_ACCOUNT_MANAGE, SCOPE_JOB_DETACH}; diff --git a/src/auth/project_share.rs b/src/auth/project_share.rs index c975cf6f..ada7134c 100644 --- a/src/auth/project_share.rs +++ b/src/auth/project_share.rs @@ -5,13 +5,17 @@ //! old authorization codes/access tokens/refresh tokens unusable without //! changing ownership of existing project tasks. -use super::{SCOPE_JOB_RUN, SCOPE_PROJECT_READ, SCOPE_PROJECT_WRITE, SCOPE_RUNTIME_READ}; +use super::{ + SCOPE_JOB_RUN, SCOPE_PROJECT_READ, SCOPE_PROJECT_WRITE, SCOPE_RUNTIME_READ, + SCOPE_SESSION_COLLABORATE, +}; pub(crate) const PROJECT_SHARE_OAUTH_SUBJECT_KIND: &str = "project_share"; pub(crate) const PROJECT_SHARE_OAUTH_TOKEN_KIND: &str = "oauth2_project"; pub(crate) const PROJECT_SHARE_SESSION_PREFIX: &str = "wc_share_"; pub(crate) const PROJECT_SHARE_OAUTH_SCOPES: &[&str] = &[ SCOPE_RUNTIME_READ, + SCOPE_SESSION_COLLABORATE, SCOPE_PROJECT_READ, SCOPE_PROJECT_WRITE, SCOPE_JOB_RUN, diff --git a/src/auth/scopes.rs b/src/auth/scopes.rs index db6807d6..1b8d1d19 100644 --- a/src/auth/scopes.rs +++ b/src/auth/scopes.rs @@ -17,6 +17,7 @@ use crate::tool_runtime::metadata::lookup_tool_metadata; /// treated as having the `admin` scope (full access). Stored space-separated in /// the database; parsed into a list on read. pub const SCOPE_RUNTIME_READ: &str = "runtime:read"; +pub const SCOPE_SESSION_COLLABORATE: &str = "session:collaborate"; pub const SCOPE_PROJECT_READ: &str = "project:read"; pub const SCOPE_PROJECT_WRITE: &str = "project:write"; pub const SCOPE_JOB_RUN: &str = "job:run"; @@ -61,6 +62,7 @@ pub(crate) const KNOWN_SCOPES: &[&str] = &[ SCOPE_COMPUTER_CLIPBOARD_READ, SCOPE_COMPUTER_CLIPBOARD_WRITE, SCOPE_RUNTIME_READ, + SCOPE_SESSION_COLLABORATE, SCOPE_PROJECT_READ, SCOPE_PROJECT_WRITE, SCOPE_JOB_RUN, @@ -251,11 +253,13 @@ pub(crate) fn oauth_route_scope_policy_for_path_method( ("POST", "/api/runtime-console/overview") | ("POST", "/api/runtime-console/runner") | ("POST", "/api/runtime-console/workflow-session-messages") - | ("POST", "/api/runtime-console/workflow-session-observe") - | ("POST", "/api/runtime-console/workflow-session-post-message") + | ("POST", "/api/runtime-console/workflow-session-observe") => { + OAuthRouteScopePolicy::Require(SCOPE_RUNTIME_READ) + } + ("POST", "/api/runtime-console/workflow-session-post-message") | ("POST", "/api/runtime-console/workflow-session-withdraw-message") | ("POST", "/api/runtime-console/workflow-session-replace-message") => { - OAuthRouteScopePolicy::Require(SCOPE_RUNTIME_READ) + OAuthRouteScopePolicy::Require(SCOPE_SESSION_COLLABORATE) } ("POST", "/api/runtime-console/projects") | ("POST", "/api/runtime-console/workflow-sessions") @@ -579,17 +583,17 @@ mod tests { ( "POST", "/api/runtime-console/workflow-session-post-message", - SCOPE_RUNTIME_READ, + SCOPE_SESSION_COLLABORATE, ), ( "POST", "/api/runtime-console/workflow-session-withdraw-message", - SCOPE_RUNTIME_READ, + SCOPE_SESSION_COLLABORATE, ), ( "POST", "/api/runtime-console/workflow-session-replace-message", - SCOPE_RUNTIME_READ, + SCOPE_SESSION_COLLABORATE, ), ("POST", "/api/tools/list", SCOPE_RUNTIME_READ), ("POST", "/api/connector/task/start", SCOPE_RUNTIME_READ), @@ -645,9 +649,6 @@ mod tests { "/api/runtime-console/runner", "/api/runtime-console/workflow-session-messages", "/api/runtime-console/workflow-session-observe", - "/api/runtime-console/workflow-session-post-message", - "/api/runtime-console/workflow-session-withdraw-message", - "/api/runtime-console/workflow-session-replace-message", ] { for (label, auth) in [("pat", &pat), ("oauth", &oauth), ("shared", &shared)] { assert!( @@ -656,6 +657,26 @@ mod tests { ); } } + for path in [ + "/api/runtime-console/workflow-session-post-message", + "/api/runtime-console/workflow-session-withdraw-message", + "/api/runtime-console/workflow-session-replace-message", + ] { + for (label, auth) in [("pat", &pat), ("oauth", &oauth)] { + assert_eq!( + enforce_route_scope(auth, "POST", path), + Err(( + Some(SCOPE_SESSION_COLLABORATE), + "missing required scope: session:collaborate".to_string() + )), + "{label} runtime:read must not mutate Session collaboration on {path}" + ); + } + assert!( + enforce_route_scope(&shared, "POST", path).is_ok(), + "direct shared key should retain Session collaboration on {path}" + ); + } for (label, auth) in [("pat", &pat), ("oauth", &oauth)] { assert_eq!( enforce_route_scope(auth, "POST", "/api/projects/read_file"), @@ -894,11 +915,15 @@ mod tests { ), ( "update_session_context", - OAuthToolScopePolicy::Require(SCOPE_PROJECT_READ), + OAuthToolScopePolicy::Require(SCOPE_PROJECT_WRITE), + ), + ( + "close_session", + OAuthToolScopePolicy::Require(SCOPE_SESSION_COLLABORATE), ), ( "post_session_message", - OAuthToolScopePolicy::Require(SCOPE_RUNTIME_READ), + OAuthToolScopePolicy::Require(SCOPE_SESSION_COLLABORATE), ), ( "list_session_messages", @@ -910,7 +935,11 @@ mod tests { ), ( "resolve_session_message", - OAuthToolScopePolicy::Require(SCOPE_RUNTIME_READ), + OAuthToolScopePolicy::Require(SCOPE_SESSION_COLLABORATE), + ), + ( + "complete_session_message", + OAuthToolScopePolicy::Require(SCOPE_SESSION_COLLABORATE), ), ( "session_discussion_summary", @@ -1021,10 +1050,12 @@ mod tests { "start_session", "session_summary", "update_session_context", + "close_session", "post_session_message", "list_session_messages", "observe_session_messages", "resolve_session_message", + "complete_session_message", "session_discussion_summary", "bind_current_session", "current_session", diff --git a/src/auth/shared_key.rs b/src/auth/shared_key.rs index b5ce5624..3252631d 100644 --- a/src/auth/shared_key.rs +++ b/src/auth/shared_key.rs @@ -6,7 +6,7 @@ use super::context::{AuthContext, AuthKind}; use super::scopes::{ SCOPE_AGENT_JOB_UPDATE, SCOPE_AGENT_POLL, SCOPE_AGENT_REGISTER, SCOPE_AGENT_RESULT, SCOPE_COMPUTER_CONTROL, SCOPE_COMPUTER_READ, SCOPE_JOB_RUN, SCOPE_PROJECT_READ, - SCOPE_PROJECT_WRITE, SCOPE_RUNTIME_READ, + SCOPE_PROJECT_WRITE, SCOPE_RUNTIME_READ, SCOPE_SESSION_COLLABORATE, }; /// Read the explicit-anonymous (`--open`) flag from the environment. When true, @@ -49,6 +49,7 @@ pub(crate) fn shared_key_hash_of(token: &str) -> String { /// transport scopes or unrelated future OAuth permissions. pub(crate) const DIRECT_SHARED_KEY_MODEL_SCOPES: &[&str] = &[ SCOPE_RUNTIME_READ, + SCOPE_SESSION_COLLABORATE, SCOPE_PROJECT_READ, SCOPE_PROJECT_WRITE, SCOPE_JOB_RUN, @@ -79,6 +80,7 @@ fn shared_key_scopes() -> Vec { fn open_anonymous_scopes() -> Vec { vec![ SCOPE_RUNTIME_READ.to_string(), + SCOPE_SESSION_COLLABORATE.to_string(), SCOPE_PROJECT_READ.to_string(), SCOPE_PROJECT_WRITE.to_string(), SCOPE_JOB_RUN.to_string(), @@ -90,6 +92,7 @@ fn open_anonymous_scopes() -> Vec { fn project_connector_scopes() -> Vec { vec![ SCOPE_RUNTIME_READ.to_string(), + SCOPE_SESSION_COLLABORATE.to_string(), SCOPE_PROJECT_READ.to_string(), SCOPE_PROJECT_WRITE.to_string(), SCOPE_JOB_RUN.to_string(), diff --git a/src/oauth_http/project_share.rs b/src/oauth_http/project_share.rs index 9aeadcb8..37e81b7e 100644 --- a/src/oauth_http/project_share.rs +++ b/src/oauth_http/project_share.rs @@ -15,7 +15,7 @@ use super::{ }; pub(crate) const PROJECT_SHARE_OAUTH_INVALID_SCOPE_MESSAGE: &str = - "project share tokens are limited to runtime:read, project:read, project:write, job:run"; + "project share tokens are limited to runtime:read, session:collaborate, project:read, project:write, job:run"; pub(crate) fn normalize_project_share_oauth_scopes( requested: Option<&str>, diff --git a/src/oauth_http/scope_registry.rs b/src/oauth_http/scope_registry.rs index c312e697..7a7e45fc 100644 --- a/src/oauth_http/scope_registry.rs +++ b/src/oauth_http/scope_registry.rs @@ -8,6 +8,7 @@ use super::OAuthAuthorizeError; /// scope not intended for OAuth2 delegation. const OAUTH_SCOPES_SUPPORTED: &[&str] = &[ scopes::SCOPE_RUNTIME_READ, + scopes::SCOPE_SESSION_COLLABORATE, scopes::SCOPE_PROJECT_READ, scopes::SCOPE_PROJECT_WRITE, scopes::SCOPE_JOB_RUN, diff --git a/src/oauth_http/shared_key_bridge.rs b/src/oauth_http/shared_key_bridge.rs index 2f044e47..a36f1a8f 100644 --- a/src/oauth_http/shared_key_bridge.rs +++ b/src/oauth_http/shared_key_bridge.rs @@ -6,6 +6,7 @@ use crate::auth::{ SCOPE_COMPUTER_CLIPBOARD_WRITE, SCOPE_COMPUTER_CONTROL, SCOPE_COMPUTER_DISPLAY_READ, SCOPE_COMPUTER_LAUNCH, SCOPE_COMPUTER_POINTER_CONTROL, SCOPE_COMPUTER_READ, SCOPE_JOB_RUN, SCOPE_MCP_LOCAL, SCOPE_PROJECT_READ, SCOPE_PROJECT_WRITE, SCOPE_RUNTIME_READ, + SCOPE_SESSION_COLLABORATE, }; use crate::models::OAuthAuthorizationCodeRecord; use crate::shell_protocol::ShellClientCapabilities; @@ -34,6 +35,7 @@ pub(crate) const SHARED_KEY_OAUTH_OPTIONAL_COMPUTER_SCOPES: &[&str] = &[ /// same fixed optional Computer scopes. pub(crate) const SHARED_KEY_OAUTH_COMPUTER_ENABLED_SCOPES: &[&str] = &[ SCOPE_RUNTIME_READ, + SCOPE_SESSION_COLLABORATE, SCOPE_PROJECT_READ, SCOPE_PROJECT_WRITE, SCOPE_JOB_RUN, diff --git a/src/oauth_http/tests/clients.rs b/src/oauth_http/tests/clients.rs index d3012be2..a3338e82 100644 --- a/src/oauth_http/tests/clients.rs +++ b/src/oauth_http/tests/clients.rs @@ -410,6 +410,7 @@ async fn oauth_client_create_omitted_and_empty_scopes_use_exact_legacy_default() .copied() .filter(|scope| !LEGACY_DEFAULT_SCOPES.contains(scope)) .collect::>(); + assert!(non_legacy_supported.contains(&"session:collaborate")); assert!(non_legacy_supported.contains(&"computer:launch")); assert!(non_legacy_supported.contains(&"computer:display_read")); assert!(non_legacy_supported.contains(&"computer:pointer_control")); diff --git a/src/oauth_http/tests/project_share.rs b/src/oauth_http/tests/project_share.rs index 980b4e14..cdae8204 100644 --- a/src/oauth_http/tests/project_share.rs +++ b/src/oauth_http/tests/project_share.rs @@ -2,7 +2,7 @@ use super::*; #[test] fn project_share_scope_ceiling_is_connector_only() { - let allowed = "runtime:read project:read project:write job:run"; + let allowed = "runtime:read session:collaborate project:read project:write job:run"; let normalized = normalize_project_share_oauth_scopes(Some(allowed), allowed).unwrap(); assert_eq!(normalized, allowed); @@ -15,7 +15,7 @@ fn project_share_scope_ceiling_is_connector_only() { } let globally_supported_but_outside_connector = - "runtime:read project:read project:write job:run computer:read"; + "runtime:read session:collaborate project:read project:write job:run computer:read"; if normalize_oauth_scopes( Some(globally_supported_but_outside_connector), globally_supported_but_outside_connector, diff --git a/src/oauth_http/tests/shared_key_bridge.rs b/src/oauth_http/tests/shared_key_bridge.rs index 8bfc69f7..54a9b3c4 100644 --- a/src/oauth_http/tests/shared_key_bridge.rs +++ b/src/oauth_http/tests/shared_key_bridge.rs @@ -39,6 +39,7 @@ fn bridge_computer_scopes_use_explicit_closed_ceiling_without_changing_direct_au bridge_oauth_computer_enabled_scopes(), &[ "runtime:read", + "session:collaborate", "project:read", "project:write", "job:run", diff --git a/src/pairing_http.rs b/src/pairing_http.rs index 829cea54..3cb26d0a 100644 --- a/src/pairing_http.rs +++ b/src/pairing_http.rs @@ -11,6 +11,7 @@ use crate::auth::{ token_prefix, validate_allowed_client_id, validate_username, AuthContext, SCOPE_AGENT_JOB_UPDATE, SCOPE_AGENT_POLL, SCOPE_AGENT_REGISTER, SCOPE_AGENT_RESULT, SCOPE_JOB_RUN, SCOPE_PROJECT_READ, SCOPE_PROJECT_WRITE, SCOPE_RUNTIME_READ, + SCOPE_SESSION_COLLABORATE, }; use crate::db::PairingConsumeResult; use crate::json_error; @@ -27,6 +28,7 @@ const MAX_TTL_SECS: i64 = 3600; const ENROLL_USER_SCOPES: &[&str] = &[ SCOPE_RUNTIME_READ, + SCOPE_SESSION_COLLABORATE, SCOPE_PROJECT_READ, SCOPE_PROJECT_WRITE, SCOPE_JOB_RUN, @@ -751,6 +753,7 @@ mod tests { body["user_token_scopes"], json!([ SCOPE_RUNTIME_READ, + SCOPE_SESSION_COLLABORATE, SCOPE_PROJECT_READ, SCOPE_PROJECT_WRITE, SCOPE_JOB_RUN diff --git a/src/runtime_console_http.rs b/src/runtime_console_http.rs index 91ee761c..800b4c2d 100644 --- a/src/runtime_console_http.rs +++ b/src/runtime_console_http.rs @@ -4,7 +4,7 @@ //! runtime project authorization and the existing Workflow Session console //! projection without creating a second store, parser, or observation authority. -use crate::auth::{AuthContext, SCOPE_PROJECT_READ, SCOPE_RUNTIME_READ}; +use crate::auth::{AuthContext, SCOPE_PROJECT_READ, SCOPE_RUNTIME_READ, SCOPE_SESSION_COLLABORATE}; use crate::tool_runtime::sessions::{ aggregate_console_list, is_valid_session_id, SessionMessageKind, SessionMessagePriority, WorkflowSessionConsoleAggregate, WorkflowSessionConsoleAttentionOverview, @@ -442,6 +442,17 @@ fn require_runtime_read(auth: &AuthContext) -> Result<(), RuntimeConsoleError> { } } +fn require_session_collaborate(auth: &AuthContext) -> Result<(), RuntimeConsoleError> { + if auth.has_scope(SCOPE_SESSION_COLLABORATE) { + Ok(()) + } else { + Err(RuntimeConsoleError::Request { + status: 403, + message: "Session collaboration access required", + }) + } +} + fn require_project_read(auth: &AuthContext) -> Result<(), RuntimeConsoleError> { if project_read_available(auth) { Ok(()) @@ -1396,7 +1407,7 @@ async fn session_post_message_for_auth( auth: &AuthContext, input: WorkflowSessionPostMessageInput, ) -> Result { - require_runtime_read(auth)?; + require_session_collaborate(auth)?; if !matches!( input.kind, SessionMessageKind::Note @@ -1443,7 +1454,7 @@ async fn session_withdraw_message_for_auth( auth: &AuthContext, input: WorkflowSessionWithdrawMessageInput, ) -> Result { - require_runtime_read(auth)?; + require_session_collaborate(auth)?; if !valid_runtime_message_id(&input.message_id) { return Err(RuntimeConsoleError::Invalid); } @@ -1473,7 +1484,7 @@ async fn session_replace_message_for_auth( auth: &AuthContext, input: WorkflowSessionReplaceMessageInput, ) -> Result { - require_runtime_read(auth)?; + require_session_collaborate(auth)?; if !valid_runtime_message_id(&input.message_id) { return Err(RuntimeConsoleError::Invalid); } @@ -2467,6 +2478,34 @@ mod tests { ); } + #[tokio::test] + async fn collaboration_mutations_require_session_collaborate_before_session_lookup() { + let runtime = test_runtime(); + let runtime_read_only = scoped_oauth(&[SCOPE_RUNTIME_READ, SCOPE_PROJECT_READ]); + let error = session_post_message_for_auth( + &runtime, + &runtime_read_only, + WorkflowSessionPostMessageInput { + project: "agent:missing:project".to_string(), + session_id: "wc_sess_missing".to_string(), + kind: SessionMessageKind::Guidance, + priority: SessionMessagePriority::High, + message: "must not be injected by runtime:read".to_string(), + reply_to: None, + requires_ack: true, + }, + ) + .await + .unwrap_err(); + assert_eq!( + error, + RuntimeConsoleError::Request { + status: 403, + message: "Session collaboration access required", + } + ); + } + #[tokio::test] async fn collaboration_message_projection_reuses_authority_fence_and_hides_completion_identity() { @@ -2784,7 +2823,7 @@ mod tests { let runtime = test_runtime(); let auth_a = crate::auth::shared_key_context("runtime-console-mutate-a"); let auth_b = crate::auth::shared_key_context("runtime-console-mutate-b"); - let no_runtime_read = scoped_oauth(&[SCOPE_PROJECT_READ]); + let runtime_read_only = scoped_oauth(&[SCOPE_RUNTIME_READ, SCOPE_PROJECT_READ]); let project_id = "agent:client-a:proj-a"; register_project(&runtime, "client-a", "proj-a", "/private/a", Some(&auth_a)).await; let session = start_authorized_session(&runtime, project_id, &auth_a); @@ -2803,7 +2842,7 @@ mod tests { assert_eq!( session_withdraw_message_for_auth( &runtime, - &no_runtime_read, + &runtime_read_only, WorkflowSessionWithdrawMessageInput { project: project_id.to_string(), session_id: session.session_id.clone(), @@ -2814,7 +2853,7 @@ mod tests { .unwrap_err(), RuntimeConsoleError::Request { status: 403, - message: "Runtime read access required", + message: "Session collaboration access required", } ); assert_eq!( diff --git a/src/tool_runtime/kernel.rs b/src/tool_runtime/kernel.rs index fd48bd9c..9860565d 100644 --- a/src/tool_runtime/kernel.rs +++ b/src/tool_runtime/kernel.rs @@ -1133,8 +1133,8 @@ mod tests { assert_eq!( outcome.error_status, Some(ToolCallErrorStatus::InsufficientScope { - required_scope: Some(crate::auth::SCOPE_RUNTIME_READ), - description: "missing required scope: runtime:read".to_string(), + required_scope: Some(crate::auth::SCOPE_SESSION_COLLABORATE), + description: "missing required scope: session:collaborate".to_string(), }) ); assert!(outcome.result.is_none()); @@ -1168,8 +1168,8 @@ mod tests { assert_eq!( check_session_message_resolution_scope(Some(&project_read_only), true), Err(ToolCallErrorStatus::InsufficientScope { - required_scope: Some(crate::auth::SCOPE_RUNTIME_READ), - description: "missing required scope: runtime:read".to_string(), + required_scope: Some(crate::auth::SCOPE_SESSION_COLLABORATE), + description: "missing required scope: session:collaborate".to_string(), }), "piggyback resolution must not inherit the main tool scope" ); @@ -1181,6 +1181,15 @@ mod tests { let runtime_read = oauth(&["runtime:read"]); assert_eq!( check_session_message_resolution_scope(Some(&runtime_read), true), + Err(ToolCallErrorStatus::InsufficientScope { + required_scope: Some(crate::auth::SCOPE_SESSION_COLLABORATE), + description: "missing required scope: session:collaborate".to_string(), + }), + "runtime observation authority must not resolve Session messages" + ); + let collaborator = oauth(&["session:collaborate"]); + assert_eq!( + check_session_message_resolution_scope(Some(&collaborator), true), Ok(()), "piggyback resolution must track the dedicated resolve tool policy" ); diff --git a/src/tool_runtime/metadata.rs b/src/tool_runtime/metadata.rs index ec14f2be..f1377bac 100644 --- a/src/tool_runtime/metadata.rs +++ b/src/tool_runtime/metadata.rs @@ -59,6 +59,7 @@ pub(crate) struct ToolMetadata { } pub(crate) const RUNTIME_READ: &str = crate::auth::SCOPE_RUNTIME_READ; +pub(crate) const SESSION_COLLABORATE: &str = crate::auth::SCOPE_SESSION_COLLABORATE; pub(crate) const PROJECT_READ: &str = crate::auth::SCOPE_PROJECT_READ; pub(crate) const PROJECT_WRITE: &str = crate::auth::SCOPE_PROJECT_WRITE; pub(crate) const JOB_RUN: &str = crate::auth::SCOPE_JOB_RUN; diff --git a/src/tool_runtime/tool_definition/sessions.rs b/src/tool_runtime/tool_definition/sessions.rs index 0081e9e6..fb8260ac 100644 --- a/src/tool_runtime/tool_definition/sessions.rs +++ b/src/tool_runtime/tool_definition/sessions.rs @@ -6,8 +6,8 @@ use super::{ TOOL_CATEGORY_VALIDATION, }; use crate::tool_runtime::metadata::{ - ToolPathHint::None as NoPath, ToolRisk::ReadOnly, PROJECT_READ, RUNTIME_READ, - TOOL_PROVIDER_CONTROL, + ToolPathHint::None as NoPath, ToolRisk::ReadOnly, PROJECT_READ, PROJECT_WRITE, RUNTIME_READ, + SESSION_COLLABORATE, TOOL_PROVIDER_CONTROL, }; pub(super) const DEFINITIONS: &[ToolDefinition] = &[ @@ -86,7 +86,7 @@ pub(super) const DEFINITIONS: &[ToolDefinition] = &[ Some(OwnerOnly), TOOL_PROVIDER_CONTROL, ReadOnly, - Some(PROJECT_READ), + Some(PROJECT_WRITE), true, NoPath, false, @@ -99,7 +99,7 @@ pub(super) const DEFINITIONS: &[ToolDefinition] = &[ None, TOOL_PROVIDER_CONTROL, ReadOnly, - Some(RUNTIME_READ), + Some(SESSION_COLLABORATE), false, NoPath, false, @@ -125,7 +125,7 @@ pub(super) const DEFINITIONS: &[ToolDefinition] = &[ None, TOOL_PROVIDER_CONTROL, ReadOnly, - Some(RUNTIME_READ), + Some(SESSION_COLLABORATE), false, NoPath, false, @@ -164,7 +164,7 @@ pub(super) const DEFINITIONS: &[ToolDefinition] = &[ None, TOOL_PROVIDER_CONTROL, ReadOnly, - Some(RUNTIME_READ), + Some(SESSION_COLLABORATE), false, NoPath, false, @@ -177,7 +177,7 @@ pub(super) const DEFINITIONS: &[ToolDefinition] = &[ None, TOOL_PROVIDER_CONTROL, ReadOnly, - Some(RUNTIME_READ), + Some(SESSION_COLLABORATE), false, NoPath, false,