From d21bd6da9abc7a2b5e28ffaa4cf01d2d9563bc62 Mon Sep 17 00:00:00 2001 From: techflag <562635045@qq.com> Date: Sat, 12 Sep 2026 00:40:34 +0800 Subject: [PATCH] feat: add governed session host ingress --- docs/MODULE-VERSIONS.md | 2 +- docs/PLUGIN-DELIVERY.md | 2 +- docs/STATUS.md | 4 +- docs/evidence/d01-access-audit.md | 4 +- packages/contracts/README.md | 2 +- packages/plugins/access/CHANGELOG.md | 7 ++ packages/plugins/access/README.md | 6 +- packages/plugins/access/package.json | 4 +- packages/plugins/access/src/index.ts | 117 +++++++++++++++++- pnpm-lock.yaml | 5 +- tests/integration/tool-access-bridge.test.mjs | 80 +++++++++++- 11 files changed, 221 insertions(+), 12 deletions(-) diff --git a/docs/MODULE-VERSIONS.md b/docs/MODULE-VERSIONS.md index 997905d72f..cc41969b0a 100644 --- a/docs/MODULE-VERSIONS.md +++ b/docs/MODULE-VERSIONS.md @@ -28,7 +28,7 @@ | --- | --- | --- | --- | | 领域公开契约 | **0.1** | `workdsh-contracts@0.1.0-alpha.4` | P0-04 实施中;包含 Host identity/access/audit、Session owner/runtime binding 与排空契约 | | 本地身份提供方 | **0.1** | `workdsh-provider-identity-local@0.1.0-alpha.3` | P0-05 实施中;官方 Storage Domain 持久化可信单用户 Profile,不用于远程认证 | -| 资源授权 | **0.1** | `workdsh-plugin-access@0.1.0-alpha.2` | P1-09 实施中;持久 Session owner、RuntimeBinding 与官方工具前置授权/结果审计 | +| 资源授权 | **0.1** | `workdsh-plugin-access@0.1.0-alpha.3` | P1-09 实施中;持久 Session owner、受控 Session Host 入口、RuntimeBinding 与官方工具授权/审计 | | 审计 | **0.1** | `workdsh-plugin-audit@0.1.0-alpha.2` | P1-09 实施中;官方 Storage Domain 持久审计、排空和敏感引用拒绝 | | 技能管理 | **0.1** | `workdsh-plugin-skills@0.1.0-alpha.23` | 默认/本地管理闭环完成;公共市场不在当前范围,企业服务端与管理 Web 列入后期 ToDo | | 工作台 | 0.1 | `workdsh-plugin-workbench@0.1.0-alpha.8` | 官方 Sidebar/Conversation 组合切片 | diff --git a/docs/PLUGIN-DELIVERY.md b/docs/PLUGIN-DELIVERY.md index f7a357f48e..0ce99269c8 100644 --- a/docs/PLUGIN-DELIVERY.md +++ b/docs/PLUGIN-DELIVERY.md @@ -93,7 +93,7 @@ DOC-06 官方文档审查已完成 127/127;C01 的目录、正文、真实 Age 当前已实施 `workdsh-contracts` 0.1,固化 ActorContext、Organization、Membership、ResourceOwner、AccessGrant、RuntimeBinding、AuditEvent 及 identity/access/audit 服务边界,见 [ADR-0016](adr/0016-governance-contracts-first.md)。该包无 UI、数据库和传输,不复制 Harness User、Permission Preset、Approval、Sandbox 或 Session scope。 -可信本地 IdentityProvider、Access 和 Audit 的 Host 基础服务已经建立,并以两主体、两组织、管理员访问他人私有资源、显式 grant/revoke、冲突和冷重启负例验证。官方工具流水线已通过 Session owner/runtime binding 接入同一 ActorContext/access/audit guard。下一步接 Session Controller 创建/恢复、文件、Remote 与成员撤权后的在途取消。团队远程入口继续关闭;企业 SSO、服务端与管理 Web 保留到后期阶段。全路径证据完成后才关闭 D01、进入 D02 基础插件和工作台最小版本。 +可信本地 IdentityProvider、Access 和 Audit 的 Host 基础服务已经建立,并以两主体、两组织、管理员访问他人私有资源、显式 grant/revoke、冲突和冷重启负例验证。官方工具流水线已通过 Session owner/runtime binding 接入同一 ActorContext/access/audit guard;受控 Session Host 入口会先绑定 owner 再委托官方 Controller 创建,并在恢复 Agent 前重新授权。下一步为该 Host 入口生成 WorkDSH Remote 并确保企业组合不暴露裸 Session Remote,再接文件、其他 Remote 与成员撤权后的在途取消。团队远程入口继续关闭;企业 SSO、服务端与管理 Web 保留到后期阶段。全路径证据完成后才关闭 D01、进入 D02 基础插件和工作台最小版本。 仍保留的运行差异探针按 [审查收尾清单](research/harness-review-closure.md) 独立执行:Plan 提交时机、Shell/PTY/Code runtime 停稳、feedback/inspect/lookup 主体限制,以及 Subagent/Goal 恢复。它们不能因 Skill 0.1 完成而删除,也不要求为验证创建假业务 UI。 diff --git a/docs/STATUS.md b/docs/STATUS.md index ee9ac68b54..fa9cdf1679 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -12,9 +12,9 @@ D01 当前推进 P0-04/P0-05。`workdsh-contracts@0.1.0-alpha.4` 定义服务端 P0-05 同步推进到 `workdsh-provider-identity-local@0.1.0-alpha.3`。本地 provider 已注册为 Cordis Host Service,通过官方 `ctx.storageDomain` 原子保存主体、个人组织与 owner 成员关系;冷启动保持同一 revision,配置与持久身份冲突时拒绝启动。principal/organization 仍只来自 Host 配置,每次请求生成独立 requestId,输入夹带的伪造身份字段会被忽略。Harness 官方匿名安装 ID 明确只用于遥测关联,未被误用为用户身份。 -`workdsh-plugin-audit@0.1.0-alpha.2` 和 `workdsh-plugin-access@0.1.0-alpha.2` 已建立真实 Cordis Host Service。两者使用官方 Storage Domain 分域持久化;Access 只经 IdentityService 查询成员关系,实行组织隔离、资源 owner、显式 grant/revoke、revision 冲突检测,并在返回授权结果前写入审计。新增独立 Session owner Domain 与官方工具流水线桥接:`tools/pre-execute` 做 Host 身份解析和授权,`tools/result` 记录最终成功/失败,`session/flush` 排空审计。个人 Profile 可首次调用自动绑定;关闭自动绑定后未登记 Session 失败关闭,owner 替换被拒绝,冷重启保持绑定。当前尚未接入 Session Controller 创建/恢复、文件、Remote 与成员撤权后的在途取消,因此 D01/P1-09 保持进行中,团队远程入口继续关闭。证据见 [Access 与 Audit 验证](evidence/d01-access-audit.md)。 +`workdsh-plugin-audit@0.1.0-alpha.2` 和 `workdsh-plugin-access@0.1.0-alpha.3` 已建立真实 Cordis Host Service。两者使用官方 Storage Domain 分域持久化;Access 只经 IdentityService 查询成员关系,实行组织隔离、资源 owner、显式 grant/revoke、revision 冲突检测,并在返回授权结果前写入审计。独立 Session owner Domain 与官方工具流水线桥接已生效;新增 `workdshSessionAccess` Host 入口,在调用官方 Session Controller 创建前先保留不可替换的 owner,并在恢复 Agent 前按最新成员关系和 grant 重新授权。个人 Profile 可首次工具调用自动绑定;企业式组合应关闭该回退并只从受控入口创建/恢复。该入口当前是 Host Service,尚未生成并替换外部 Session Remote;文件、其他 Remote 与成员撤权后的在途取消也未接入,因此 D01/P1-09 保持进行中,团队远程入口继续关闭。证据见 [Access 与 Audit 验证](evidence/d01-access-audit.md)。 -当前根构建、类型检查、31/31 集成测试、2/2 规划测试、计划清单、版本锁定和补丁格式检查均通过。 +当前根构建、类型检查、32/32 集成测试、2/2 规划测试、计划清单、版本锁定和补丁格式检查均通过。 skills alpha.23 / bundle alpha.35 收口认证 Fetch 的超时与取消。Client 对列表和修改请求设置有界超时,上传打包、流读取、预检和确认安装共用 `AbortSignal`;界面在上传和安装阶段均提供真实取消入口。Host 主动取消阻塞中的流读取并清理私有暂存目录,在文件遍历、摘要、复制及最终原子重命名前检查中止;原子发布完成后按成功结算,避免技能已安装但界面报告取消。中途上传取消与提交前取消/重试已进入 19/19 集成回归。该能力继续使用 Harness Connection 的认证 exact Fetch 扩展面,不增加第二套传输。 diff --git a/docs/evidence/d01-access-audit.md b/docs/evidence/d01-access-audit.md index 8b12c9e147..ca47d499e4 100644 --- a/docs/evidence/d01-access-audit.md +++ b/docs/evidence/d01-access-audit.md @@ -18,6 +18,7 @@ - Access 与 Audit 属于不同 Storage Domain,当前后端不提供跨 Domain 事务。grant/revoke 先记录 outcome=unknown 的操作意图,写入授权事实后再记录 succeeded,避免把部分成功伪装成原子成功。 - Session owner 使用独立 `workdsh_runtime_binding` Domain 持久化,已绑定 owner 不能被另一主体替换;当前 RuntimeBinding 在每次执行时根据最新 membership 与 grant 重新生成。 - 工具执行按官方扩展点接入:`tools/pre-execute` 完成异步身份解析和授权,`tools/result` 观察不可变最终结果,`session/flush` 与 Cordis disposer 等待排队审计落盘。 +- `workdshSessionAccess` 在调用官方 `sessionController.create()` 前生成稳定 Session id 并持久绑定 owner;显式 id 首次绑定前通过官方 `inspect()` 拒绝已有但无 owner 的 Session,创建失败则保留同一 owner 的可重试预留。恢复 Agent 前先按当前 membership/grant 调用 `resolveRuntime()`,拒绝时不触达官方 Controller。 ## 验证 @@ -29,7 +30,8 @@ - 个人 Profile 的首次官方 Agent 工具调用可以自动绑定 Session;未授权成员的工具正文不会运行,显式取得 Session use grant 后可以运行。 - 工具成功、工具抛错和策略拒绝分别形成 succeeded、failed 和 denied 审计;最终结果审计携带本次 authorizationRevision。 - 关闭个人自动绑定时,未在受信入口登记 owner 的 Session 失败关闭;显式绑定经冷重启保持,替换 owner 被拒绝。 +- 已验证未登记 owner 的历史 Session 采用失败、owner 记录先于官方 Session 创建、创建失败后的同 owner 重试、异主体接管拒绝、未授权恢复不激活 Agent,以及 grant 后恢复成功。 ## 尚未完成 -该证据覆盖 Host 服务直接调用及官方工具流水线,但尚未让 Session Controller 的创建/恢复入口显式登记 owner,也未接入文件/资产服务、Typert Remote/Connection、订阅和成员撤权后的在途取消。企业身份、服务器端管理 Web、组织目录和审计查询 UI 属于后期范围。D01、P0-04、P0-05 与 P1-09 因此保持进行中。 +该证据覆盖 Host 服务、受控 Session 创建/恢复入口及官方工具流水线。受控 Session 入口仍是内部 Host Service,尚未生成 WorkDSH Remote 并从企业组合中撤下裸 `session` Remote;文件/资产服务、其他 Typert Remote/Connection、订阅和成员撤权后的在途取消也未覆盖。企业身份、服务器端管理 Web、组织目录和审计查询 UI 属于后期范围。D01、P0-04、P0-05 与 P1-09 因此保持进行中。 diff --git a/packages/contracts/README.md b/packages/contracts/README.md index 3e408fa510..e0608393c7 100644 --- a/packages/contracts/README.md +++ b/packages/contracts/README.md @@ -15,4 +15,4 @@ ## 验收与下一步 -本地 identity、access 和 audit 已消费本包接口,并完成官方工具流水线的首个治理接入;下一步接 Session Controller、文件和 Remote。完成对应 PLAN 任务及 [验收矩阵](../../docs/ACCEPTANCE.md) 场景后才完成 P0-04/P0-05。企业 Skill 架构见 [ADR 0015](../../docs/adr/0015-skill-control-plane-and-runtime-projection.md)。 +本地 identity、access 和 audit 已消费本包接口,并完成受控 Session Host 入口和官方工具流水线的首个治理接入;下一步生成受控 Session Remote,再接文件和其他 Remote。完成对应 PLAN 任务及 [验收矩阵](../../docs/ACCEPTANCE.md) 场景后才完成 P0-04/P0-05。企业 Skill 架构见 [ADR 0015](../../docs/adr/0015-skill-control-plane-and-runtime-projection.md)。 diff --git a/packages/plugins/access/CHANGELOG.md b/packages/plugins/access/CHANGELOG.md index 7076a177e5..04ead0e63d 100644 --- a/packages/plugins/access/CHANGELOG.md +++ b/packages/plugins/access/CHANGELOG.md @@ -1,3 +1,10 @@ +# 0.1.0-alpha.3 + +- 增加 `workdshSessionAccess` 受控 Host 入口,在调用官方 Session Controller 创建前持久绑定稳定 owner。 +- Session 恢复在激活官方 Agent 前重新检查当前成员关系和 grant,拒绝请求不会触达 Controller。 +- 创建失败保留不可替换的 owner 预留,允许同一主体安全重试并阻止其他主体接管 Session id。 +- 显式 Session id 在首次绑定前使用官方冷读 `inspect()` 排除已存在但无 owner 的会话,阻止历史会话被补绑接管。 + # 0.1.0-alpha.2 - 增加独立 Storage Domain 保存不可替换的 Session owner binding,冷重启后仍可解析当前 RuntimeBinding。 diff --git a/packages/plugins/access/README.md b/packages/plugins/access/README.md index cdd0597aa5..4c81ae7932 100644 --- a/packages/plugins/access/README.md +++ b/packages/plugins/access/README.md @@ -1,6 +1,6 @@ # 资源授权 -状态:**0.1 实现中**。`0.1.0-alpha.2` 提供 Host 侧资源授权、持久 grant、Session owner/runtime binding、预期修订更新、撤权和官方工具流水线审计;尚未接入 Session Controller 创建/恢复、文件与 Remote 全入口。 +状态:**0.1 实现中**。`0.1.0-alpha.3` 提供 Host 侧资源授权、持久 grant、Session owner/runtime binding、受控 Session Host 入口、预期修订更新、撤权和官方工具流水线审计;尚未生成企业外部 Session Remote,也未接入文件与其他 Remote 入口。 - 实现阶段:P0/P1 - 主任务:P1-09,详见 [开发计划](../../../docs/PLAN.md) @@ -19,4 +19,6 @@ `ToolAccessBridge` 使用官方 `tools/pre-execute` 做异步授权,并用最终 `tools/result` 形成结果审计;`session/flush` 和插件卸载会等待审计排空。个人 Profile 可在首次工具调用时绑定 Session,企业组合必须关闭该选项并在受信创建入口显式调用 `bindSession()`。 -下一步将相同服务装配到 Session Controller 创建/恢复、文件、Remote 与订阅入口,并验证成员撤权后的在途取消。完成对应 PLAN 任务及 [验收矩阵](../../../docs/ACCEPTANCE.md) 场景后才结束 P1-09。 +`SessionAccessBridge` 是 WorkDSH 内部 Host 入口:创建时先生成 Session id 并持久绑定 owner,再委托官方 `sessionController.create()`;显式 id 首次绑定前用官方 `inspect()` 拒绝采用已有但无 owner 的 Session。恢复时先按当前身份和 grant 授权,再委托 `resolveAgent()`。它不会修改或包装官方服务,也不会在本版本声明为生成式 Remote。企业组合后续必须只暴露 WorkDSH 受控 Remote,并撤下外部裸 Session Remote。 + +下一步生成受控 Session Remote,再将相同服务装配到文件、其他 Remote 与订阅入口,并验证成员撤权后的在途取消。完成对应 PLAN 任务及 [验收矩阵](../../../docs/ACCEPTANCE.md) 场景后才结束 P1-09。 diff --git a/packages/plugins/access/package.json b/packages/plugins/access/package.json index a61bbc598a..cb89923fa3 100644 --- a/packages/plugins/access/package.json +++ b/packages/plugins/access/package.json @@ -1,6 +1,6 @@ { "name": "workdsh-plugin-access", - "version": "0.1.0-alpha.2", + "version": "0.1.0-alpha.3", "private": true, "type": "module", "exports": { @@ -24,12 +24,14 @@ }, "peerDependencies": { "@deepseek-ai/cordis": "^4.0.2", + "@deepseek-ai/dsh-api-session-controller": "^0.1.5-rc.1", "@deepseek-ai/dsh-storage-domain": "^0.1.5-rc.1", "@deepseek-ai/dsh-session": "^0.1.5-rc.1", "@deepseek-ai/dsh-tools": "^0.1.5-rc.1" }, "devDependencies": { "@deepseek-ai/cordis": "4.0.2", + "@deepseek-ai/dsh-api-session-controller": "0.1.5-rc.1", "@deepseek-ai/dsh-storage-domain": "0.1.5-rc.1", "@types/node": "22.19.0", "@deepseek-ai/dsh-session": "0.1.5-rc.1", diff --git a/packages/plugins/access/src/index.ts b/packages/plugins/access/src/index.ts index 24e06b20d2..6d48e380e6 100644 --- a/packages/plugins/access/src/index.ts +++ b/packages/plugins/access/src/index.ts @@ -1,7 +1,11 @@ import { createHash, randomUUID } from 'node:crypto'; import { Context, Service } from '@deepseek-ai/cordis'; +import type { + SessionCreateRequest, + SessionCreateValue, +} from '@deepseek-ai/dsh-api-session-controller'; import { defineDomain, domainTable, type KvTable } from '@deepseek-ai/dsh-storage-domain'; -import type {} from '@deepseek-ai/dsh-session'; +import type { SessionId } from '@deepseek-ai/dsh-session'; import type { PreToolDecision, ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools'; import { z } from 'zod'; import { @@ -25,6 +29,8 @@ import { type SessionOwnerBinding, } from 'workdsh-contracts'; +type SessionAgentResult = Awaited>; + const bounded = z.string().min(1).max(256).refine((value) => !/[\u0000-\u001f]/.test(value)); const resourceRefSchema: z.ZodType = z.object({ domain: bounded, id: bounded, revision: bounded.optional() }); const accessGrantSchema: z.ZodType = z.object({ @@ -63,6 +69,7 @@ declare module '@deepseek-ai/cordis' { workdshIdentity: IdentityService; workdshAudit: AuditService; workdshAccess: AccessManager; + workdshSessionAccess: SessionAccessBridge; workdshToolAccess: ToolAccessBridge; } } @@ -375,6 +382,114 @@ export interface ToolAccessBridgeConfig { readonly autoBindPersonalSessions?: boolean; } +export interface SessionAccessBridgeConfig { + readonly runtimeId?: string; + readonly isolation?: RuntimeBindingRequest['isolation']; +} + +/** Trusted Host ingress for WorkDSH Session creation and resume. */ +export class SessionAccessBridge extends Service { + static inject = ['sessionController', 'workdshIdentity', 'workdshAccess', 'workdshAudit']; + private readonly runtimeId: string; + private readonly isolation: RuntimeBindingRequest['isolation']; + + constructor(ctx: Context, config: SessionAccessBridgeConfig = {}) { + super(ctx, 'workdshSessionAccess'); + this.runtimeId = config.runtimeId ?? `workdsh-session-runtime-${randomUUID()}`; + this.isolation = config.isolation ?? 'local-trusted'; + } + + async create(request: SessionCreateRequest, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + const sessionId = request.sessionId ?? (`session-${randomUUID()}` as SessionId); + const actor = await this.ctx.workdshIdentity.resolve({ sessionId: String(sessionId) }, signal); + if (request.sessionId !== undefined && !this.ctx.workdshAccess.sessionOwner(String(sessionId))) { + let exists = false; + try { + await this.ctx.sessionController.inspect(sessionId, signal); + exists = true; + } catch (error) { + if (!hasErrorCode(error, 'session/not-found')) throw error; + } + if (exists) { + await this.audit(actor, 'session.create', 'denied', 'access/unbound-session-adoption'); + throw new GovernanceContractError( + 'access/unbound-session-adoption', + 'An existing Session without a trusted owner binding cannot be adopted.', + ); + } + } + await this.ctx.workdshAccess.bindSession(actor, { + sessionId: String(sessionId), + ...(request.workspaceId === undefined ? {} : { workspaceId: String(request.workspaceId) }), + }, signal); + signal?.throwIfAborted(); + try { + const result = await this.ctx.sessionController.create({ ...request, sessionId }); + if (String(result.sessionId) !== String(sessionId)) { + throw new GovernanceContractError('access/session-id-mismatch', 'Session Controller returned a different Session identity.'); + } + await this.audit(actor, 'session.create', 'succeeded', 'session/create-succeeded'); + return result; + } catch (error) { + await this.audit(actor, 'session.create', 'failed', errorCode(error, 'session/create-failed')); + throw error; + } + } + + async resolveAgent(sessionId: SessionId, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + const id = String(sessionId); + const actor = await this.ctx.workdshIdentity.resolve({ sessionId: id }, signal); + const owner = this.ctx.workdshAccess.sessionOwner(id); + await this.ctx.workdshAccess.resolveRuntime(actor, { + sessionId: id, + runtimeId: this.runtimeId, + isolation: this.isolation, + ...(owner?.workspaceId === undefined ? {} : { workspaceId: owner.workspaceId }), + }, signal); + signal?.throwIfAborted(); + try { + const result = await this.ctx.sessionController.resolveAgent(sessionId); + if ('error' in result) { + await this.audit(actor, 'session.resume', 'failed', errorCode(result.error, 'session/resume-failed')); + } else { + await this.audit(actor, 'session.resume', 'succeeded', 'session/resume-succeeded'); + } + return result; + } catch (error) { + await this.audit(actor, 'session.resume', 'failed', errorCode(error, 'session/resume-failed')); + throw error; + } + } + + private async audit(actor: ActorContext, action: string, outcome: AuditEvent['outcome'], code: string): Promise { + await this.ctx.workdshAudit.append({ + id: randomUUID(), + occurredAt: new Date().toISOString(), + requestId: actor.requestId, + principalId: actor.principalId, + organizationId: actor.organizationId, + action, + target: sessionResource(actor.sessionId ?? 'unknown'), + outcome, + code, + ...(actor.sessionId ? { sessionId: actor.sessionId } : {}), + }); + } +} + +function errorCode(error: unknown, fallback: string): string { + if (error && typeof error === 'object' && 'code' in error && typeof error.code === 'string') { + return safeAuditReference(error.code); + } + return fallback; +} + +function hasErrorCode(error: unknown, code: string): boolean { + return Boolean(error && typeof error === 'object' && 'code' in error && error.code === code); +} + /** Binds the official tool pipeline to Host identity, Session ownership, Access and Audit. */ export class ToolAccessBridge extends Service { static inject = ['tools', 'workdshIdentity', 'workdshAccess', 'workdshAudit']; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 038fae719a..8cf5ab232e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -392,6 +392,9 @@ importers: '@deepseek-ai/cordis': specifier: 4.0.2 version: 4.0.2(@deepseek-ai/cordis-plugin-include@1.0.7)(@deepseek-ai/cordis-plugin-loader@1.0.3) + '@deepseek-ai/dsh-api-session-controller': + specifier: 0.1.5-rc.1 + version: 0.1.5-rc.1(2c50dad3f37865829d6010a3cf949f80) '@deepseek-ai/dsh-session': specifier: 0.1.5-rc.1 version: 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))) @@ -4593,7 +4596,7 @@ snapshots: '@deepseek-ai/dsh-workspace': 0.1.5-rc.1(0d21b8701233c0d1ccd262c73d8d4b61) '@deepseek-ai/schemastery': 3.18.2 mime-types: 3.0.2 - zod: 4.6.1 + zod: 4.4.3 optionalDependencies: '@deepseek-ai/dsh-jobs': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-agent@0.1.5-rc.1(011efc65c149ca53f6af28580be53483))(@deepseek-ai/dsh-brand@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-session@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)))) '@deepseek-ai/dsh-session-persistence': 0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-brand@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-session@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-scope@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2))))(@deepseek-ai/dsh-timeout@0.1.5-rc.1(@deepseek-ai/cordis@4.0.2)) diff --git a/tests/integration/tool-access-bridge.test.mjs b/tests/integration/tool-access-bridge.test.mjs index 5b71c0617b..f0d47412b8 100644 --- a/tests/integration/tool-access-bridge.test.mjs +++ b/tests/integration/tool-access-bridge.test.mjs @@ -8,7 +8,7 @@ import Storage from '@deepseek-ai/dsh-storage'; import * as JsonStorage from '@deepseek-ai/dsh-storage-json'; import * as StorageDomain from '@deepseek-ai/dsh-storage-domain'; import Tools from '@deepseek-ai/dsh-tools'; -import { AccessManager, ToolAccessBridge } from '../../packages/plugins/access/dist/index.js'; +import { AccessManager, SessionAccessBridge, ToolAccessBridge } from '../../packages/plugins/access/dist/index.js'; import { AuditJournal } from '../../packages/plugins/audit/dist/index.js'; const membershipDirectory = new Map([ @@ -187,3 +187,81 @@ test('explicit Session ownership survives restart and cannot be replaced', async await rm(root, { recursive: true, force: true }); } }); + +test('trusted Session ingress binds before create, preserves retry ownership and authorizes resume', async () => { + const root = await mkdtemp(join(tmpdir(), 'workdsh-session-ingress-')); + const selected = new Map(); + const createCalls = []; + const resumeCalls = []; + const failCreate = new Set(['session-retry']); + const existingSessions = new Set(['session-orphan']); + let ctx; + try { + ctx = await boot(root, selected, { autoBindPersonalSessions: false }); + ctx.provide('sessionController', { + async inspect(sessionId) { + if (existingSessions.has(String(sessionId))) return { header: { id: sessionId }, events: [] }; + throw Object.assign(new Error('fixture Session not found'), { code: 'session/not-found' }); + }, + async create(request) { + const sessionId = String(request.sessionId); + assert.equal(ctx.workdshAccess.sessionOwner(sessionId)?.ownerPrincipalId, 'owner-a', 'owner is durable before official create'); + createCalls.push(sessionId); + if (failCreate.delete(sessionId)) throw Object.assign(new Error('fixture create failed'), { code: 'session/create-fixture' }); + existingSessions.add(sessionId); + return { sessionId: request.sessionId }; + }, + async resolveAgent(sessionId) { + resumeCalls.push(String(sessionId)); + return { agent: { id: sessionId } }; + }, + }); + await ctx.plugin(SessionAccessBridge, { runtimeId: 'session-ingress-test' }); + + await assert.rejects( + ctx.workdshSessionAccess.create({ sessionId: 'session-orphan', cwd: '/tmp' }), + { code: 'access/unbound-session-adoption' }, + ); + assert.equal(ctx.workdshAccess.sessionOwner('session-orphan'), undefined); + + await assert.rejects(ctx.workdshSessionAccess.create({ sessionId: 'session-retry', cwd: '/tmp' }), /fixture create failed/); + assert.equal(ctx.workdshAccess.sessionOwner('session-retry')?.ownerPrincipalId, 'owner-a'); + + selected.set('session-retry', ['member-a', 'personal-owner']); + await assert.rejects( + ctx.workdshSessionAccess.create({ sessionId: 'session-retry', cwd: '/tmp' }), + { code: 'access/session-owner-conflict' }, + ); + assert.deepEqual(createCalls, ['session-retry'], 'an ownership conflict never reaches the official controller'); + + selected.set('session-retry', ['owner-a', 'personal-owner']); + assert.equal((await ctx.workdshSessionAccess.create({ sessionId: 'session-retry', cwd: '/tmp' })).sessionId, 'session-retry'); + const generated = await ctx.workdshSessionAccess.create({ cwd: '/tmp' }); + assert.match(String(generated.sessionId), /^session-/); + assert.equal(ctx.workdshAccess.sessionOwner(String(generated.sessionId))?.ownerPrincipalId, 'owner-a'); + + selected.set('session-retry', ['member-a', 'personal-owner']); + await assert.rejects(ctx.workdshSessionAccess.resolveAgent('session-retry'), { code: 'access/not-granted' }); + assert.deepEqual(resumeCalls, [], 'unauthorized resume never reaches the official controller'); + await ctx.workdshAccess.putGrant( + { principalId: 'owner-a', organizationId: 'personal-owner', requestId: 'session-grant', resolvedBy: 'test-session-identity' }, + { organizationId: 'personal-owner', ownerPrincipalId: 'owner-a', scope: 'personal' }, + { + id: 'session-retry-use-member-a', organizationId: 'personal-owner', subjectPrincipalId: 'member-a', + resource: { domain: 'session', id: 'session-retry' }, actions: ['use'], revision: 'session-retry-grant-1', + }, + ); + assert.ok('agent' in await ctx.workdshSessionAccess.resolveAgent('session-retry')); + assert.deepEqual(resumeCalls, ['session-retry']); + + await ctx.workdshToolAccess.flush(); + const events = ctx.workdshAudit.snapshot(); + assert.ok(events.some((event) => event.action === 'session.create' && event.outcome === 'failed' && event.code === 'session/create-fixture')); + assert.ok(events.some((event) => event.action === 'session.create' && event.outcome === 'denied' && event.code === 'access/unbound-session-adoption')); + assert.ok(events.some((event) => event.action === 'session.create' && event.outcome === 'succeeded')); + assert.ok(events.some((event) => event.action === 'session.resume' && event.outcome === 'succeeded')); + } finally { + if (ctx) await ctx.fiber.dispose(); + await rm(root, { recursive: true, force: true }); + } +});