diff --git a/plugins/agent/conversations/ui/SessionSurface.tsx b/plugins/agent/conversations/ui/SessionSurface.tsx index c0e2feb5fb..2500ca4e5e 100644 --- a/plugins/agent/conversations/ui/SessionSurface.tsx +++ b/plugins/agent/conversations/ui/SessionSurface.tsx @@ -1136,7 +1136,7 @@ export function createConversationUi( expectedRevision: session.revision, title: session.title, labels: session.labels, - permissions: { settings, revokeGrants: true }, + permissions: { settings, revokeGrants: false }, }); if (changed.kind !== 'committed') throw new UiError('Sandbox settings rejected: {reason}', { @@ -1471,7 +1471,7 @@ export function createConversationUi( access: access ?? session.permissions.access, requestPermissions: requests ?? session.permissions.requestPermissions, }, - revokeGrants: true, + revokeGrants: false, }, }); if (changed.kind !== 'committed') diff --git a/plugins/agent/native/README.md b/plugins/agent/native/README.md index 15436815cc..3b72b27ee1 100644 --- a/plugins/agent/native/README.md +++ b/plugins/agent/native/README.md @@ -175,3 +175,5 @@ compaction produces a text summary. Host freezes a single protocol operation; th `NativeCompaction` only at stream completion. Engine and EventLog do not extract provider-options JSON for this result. Checkpoint headers retain the producing-step reference, digest and token estimate; baseline reads derive producer/model facts from that original request. + +Disabling new requests preserves existing valid grants. Changing base access or explicitly revoking grants advances the permission boundary. diff --git a/plugins/agent/native/README.zh-CN.md b/plugins/agent/native/README.zh-CN.md index d691bf09c9..f98f7d9180 100644 --- a/plugins/agent/native/README.zh-CN.md +++ b/plugins/agent/native/README.zh-CN.md @@ -154,3 +154,5 @@ ChatGPT 冷缓存或到期刷新在逻辑请求准备时等待,后台压缩的 订阅 Provider 仅在连接开启该选项时,在解析后的能力中选择原生压缩。关闭后仍可回放已有原生 checkpoint;下一次压缩生成文字摘要。Host 冻结一个协议操作,Responses 适配器负责追加触发命令, 并只在流完成时返回有类型的 `NativeCompaction`。Engine 和 EventLog 无需再从 provider options JSON 提取结果。 检查点头部只保留来源步骤引用、内容摘要和 token 估算,baseline 读取时从原始请求派生 producer/model。 + +关闭新增权限申请会保留已有有效授权;修改基础访问权限或明确撤销授权才会推进权限边界。 diff --git a/plugins/agent/native/src/owner.rs b/plugins/agent/native/src/owner.rs index e44be2b6c6..9a093ea842 100644 --- a/plugins/agent/native/src/owner.rs +++ b/plugins/agent/native/src/owner.rs @@ -1101,7 +1101,7 @@ impl Owner { serde_json::from_value(record.metadata.configuration.clone()) .map_err(|error| format!("agent_session_configuration_invalid: {error}"))?; let policy_changed = input.permissions.as_ref().is_some_and(|change| { - change.settings != configuration.permissions || change.revoke_grants + change.settings.access != configuration.permissions.access || change.revoke_grants }); let guard = if policy_changed { let busy = self.runs.state().await.busy(&input.session_id) diff --git a/plugins/agent/native/src/owner/driver/tests.rs b/plugins/agent/native/src/owner/driver/tests.rs index aaf38ee9a4..5b126d154f 100644 --- a/plugins/agent/native/src/owner/driver/tests.rs +++ b/plugins/agent/native/src/owner/driver/tests.rs @@ -414,7 +414,15 @@ impl Rig { let methods: &[&str] = if model_test { &["create", "models", "send", "work"] } else { - &["create", "history", "source", "search", "list", "execution"] + &[ + "create", + "history", + "source", + "search", + "list", + "execution", + "metadata", + ] }; let mut endpoints = BTreeMap::from([( "agent".into(), @@ -3356,3 +3364,5 @@ mod replay_origin_regressions { } mod tool_history; + +mod permission_boundary; diff --git a/plugins/agent/native/src/owner/driver/tests/permission_boundary.rs b/plugins/agent/native/src/owner/driver/tests/permission_boundary.rs new file mode 100644 index 0000000000..005ab5a2c3 --- /dev/null +++ b/plugins/agent/native/src/owner/driver/tests/permission_boundary.rs @@ -0,0 +1,121 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +use super::*; +use maka_agent_api::contract::WorkspaceAccess; + +async fn update_permissions( + rig: &Rig, + session: &str, + expected_revision: u64, + access: WorkspaceAccess, + request_permissions: bool, + revoke_grants: bool, +) -> TestResult<()> { + let mut call = rig + .client + .as_ref() + .unwrap() + .call( + "agent", + "metadata".into(), + json!({ + "sessionId":session, + "expectedRevision":expected_revision, + "title":"Permission metadata regression", + "labels":[], + "permissions":{ + "settings":{"access":access,"requestPermissions":request_permissions}, + "revokeGrants":revoke_grants, + }, + }), + Attachments::new(), + ) + .await?; + call.start().await?; + assert!(matches!(call.receive().await?, Reply::Prepared { .. })); + let Reply::Mutation { reply, cleanup } = + call.confirm(json!({"test":"permission-boundary"})).await? + else { + return Err("metadata did not return a mutation receipt".into()); + }; + cleanup?; + let Receipt::Recorded { + outcome: + OperationOutcome::Returned { + result: OperationResult::Success { value }, + }, + cleanup: OperationCleanup::Confirmed, + } = &reply.receipt + else { + return Err(format!("metadata did not commit: {:?}", reply.receipt).into()); + }; + assert_eq!(value["kind"], "committed", "metadata mutation: {value}"); + call.close().await?; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn permission_request_toggle_preserves_boundary_but_access_and_revoke_advance_it() { + let mut rig = Rig::new().unwrap(); + let outcome = AssertUnwindSafe(tokio::time::timeout(Duration::from_secs(60), async { + let session = rig.start().await?; + let owner = rig.captured.lock().unwrap().clone().unwrap(); + let mut previous = owner.mutable_session(&session).await?; + let initial = previous.configuration.permissions.clone(); + let changed_access = match initial.access { + WorkspaceAccess::ReadOnly => WorkspaceAccess::WorkspaceWrite, + WorkspaceAccess::WorkspaceWrite | WorkspaceAccess::DangerFullAccess => { + WorkspaceAccess::ReadOnly + } + }; + for (access, requests, revoke, preserves_basis) in [ + (initial.access, !initial.request_permissions, false, true), + (initial.access, initial.request_permissions, false, true), + (changed_access, false, false, false), + (changed_access, false, true, false), + ] { + update_permissions(&rig, &session, previous.revision, access, requests, revoke).await?; + let current = owner.mutable_session(&session).await?; + assert_eq!( + serde_json::to_value(¤t.configuration.permissions)?, + json!({"access":access,"requestPermissions":requests}), + ); + assert_eq!( + current.configuration.permission_basis() + == previous.configuration.permission_basis(), + preserves_basis, + "permission basis after access={access:?}, requests={requests}, revoke={revoke}", + ); + previous = current; + } + Ok::<_, Box>(()) + })) + .catch_unwind() + .await; + let cleanup = rig.finish().await; + assert!( + matches!(&outcome, Ok(Ok(Ok(())))), + "permission metadata regression: {outcome:?}" + ); + assert!( + cleanup.is_empty(), + "permission metadata fixture cleanup: {cleanup:?}" + ); +} diff --git a/products/maka/workbench/app/sessionPermissions.ts b/products/maka/workbench/app/sessionPermissions.ts index 4ebe276338..c7a3ecdf9b 100644 --- a/products/maka/workbench/app/sessionPermissions.ts +++ b/products/maka/workbench/app/sessionPermissions.ts @@ -65,7 +65,7 @@ export function createSessionPermissions(host: Host, sessionId: string, control: expectedRevision: session.revision, title: session.title, labels: [...session.labels], - permissions: { settings, revokeGrants: true }, + permissions: { settings, revokeGrants: false }, }; pending.set(true); try { diff --git a/products/maka/workbench/tests/session-permissions.test.mjs b/products/maka/workbench/tests/session-permissions.test.mjs new file mode 100644 index 0000000000..800ddbff3c --- /dev/null +++ b/products/maka/workbench/tests/session-permissions.test.mjs @@ -0,0 +1,83 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { registerHooks } from 'node:module'; +import test from 'node:test'; + +const appRoot = new URL('../app/', import.meta.url); +const native = { + nsh: `export const signal = initial => { + let value = initial; + const read = () => value; + read.set = next => { value = next; }; + return read; + }; + export const cleanup = () => {};`, + locale: `export const tr = key => key;`, +}; +const hooks = registerHooks({ + resolve(specifier, context, next) { + const key = + context.parentURL?.startsWith(appRoot.href) && specifier === './locale' + ? 'locale' + : specifier; + if (key in native) + return { url: `data:text/javascript,${encodeURIComponent(native[key])}`, shortCircuit: true }; + if ( + context.parentURL?.startsWith(appRoot.href) && + specifier.startsWith('.') && + !/\.\w+$/.test(specifier) + ) + return next(`${specifier}.ts`, context); + return next(specifier, context); + }, +}); +const { createSessionPermissions } = await import(new URL('sessionPermissions.ts', appRoot)); +hooks.deregister(); + +test('disabling permission requests does not request grant revocation', async () => { + const session = { + id: 'session', + revision: 4, + title: 'Permission settings', + labels: [], + permissions: { access: 'read_only', requestPermissions: true }, + }; + const sent = []; + const permissions = createSessionPermissions({}, session.id, async (method, input) => { + sent.push([method, input.permissions]); + return { kind: 'committed', session }; + }); + permissions.observe(session); + + await permissions.select({ + session, + settings: { ...session.permissions, requestPermissions: false }, + }); + assert.deepEqual(sent, [ + [ + 'metadata', + { + settings: { access: 'read_only', requestPermissions: false }, + revokeGrants: false, + }, + ], + ]); +});