feat: add governed session host ingress

This commit is contained in:
techflag
2026-09-12 00:40:34 +08:00
parent 4232fa382d
commit d21bd6da9a
11 changed files with 221 additions and 12 deletions
+1 -1
View File
@@ -28,7 +28,7 @@
| --- | --- | --- | --- |
| 领域公开契约 | **0.1** | `[email protected]` | P0-04 实施中;包含 Host identity/access/audit、Session owner/runtime binding 与排空契约 |
| 本地身份提供方 | **0.1** | `[email protected]` | P0-05 实施中;官方 Storage Domain 持久化可信单用户 Profile,不用于远程认证 |
| 资源授权 | **0.1** | `[email protected].2` | P1-09 实施中;持久 Session owner、RuntimeBinding 与官方工具前置授权/结果审计 |
| 资源授权 | **0.1** | `[email protected].3` | P1-09 实施中;持久 Session owner、受控 Session Host 入口、RuntimeBinding 与官方工具授权/审计 |
| 审计 | **0.1** | `[email protected]` | P1-09 实施中;官方 Storage Domain 持久审计、排空和敏感引用拒绝 |
| 技能管理 | **0.1** | `[email protected]` | 默认/本地管理闭环完成;公共市场不在当前范围,企业服务端与管理 Web 列入后期 ToDo |
| 工作台 | 0.1 | `[email protected]` | 官方 Sidebar/Conversation 组合切片 |
+1 -1
View File
@@ -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。
+2 -2
View File
@@ -12,9 +12,9 @@ D01 当前推进 P0-04/P0-05。`[email protected]` 定义服务端
P0-05 同步推进到 `[email protected]`。本地 provider 已注册为 Cordis Host Service,通过官方 `ctx.storageDomain` 原子保存主体、个人组织与 owner 成员关系;冷启动保持同一 revision,配置与持久身份冲突时拒绝启动。principal/organization 仍只来自 Host 配置,每次请求生成独立 requestId,输入夹带的伪造身份字段会被忽略。Harness 官方匿名安装 ID 明确只用于遥测关联,未被误用为用户身份。
`[email protected]` 和 `[email protected].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)。
`[email protected]` 和 `[email protected].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 扩展面,不增加第二套传输。
+3 -1
View File
@@ -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 因此保持进行中。
+1 -1
View File
@@ -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)。
+7
View File
@@ -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。
+4 -2
View File
@@ -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。
+3 -1
View File
@@ -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",
+116 -1
View File
@@ -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<ReturnType<Context['sessionController']['resolveAgent']>>;
const bounded = z.string().min(1).max(256).refine((value) => !/[\u0000-\u001f]/.test(value));
const resourceRefSchema: z.ZodType<ResourceRef> = z.object({ domain: bounded, id: bounded, revision: bounded.optional() });
const accessGrantSchema: z.ZodType<AccessGrant> = 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<SessionCreateValue> {
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<SessionAgentResult> {
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<void> {
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'];
+4 -1
View File
@@ -392,6 +392,9 @@ importers:
'@deepseek-ai/cordis':
specifier: 4.0.2
version: 4.0.2(@deepseek-ai/[email protected])(@deepseek-ai/[email protected])
'@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/[email protected])(@deepseek-ai/[email protected](@deepseek-ai/[email protected])(@deepseek-ai/[email protected](@deepseek-ai/[email protected])))
@@ -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/[email protected])(@deepseek-ai/[email protected](011efc65c149ca53f6af28580be53483))(@deepseek-ai/[email protected](@deepseek-ai/[email protected]))(@deepseek-ai/[email protected](@deepseek-ai/[email protected]))(@deepseek-ai/[email protected](@deepseek-ai/[email protected])(@deepseek-ai/[email protected](@deepseek-ai/[email protected])(@deepseek-ai/[email protected](@deepseek-ai/[email protected]))))
'@deepseek-ai/dsh-session-persistence': 0.1.5-rc.1(@deepseek-ai/[email protected])(@deepseek-ai/[email protected](@deepseek-ai/[email protected]))(@deepseek-ai/[email protected](@deepseek-ai/[email protected])(@deepseek-ai/[email protected](@deepseek-ai/[email protected])(@deepseek-ai/[email protected](@deepseek-ai/[email protected]))))(@deepseek-ai/[email protected](@deepseek-ai/[email protected]))
+79 -1
View File
@@ -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 });
}
});