@addozhang/dsh-discord 0.5.0-rc.2 → 0.5.0-rc.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/README.zh.md +4 -1
- package/cordis.patch.yml +1 -1
- package/lib/discord/commands.js +6 -0
- package/lib/dsh/host-face.d.ts +73 -0
- package/lib/dsh/host-face.js +124 -1
- package/lib/features/interaction-router.d.ts +8 -0
- package/lib/features/interaction-router.js +232 -2
- package/lib/features/permission-control.d.ts +88 -0
- package/lib/features/permission-control.js +59 -0
- package/lib/i18n.d.ts +26 -0
- package/lib/i18n.js +56 -0
- package/lib/index.js +5 -2
- package/lib/settings.d.ts +8 -0
- package/lib/settings.js +2 -0
- package/lib/startup.d.ts +1 -1
- package/lib/startup.js +7 -1
- package/lib/stream/live.d.ts +7 -0
- package/lib/stream/live.js +21 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -23,6 +23,7 @@ No extra process: the adapter is a DSH plugin that mounts straight onto your `ds
|
|
|
23
23
|
- **Approvals & questions** — DSH asks become buttons, select menus, and a free-text modal. Ownership is enforced (the asker — or the thread owner on later turns — clicks), expiry sweeps fail closed, and settled controls grey out in place.
|
|
24
24
|
- **Session control** — `/steer`, `/stop`, and `/queue list|remove` with turn-ownership checks; `/project bind|list|info` and `/session resume` for guild↔workspace binding and history; `/guild forget` for operator cleanup.
|
|
25
25
|
- **Model selection** — `/model show` reads the session's live model directory; `/model select` walks an interactive provider → model → reasoning cascade, or applies a typed `provider/model` directly. Open to any authorized member by default; restrictable to Host operators.
|
|
26
|
+
- **Permission switching** — `/permission show` / `set` read and switch the session's permission preset (bundled sandbox + approval tiers) through the Host's own `/permission` command path, inheriting its journal audit pair; `danger-full-access` requires a button confirmation.
|
|
26
27
|
- **Settings card, bilingual out of the box** — token onboarding and connect/disconnect (stored in the Host credential service, never in settings or logs), guild allowlist, auto-archive, and language. Every Discord-visible string ships in Chinese and English; the bot follows the DSH language preference or a pinned choice.
|
|
27
28
|
- **Hardened by design** — deny-first authorization inside an explicit guild allowlist. Mentions are suppressed twice: `allowed_mentions` on every request, plus byte-level neutralization of the wire body. DSH submission is at-most-once with unknown-preserving reconciliation — an ambiguous delivery is never blindly resent. Bindings survive restarts, and the READY sweep rebuilds deleted category/control channels while treating a deleted workspace channel as user intent (the mapping retires; the workspace stays bindable).
|
|
28
29
|
|
|
@@ -70,6 +71,7 @@ All keys live in the `dsh-discord` settings namespace and can be set either from
|
|
|
70
71
|
| `deniedUserIds` / `deniedRoleIds` | `[]` | Deny entries; they win over every grant above. |
|
|
71
72
|
| `hostOperatorUserIds` | `[]` | Host operators (`/guild forget`; `/model select` when `modelSelectOperatorOnly` is enabled). |
|
|
72
73
|
| `modelSelectOperatorOnly` | `false` | Restrict `/model select` to Host operators (`settings.yaml` only; the card does not expose it). Default `false`: any authorized member may switch, and the switch still updates the Host default. |
|
|
74
|
+
| `permissionSelectOperatorOnly` | `true` | Restrict `/permission set` to Host operators (`settings.yaml` only). Default `true` — stricter than `/model` because `danger-full-access` disables both sandbox and approval; loosen to let any authorized member switch. |
|
|
73
75
|
| `defaultVerbosity` | `essential-tools` | Tool-activity row granularity: `text-only`, `essential-tools`, or `full-tools`. |
|
|
74
76
|
| `language` | `auto` | Bot-visible copy language: `auto` follows the DSH language preference (non-Chinese renders English), or pin `zh`/`en`. |
|
|
75
77
|
| `streamUpdateIntervalMs` | `800` | Coalescing budget for stream edits (250–10000). |
|
|
@@ -110,10 +112,11 @@ The settings card exposes the three high-frequency fields (guild allowlist, auto
|
|
|
110
112
|
| `/model show` / `select` | session thread | show the live model directory; `select` without arguments walks the interactive provider → model → reasoning cascade (any authorized member by default) |
|
|
111
113
|
| `/session resume` | project channel | pick one of this workspace's past sessions (autocomplete: title and age, newest first) and resume it into a new thread of this channel; blank, already-bound, subagent, and archived sessions are never offered. The thread renders the session's history — answers and tool summaries plus the user's inputs as quoted echo lines (plugin/system injections are never echoed). After an adapter restart, threads catch up only what they missed: a persisted render watermark suppresses already-delivered history, and bound sessions re-subscribe on reconnect so turns prompted from the web UI still land in their thread |
|
|
112
114
|
| `/guild forget` | any channel | operator-only removal of adapter records |
|
|
115
|
+
| `/permission show` / `set` | session thread | read or switch the session's permission preset (view only / workspace edit / full access — bundled sandbox + approval tiers, catalog read live from the Host). `set danger-full-access` asks for a button confirmation first; presets autocomplete; Host operators only by default (`permissionSelectOperatorOnly` can loosen it). Every switch — from Discord or the web UI — renders one system line in the thread |
|
|
113
116
|
|
|
114
117
|
## Design notes
|
|
115
118
|
|
|
116
|
-
- The adapter is a function/namespace plugin (`inject: ['sessionController', 'workspaceController', 'sessionQuery', 'webServer', 'credentials', 'settings', 'storageDomain', 'connection']`) that mounts the Discord Gateway, command surface, stream renderer, and the settings card onto the DSH web profile.
|
|
119
|
+
- The adapter is a function/namespace plugin (`inject: ['sessionController', 'workspaceController', 'sessionQuery', 'webServer', 'credentials', 'settings', 'storageDomain', 'connection', 'commands', 'permissionPresets']`) that mounts the Discord Gateway, command surface, stream renderer, and the settings card onto the DSH web profile.
|
|
117
120
|
- The settings card is the first-run onboarding surface: the token entry writes the credential service's `DSH_DISCORD_BOT_TOKEN` ref over the plugin management channel, then triggers the start chain. Disconnect keeps the credential; an empty reconnect uses it.
|
|
118
121
|
- The publish workflow authenticates to npm via trusted publishing (OIDC) — no publish token is stored anywhere.
|
|
119
122
|
- The adapter start chain is generation-counted, so Connect/Disconnect races with the initial boot yield exactly one gateway.
|
package/README.zh.md
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
- **审批与提问** — DSH 的 ask 渲染为按钮、下拉菜单与自由文本弹窗。所有权强制校验(提问者——或后续 Turn 的线程属主——才能点击),超时清扫 fail-closed,结算后的控件原地置灰。
|
|
24
24
|
- **会话控制** — `/steer`、`/stop`、`/queue list|remove` 带运行所有权校验;`/project bind|list|info` 与 `/session resume` 管理 Guild↔工作区绑定与历史会话;`/guild forget` 供操作员清理。
|
|
25
25
|
- **模型切换** — `/model show` 读取会话的实时模型目录;`/model select` 走交互式 provider → 模型 → 推理强度级联,也可直接填写 `provider/model` 应用。默认对所有授权成员开放,可收紧为仅 Host 操作员。
|
|
26
|
+
- **权限切换** — `/permission show` / `set` 读写会话权限预设(沙箱 + 审批捆绑档位),走宿主自己的 `/permission` 命令路径,journal 自动记审计;`danger-full-access` 需按钮二次确认。
|
|
26
27
|
- **设置卡片,开箱双语** — Token 引导与连接/断开(存入 Host 凭据服务,绝不写入设置或日志)、服务器白名单、自动归档与语言。所有 Discord 可见文案提供中英双语;Bot 默认跟随 DSH 语言偏好,也可从卡片固定。
|
|
27
28
|
- **安全设计** — 显式服务器白名单内的 deny-first 授权。提及抑制双保险:每条请求携带 `allowed_mentions`,外加 wire body 的字节级提及中和。DSH 提交至多一次,并做保留 unknown 的对账——结果不明的投递绝不盲目重发。绑定重启后持久;READY 扫描会重建被删除的 category/控制频道,而把被删除的工作区频道视为用户意图(解除映射,workspace 保持可重新绑定)。
|
|
28
29
|
|
|
@@ -70,6 +71,7 @@ dsh plugin --profile web rm @addozhang/dsh-discord # 卸载;先执行 /guild
|
|
|
70
71
|
| `deniedUserIds` / `deniedRoleIds` | `[]` | 拒绝名单;优先级高于上述一切授权。 |
|
|
71
72
|
| `hostOperatorUserIds` | `[]` | Host 操作员(`/guild forget`;启用 `modelSelectOperatorOnly` 后也包括 `/model select`)。 |
|
|
72
73
|
| `modelSelectOperatorOnly` | `false` | 将 `/model select` 限制为 Host 操作员(仅 `settings.yaml`,卡片不展示)。默认 `false`:任何授权成员均可切换,且切换仍会更新 Host 默认。 |
|
|
74
|
+
| `permissionSelectOperatorOnly` | `true` | 将 `/permission set` 限制为 Host 操作员(仅 `settings.yaml`)。默认 `true`——比 `/model` 更严,因为「完全权限」会同时解除沙箱与审批;可放开为任何授权成员可切换。 |
|
|
73
75
|
| `defaultVerbosity` | `essential-tools` | 工具活动行粒度:`text-only`、`essential-tools`、`full-tools`。 |
|
|
74
76
|
| `language` | `auto` | Bot 可见文案语言:`auto` 跟随 DSH 语言偏好(非中文渲染英文),或固定 `zh`/`en`。 |
|
|
75
77
|
| `streamUpdateIntervalMs` | `800` | 流式编辑合并间隔(250–10000)。 |
|
|
@@ -110,11 +112,12 @@ dsh-discord:
|
|
|
110
112
|
| `/model show` / `select` | 会话线程 | 查看实时模型目录;`select` 不带参数时走交互式 provider → 模型 → 推理强度级联(默认对所有授权成员开放) |
|
|
111
113
|
| `/session resume` | 项目频道 | 自动补全选择本工作区的历史会话(显示标题与时间,最新优先),恢复为当前频道的新线程;空白、已挂线程、subagent、已归档的会话不会出现。线程会渲染会话历史——回答与工具摘要之外,用户输入以引用行回显(插件/系统注入永不回显)。适配器重启后线程只补齐错过部分:持久化的渲染水位抑制已投递过的历史,已绑定会话在重连时自动重订阅,因此从 web UI 发起的回合也会落进对应线程 |
|
|
112
114
|
| `/guild forget` | 任意频道 | 仅操作员:移除适配器记录 |
|
|
115
|
+
| `/permission show` / `set` | 会话线程 | 查看或切换会话权限预设(仅可查看 / 工作区内修改 / 完全权限——沙箱与审批的捆绑档位,目录取自宿主实时配置)。`set danger-full-access` 先要求按钮确认;预设自动补全;默认仅 Host 操作员可切换(`permissionSelectOperatorOnly` 可放开)。任何入口(含 web UI)的切换都会在线程渲染一行系统消息 |
|
|
113
116
|
|
|
114
117
|
|
|
115
118
|
## 设计说明
|
|
116
119
|
|
|
117
|
-
- 适配器为 function/namespace 插件(`inject: ['sessionController', 'workspaceController', 'sessionQuery', 'webServer', 'credentials', 'settings', 'storageDomain', 'connection']`),将 Discord Gateway、命令面、流式渲染器与设置卡片挂载到 DSH web profile。
|
|
120
|
+
- 适配器为 function/namespace 插件(`inject: ['sessionController', 'workspaceController', 'sessionQuery', 'webServer', 'credentials', 'settings', 'storageDomain', 'connection', 'commands', 'permissionPresets']`),将 Discord Gateway、命令面、流式渲染器与设置卡片挂载到 DSH web profile。
|
|
118
121
|
- 设置卡片是首次使用的引导面:Token 输入通过插件管理通道写入凭据服务的 `DSH_DISCORD_BOT_TOKEN` 引用,然后触发启动链。断开连接保留凭据;留空重连直接使用已存 Token。
|
|
119
122
|
- 发布工作流通过 npm trusted publishing (OIDC) 认证——任何地方都不保存发布凭证。
|
|
120
123
|
- 适配器启动链带代际计数,Connect/Disconnect 与初始启动竞争时只会产生一个 Gateway。
|
package/cordis.patch.yml
CHANGED
|
@@ -11,4 +11,4 @@
|
|
|
11
11
|
- insert:
|
|
12
12
|
- id: dsh-discord
|
|
13
13
|
name: '@addozhang/dsh-discord'
|
|
14
|
-
inject: [sessionController, workspaceController, sessionQuery, webServer, credentials, settings, storageDomain, connection]
|
|
14
|
+
inject: [sessionController, workspaceController, sessionQuery, webServer, credentials, settings, storageDomain, connection, commands, permissionPresets]
|
package/lib/discord/commands.js
CHANGED
|
@@ -44,6 +44,12 @@ export const MILESTONE_ONE_COMMANDS = [
|
|
|
44
44
|
grouped('guild', 'Guild-scoped adapter operations', [
|
|
45
45
|
{ name: 'forget' },
|
|
46
46
|
]),
|
|
47
|
+
grouped('permission', 'Show or switch the session permission preset', [
|
|
48
|
+
{ name: 'show' },
|
|
49
|
+
// The preset autocompletes from the live Host catalog (16.61);
|
|
50
|
+
// danger-full-access goes through a button confirmation first.
|
|
51
|
+
{ name: 'set', options: [{ name: 'preset', required: true, autocomplete: true }] },
|
|
52
|
+
]),
|
|
47
53
|
];
|
|
48
54
|
const DISCORD_APPLICATION_COMMAND = 1;
|
|
49
55
|
const DISCORD_SUBCOMMAND = 1;
|
package/lib/dsh/host-face.d.ts
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import type { ProjectListPort } from '../features/project-list.js';
|
|
20
20
|
import type { WorkspaceResolver } from '../features/project-bind.js';
|
|
21
21
|
import type { DshModelPort } from '../features/model-control.js';
|
|
22
|
+
import type { DshPermissionPort } from '../features/permission-control.js';
|
|
22
23
|
/** The workspace rows the catalog port needs (subset of WorkspaceView). */
|
|
23
24
|
export interface WorkspaceCatalogEntry {
|
|
24
25
|
workspaceId: string;
|
|
@@ -93,6 +94,18 @@ export interface DshSessionControllerFace {
|
|
|
93
94
|
}, signal: AbortSignal): AsyncIterable<unknown>;
|
|
94
95
|
/** Host-wide live state stream (queue/jobs/projection frames over one baseline). */
|
|
95
96
|
control(signal: AbortSignal): AsyncIterable<unknown>;
|
|
97
|
+
/**
|
|
98
|
+
* Resolve or resume one Session's live Agent (0.1.6 probe-verified shape
|
|
99
|
+
* `{agent} | {error}`); the agent object stays opaque — it exists only to
|
|
100
|
+
* feed `commands.execute` for the Host-native `/permission` command path.
|
|
101
|
+
*/
|
|
102
|
+
resolveAgent(sessionId: string): Promise<{
|
|
103
|
+
agent?: unknown;
|
|
104
|
+
error?: {
|
|
105
|
+
code?: string;
|
|
106
|
+
message?: string;
|
|
107
|
+
};
|
|
108
|
+
}>;
|
|
96
109
|
}
|
|
97
110
|
/**
|
|
98
111
|
* Narrow slice of the 0.1.6 `workspaceController` cordis service. The
|
|
@@ -118,6 +131,30 @@ export interface DshSessionQueryFace {
|
|
|
118
131
|
};
|
|
119
132
|
} & Partial<AsyncDisposable>>;
|
|
120
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* Narrow slice of the `commands` cordis service (dsh-commands). `execute`
|
|
136
|
+
* runs one registered slash command against an exact agent WITHOUT sending
|
|
137
|
+
* it to the model, logging the command/run + command/done journal pair; the
|
|
138
|
+
* signal is owned by the caller. Signatures 2026-09-19 real-host verified.
|
|
139
|
+
*/
|
|
140
|
+
export interface DshCommandsFace {
|
|
141
|
+
execute(agent: unknown, line: string, submittedAttachments: readonly unknown[], signal: AbortSignal): Promise<{
|
|
142
|
+
commandId: string;
|
|
143
|
+
result?: {
|
|
144
|
+
kind?: unknown;
|
|
145
|
+
text?: unknown;
|
|
146
|
+
};
|
|
147
|
+
} | undefined>;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Narrow slice of the `permissionPresets` cordis service
|
|
151
|
+
* (dsh-permission-presets): the deployment's configured preset table.
|
|
152
|
+
*/
|
|
153
|
+
export interface DshPermissionPresetsFace {
|
|
154
|
+
catalog(): Promise<{
|
|
155
|
+
options?: unknown;
|
|
156
|
+
}>;
|
|
157
|
+
}
|
|
121
158
|
/** The host-generation model catalog (`session/modelCatalog`, host-wide). */
|
|
122
159
|
export interface ModelCatalogWireShape {
|
|
123
160
|
default: ModelSelectionShape;
|
|
@@ -134,6 +171,8 @@ export interface DshHostFace {
|
|
|
134
171
|
session: DshSessionControllerFace;
|
|
135
172
|
workspace: DshWorkspaceControllerFace;
|
|
136
173
|
sessionQuery: DshSessionQueryFace;
|
|
174
|
+
commands: DshCommandsFace;
|
|
175
|
+
permissionPresets: DshPermissionPresetsFace;
|
|
137
176
|
}
|
|
138
177
|
/**
|
|
139
178
|
* Resolve the 0.1.6 controller services off the Cordis context. Throws one
|
|
@@ -416,3 +455,37 @@ export declare function selectSessionModel(dsh: DshHostFace, request: {
|
|
|
416
455
|
* reasons about.
|
|
417
456
|
*/
|
|
418
457
|
export declare function createModelPort(dsh: DshHostFace, options?: ApiProxyFaceOptions): DshModelPort;
|
|
458
|
+
/** One normalized preset row off the catalog's `options` array. */
|
|
459
|
+
export interface PermissionCatalogEntry {
|
|
460
|
+
value: string;
|
|
461
|
+
name?: string;
|
|
462
|
+
}
|
|
463
|
+
export type PermissionCatalogOutcome = {
|
|
464
|
+
outcome: 'completed';
|
|
465
|
+
entries: PermissionCatalogEntry[];
|
|
466
|
+
} | {
|
|
467
|
+
outcome: 'failed';
|
|
468
|
+
};
|
|
469
|
+
/** Read the deployment's preset table (`permissionPresets.catalog`). */
|
|
470
|
+
export declare function permissionCatalog(dsh: DshHostFace, options?: ApiProxyFaceOptions): Promise<PermissionCatalogOutcome>;
|
|
471
|
+
export type SwitchSessionPermissionOutcome = {
|
|
472
|
+
outcome: 'completed';
|
|
473
|
+
preset: string;
|
|
474
|
+
} | {
|
|
475
|
+
outcome: 'rejected';
|
|
476
|
+
reason: string;
|
|
477
|
+
} | {
|
|
478
|
+
outcome: 'unknown';
|
|
479
|
+
};
|
|
480
|
+
/**
|
|
481
|
+
* Switch the session's preset through the Host's OWN `/permission` command
|
|
482
|
+
* path (resolveAgent → commands.execute) — the same entry the web UI uses,
|
|
483
|
+
* inheriting its journal audit pair and admission checks. An execute throw
|
|
484
|
+
* is unknown (the command may have run): callers never retry blindly.
|
|
485
|
+
*/
|
|
486
|
+
export declare function switchSessionPermission(dsh: DshHostFace, request: {
|
|
487
|
+
sessionId: string;
|
|
488
|
+
preset: string;
|
|
489
|
+
}, options?: ApiProxyFaceOptions): Promise<SwitchSessionPermissionOutcome>;
|
|
490
|
+
/** The /permission surface over the controller, command, and preset services. */
|
|
491
|
+
export declare function createPermissionPort(dsh: DshHostFace, options?: ApiProxyFaceOptions): DshPermissionPort;
|
package/lib/dsh/host-face.js
CHANGED
|
@@ -24,7 +24,7 @@ import { parseWorkspaceReference } from '../policy/disclosure.js';
|
|
|
24
24
|
*/
|
|
25
25
|
export function resolveHostFace(ctx) {
|
|
26
26
|
const missing = [];
|
|
27
|
-
for (const name of ['sessionController', 'workspaceController', 'sessionQuery']) {
|
|
27
|
+
for (const name of ['sessionController', 'workspaceController', 'sessionQuery', 'commands', 'permissionPresets']) {
|
|
28
28
|
if (ctx.get(name) === undefined || ctx.get(name) === null)
|
|
29
29
|
missing.push(name);
|
|
30
30
|
}
|
|
@@ -35,6 +35,8 @@ export function resolveHostFace(ctx) {
|
|
|
35
35
|
session: ctx.get('sessionController'),
|
|
36
36
|
workspace: ctx.get('workspaceController'),
|
|
37
37
|
sessionQuery: ctx.get('sessionQuery'),
|
|
38
|
+
commands: ctx.get('commands'),
|
|
39
|
+
permissionPresets: ctx.get('permissionPresets'),
|
|
38
40
|
};
|
|
39
41
|
}
|
|
40
42
|
/** Raised when the Host did not answer within the bounded window. */
|
|
@@ -596,3 +598,124 @@ export function createModelPort(dsh, options = {}) {
|
|
|
596
598
|
selectModel: request => selectSessionModel(dsh, request, options),
|
|
597
599
|
};
|
|
598
600
|
}
|
|
601
|
+
/** Read the deployment's preset table (`permissionPresets.catalog`). */
|
|
602
|
+
export async function permissionCatalog(dsh, options = {}) {
|
|
603
|
+
const log = options.log;
|
|
604
|
+
try {
|
|
605
|
+
const catalog = await withRpcTimeout(dsh.permissionPresets.catalog(), options.timeoutMs ?? CATALOG_TIMEOUT_MS);
|
|
606
|
+
if (!isRecord(catalog) || !Array.isArray(catalog['options'])) {
|
|
607
|
+
log?.('discord_permission_catalog_malformed', {});
|
|
608
|
+
return { outcome: 'failed' };
|
|
609
|
+
}
|
|
610
|
+
const entries = [];
|
|
611
|
+
for (const row of catalog['options']) {
|
|
612
|
+
if (!isRecord(row) || typeof row['value'] !== 'string')
|
|
613
|
+
continue;
|
|
614
|
+
entries.push({
|
|
615
|
+
value: row['value'],
|
|
616
|
+
...(typeof row['name'] === 'string' ? { name: row['name'] } : {}),
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
return { outcome: 'completed', entries };
|
|
620
|
+
}
|
|
621
|
+
catch (cause) {
|
|
622
|
+
if (cause instanceof RpcTimeoutError) {
|
|
623
|
+
log?.('discord_permission_catalog_timeout', {});
|
|
624
|
+
return { outcome: 'failed' };
|
|
625
|
+
}
|
|
626
|
+
log?.('discord_permission_catalog_failed', { cause: String(cause) });
|
|
627
|
+
return { outcome: 'failed' };
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* Read the session's current preset off the `permissions` projection view
|
|
632
|
+
* (`{currentValue}`, stateVersion 2). Any read failure resolves failed —
|
|
633
|
+
* show degrades to "current unknown", never a hard failure.
|
|
634
|
+
*/
|
|
635
|
+
async function readSessionPermission(dsh, sessionId, options) {
|
|
636
|
+
const log = options.log;
|
|
637
|
+
let observation;
|
|
638
|
+
try {
|
|
639
|
+
observation = await withRpcTimeout(dsh.sessionQuery.observeSession(sessionId), options.timeoutMs ?? CATALOG_TIMEOUT_MS);
|
|
640
|
+
}
|
|
641
|
+
catch {
|
|
642
|
+
return undefined;
|
|
643
|
+
}
|
|
644
|
+
try {
|
|
645
|
+
const projections = isRecord(observation) && isRecord(observation['projections'])
|
|
646
|
+
? observation['projections']
|
|
647
|
+
: undefined;
|
|
648
|
+
const values = projections !== undefined && isRecord(projections['values'])
|
|
649
|
+
? projections['values']
|
|
650
|
+
: undefined;
|
|
651
|
+
const permission = values !== undefined ? values['permissions'] : undefined;
|
|
652
|
+
const current = isRecord(permission) ? permission['currentValue'] : undefined;
|
|
653
|
+
return typeof current === 'string' ? current : undefined;
|
|
654
|
+
}
|
|
655
|
+
finally {
|
|
656
|
+
const dispose = observation?.[Symbol.asyncDispose]
|
|
657
|
+
?? observation?.dispose;
|
|
658
|
+
if (typeof dispose === 'function') {
|
|
659
|
+
try {
|
|
660
|
+
void dispose.call(observation);
|
|
661
|
+
}
|
|
662
|
+
catch (cause) {
|
|
663
|
+
log?.('discord_session_permission_dispose_threw', { cause: String(cause) });
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* Switch the session's preset through the Host's OWN `/permission` command
|
|
670
|
+
* path (resolveAgent → commands.execute) — the same entry the web UI uses,
|
|
671
|
+
* inheriting its journal audit pair and admission checks. An execute throw
|
|
672
|
+
* is unknown (the command may have run): callers never retry blindly.
|
|
673
|
+
*/
|
|
674
|
+
export async function switchSessionPermission(dsh, request, options = {}) {
|
|
675
|
+
const log = options.log;
|
|
676
|
+
const controller = new AbortController();
|
|
677
|
+
try {
|
|
678
|
+
const resolved = await withRpcTimeout(dsh.session.resolveAgent(request.sessionId), options.timeoutMs ?? PROMPT_TIMEOUT_MS);
|
|
679
|
+
if (!isRecord(resolved) || resolved['agent'] === undefined) {
|
|
680
|
+
const message = isRecord(resolved['error']) && typeof resolved['error']['message'] === 'string'
|
|
681
|
+
? resolved['error']['message']
|
|
682
|
+
: 'session unavailable';
|
|
683
|
+
log?.('discord_permission_agent_unresolved', { sessionId: request.sessionId });
|
|
684
|
+
return { outcome: 'rejected', reason: message };
|
|
685
|
+
}
|
|
686
|
+
const agent = resolved['agent'];
|
|
687
|
+
const execution = await withRpcTimeout(dsh.commands.execute(agent, `/permission ${request.preset}`, [], controller.signal), options.timeoutMs ?? PROMPT_TIMEOUT_MS);
|
|
688
|
+
if (execution === undefined) {
|
|
689
|
+
// Unmatched command: this Host has no /permission registered.
|
|
690
|
+
log?.('discord_permission_command_missing', { sessionId: request.sessionId });
|
|
691
|
+
return { outcome: 'rejected', reason: 'the Host has no /permission command' };
|
|
692
|
+
}
|
|
693
|
+
const result = isRecord(execution) && isRecord(execution['result']) ? execution['result'] : undefined;
|
|
694
|
+
const kind = result !== undefined ? result['kind'] : undefined;
|
|
695
|
+
const text = result !== undefined && typeof result['text'] === 'string' ? result['text'] : '';
|
|
696
|
+
if (kind === 'success') {
|
|
697
|
+
return { outcome: 'completed', preset: request.preset };
|
|
698
|
+
}
|
|
699
|
+
log?.('discord_permission_command_error', { sessionId: request.sessionId, text });
|
|
700
|
+
return { outcome: 'rejected', reason: text === '' ? 'the Host rejected the switch' : text };
|
|
701
|
+
}
|
|
702
|
+
catch (cause) {
|
|
703
|
+
if (cause instanceof RpcTimeoutError) {
|
|
704
|
+
log?.('discord_permission_switch_timeout', { sessionId: request.sessionId });
|
|
705
|
+
return { outcome: 'unknown' };
|
|
706
|
+
}
|
|
707
|
+
log?.('discord_permission_switch_threw', { sessionId: request.sessionId, cause: String(cause) });
|
|
708
|
+
return { outcome: 'unknown' };
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
/** The /permission surface over the controller, command, and preset services. */
|
|
712
|
+
export function createPermissionPort(dsh, options = {}) {
|
|
713
|
+
return {
|
|
714
|
+
catalog: () => permissionCatalog(dsh, options),
|
|
715
|
+
current: async (sessionId) => {
|
|
716
|
+
const preset = await readSessionPermission(dsh, sessionId, options);
|
|
717
|
+
return preset === undefined ? { outcome: 'failed' } : { outcome: 'completed', preset };
|
|
718
|
+
},
|
|
719
|
+
set: (sessionId, preset) => switchSessionPermission(dsh, { sessionId, preset }, options),
|
|
720
|
+
};
|
|
721
|
+
}
|
|
@@ -15,6 +15,7 @@ import type { CancelOutcome, PromptOutcome, QueueRemoveOutcome, WorkspaceDetailO
|
|
|
15
15
|
import type { TurnTracker } from './turn-ownership.js';
|
|
16
16
|
import { type DshApprovalRespondPort } from './approval-routing.js';
|
|
17
17
|
import { type DshModelPort } from './model-control.js';
|
|
18
|
+
import { type DshPermissionPort } from './permission-control.js';
|
|
18
19
|
import type { QuestionInteractionOutcome } from './question-routing.js';
|
|
19
20
|
import type { ApprovalStore } from './approval-store.js';
|
|
20
21
|
import type { ChannelBinding } from '../state/records.js';
|
|
@@ -132,6 +133,13 @@ export interface InteractionRouterDeps {
|
|
|
132
133
|
* deployments flip this so any authorized member can switch (16.42).
|
|
133
134
|
*/
|
|
134
135
|
modelSelectOperatorOnly: () => boolean;
|
|
136
|
+
/**
|
|
137
|
+
* Whether /permission set stays Host-operator-only (default true — the
|
|
138
|
+
* danger-full-access preset strips sandbox AND approval; 16.60).
|
|
139
|
+
*/
|
|
140
|
+
permissionSelectOperatorOnly: () => boolean;
|
|
141
|
+
/** The live permission-preset catalog + guarded switch (/permission surface). */
|
|
142
|
+
permission: DshPermissionPort;
|
|
135
143
|
log: (event: string, detail?: unknown) => void;
|
|
136
144
|
warn: (event: string, detail?: unknown) => void;
|
|
137
145
|
}
|
|
@@ -14,6 +14,8 @@ import { planSteer } from './steer-control.js';
|
|
|
14
14
|
import { planStop } from './stop-control.js';
|
|
15
15
|
import { handleApprovalClick } from './approval-routing.js';
|
|
16
16
|
import { applyModelSelection } from './model-control.js';
|
|
17
|
+
import { applyPermissionPreset, requiresConfirmation, showPermission, } from './permission-control.js';
|
|
18
|
+
import { permissionPresetLabel } from '../i18n.js';
|
|
17
19
|
/** Discord interactive-component id for string select menus. */
|
|
18
20
|
const SELECT_MENU = 3;
|
|
19
21
|
/** `provider/model` — or `provider/model (effort)` when an effort is set. */
|
|
@@ -40,7 +42,32 @@ function renderModelApply(copy, result) {
|
|
|
40
42
|
: copy.modelNotInCatalog;
|
|
41
43
|
}
|
|
42
44
|
}
|
|
45
|
+
/** The ephemeral outcome line for a /permission switch attempt. */
|
|
46
|
+
function renderPermissionApply(copy, result) {
|
|
47
|
+
switch (result.outcome) {
|
|
48
|
+
case 'applied': return copy.permissionApplied(permissionPresetLabel(result.preset, copy));
|
|
49
|
+
case 'rejected': return copy.permissionRejected(result.reason);
|
|
50
|
+
case 'unknown': return copy.permissionUnknown;
|
|
51
|
+
case 'refused':
|
|
52
|
+
return result.reason === 'not-host-operator' ? copy.permissionSelectOperatorOnly : copy.permissionNotInCatalog;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** Buttons live while the registry entry does; entries sweep past one extra TTL. */
|
|
56
|
+
const PERMISSION_PROMPT_SWEEP_MS = 20 * 60 * 1000;
|
|
43
57
|
export function createInteractionRouter(deps) {
|
|
58
|
+
/**
|
|
59
|
+
* Posted /permission confirmation prompts by custom_id. In-memory only —
|
|
60
|
+
* a restart forgets both halves together (the registry is memory too),
|
|
61
|
+
* so a resolved-but-unretired prompt is impossible to click anyway.
|
|
62
|
+
*/
|
|
63
|
+
const permissionPrompts = new Map();
|
|
64
|
+
/** Drop prompt records past their sweep window (never-clicked prompts). */
|
|
65
|
+
function sweepPermissionPrompts(nowMs) {
|
|
66
|
+
for (const [customId, record] of permissionPrompts) {
|
|
67
|
+
if (nowMs - record.createdAtMs > PERMISSION_PROMPT_SWEEP_MS)
|
|
68
|
+
permissionPrompts.delete(customId);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
44
71
|
/** Authorize one interaction against the live policy table. */
|
|
45
72
|
function authorize(event) {
|
|
46
73
|
return evaluateAuthorization(deps.policy(), {
|
|
@@ -52,7 +79,7 @@ export function createInteractionRouter(deps) {
|
|
|
52
79
|
});
|
|
53
80
|
}
|
|
54
81
|
async function routeAutocomplete(event, interactionToken) {
|
|
55
|
-
if (event.commandName !== 'project' && event.commandName !== 'session')
|
|
82
|
+
if (event.commandName !== 'project' && event.commandName !== 'session' && event.commandName !== 'permission')
|
|
56
83
|
return;
|
|
57
84
|
// Autocomplete has no deferred-ack form: whatever happens below, Discord
|
|
58
85
|
// must receive exactly one type-8 answer, even if only empty choices.
|
|
@@ -98,6 +125,27 @@ export function createInteractionRouter(deps) {
|
|
|
98
125
|
deps.warn('discord_autocomplete_catalog_failed', String(cause));
|
|
99
126
|
}
|
|
100
127
|
}
|
|
128
|
+
else if (decision.allowed && event.commandName === 'permission') {
|
|
129
|
+
// /permission set: live catalog entries, labeled in the current
|
|
130
|
+
// language; the raw preset key is the value (custom deployments
|
|
131
|
+
// define their own tables, so nothing is hardcoded).
|
|
132
|
+
try {
|
|
133
|
+
const catalog = await deps.permission.catalog();
|
|
134
|
+
if (catalog.outcome === 'completed') {
|
|
135
|
+
const lowered = query.trim().toLowerCase();
|
|
136
|
+
choices.push(...catalog.entries
|
|
137
|
+
.filter(entry => entry.value !== lowered)
|
|
138
|
+
.filter(entry => lowered === ''
|
|
139
|
+
|| entry.value.toLowerCase().includes(lowered)
|
|
140
|
+
|| permissionPresetLabel(entry.value, deps.copy).toLowerCase().includes(lowered))
|
|
141
|
+
.slice(0, 25)
|
|
142
|
+
.map(entry => ({ name: permissionPresetLabel(entry.value, deps.copy), value: entry.value })));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
catch (cause) {
|
|
146
|
+
deps.warn('discord_autocomplete_permission_failed', String(cause));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
101
149
|
const rest = await deps.rest();
|
|
102
150
|
if (rest === undefined) {
|
|
103
151
|
deps.warn('discord_autocomplete_failed', 'missing-token');
|
|
@@ -135,7 +183,8 @@ export function createInteractionRouter(deps) {
|
|
|
135
183
|
}
|
|
136
184
|
// Deferred-ack followups must never fail silently: the REST client
|
|
137
185
|
// resolves (never rejects) 4xx outcomes, so a void'ed call would drop
|
|
138
|
-
// the failure without a trace.
|
|
186
|
+
// the failure without a trace. Returns the created message id so
|
|
187
|
+
// callers can retire controls on the posted message later.
|
|
139
188
|
const followUp = async (content, components) => {
|
|
140
189
|
const posted = await rest.request('POST', `/webhooks/${deps.applicationId()}/${interactionToken}`, {
|
|
141
190
|
content,
|
|
@@ -144,7 +193,10 @@ export function createInteractionRouter(deps) {
|
|
|
144
193
|
});
|
|
145
194
|
if (posted.outcome !== 'completed') {
|
|
146
195
|
deps.log('discord_followup_failed', posted.outcome === 'rejected' ? `HTTP ${String(posted.status)}` : posted.reason);
|
|
196
|
+
return undefined;
|
|
147
197
|
}
|
|
198
|
+
const messageId = posted.body?.['id'];
|
|
199
|
+
return typeof messageId === 'string' ? messageId : undefined;
|
|
148
200
|
};
|
|
149
201
|
const buttonRow = (confirmId, cancelId) => [{
|
|
150
202
|
type: 1,
|
|
@@ -505,6 +557,94 @@ export function createInteractionRouter(deps) {
|
|
|
505
557
|
await followUp(content);
|
|
506
558
|
return;
|
|
507
559
|
}
|
|
560
|
+
if (event.commandName === 'permission') {
|
|
561
|
+
// /permission show|set (16.59–16.61): the bound Session's preset —
|
|
562
|
+
// the Host bundle of sandbox mode + approval policy. The switch
|
|
563
|
+
// rides the Host's own /permission command path.
|
|
564
|
+
const options = event.data['options'];
|
|
565
|
+
const subcommand = Array.isArray(options) ? options[0] : undefined;
|
|
566
|
+
const subName = subcommand?.name;
|
|
567
|
+
const sessionId = deps.sessionForThread(event.guildId, event.channelId);
|
|
568
|
+
if (sessionId === undefined) {
|
|
569
|
+
await followUp(deps.copy.permissionNeedsThread);
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
if (subName === 'show') {
|
|
573
|
+
const view = await showPermission(deps.permission, { sessionId });
|
|
574
|
+
if (view.outcome === 'failed') {
|
|
575
|
+
await followUp(deps.copy.permissionShowUnavailable);
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
const current = view.current === undefined
|
|
579
|
+
? deps.copy.permissionShowUnknownCurrent
|
|
580
|
+
: permissionPresetLabel(view.current, deps.copy);
|
|
581
|
+
const entries = view.entries.map(preset => permissionPresetLabel(preset, deps.copy)).join(' · ');
|
|
582
|
+
await followUp(deps.copy.permissionShowHeader(current, entries));
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
if (subName === 'set') {
|
|
586
|
+
// Host-operator authority by default — danger-full-access strips
|
|
587
|
+
// sandbox AND approval at once (16.60); deployments may loosen.
|
|
588
|
+
const requireOperator = deps.permissionSelectOperatorOnly();
|
|
589
|
+
const decision = authorize(event);
|
|
590
|
+
if (!decision.allowed || (requireOperator && decision.level !== 'host-operator')) {
|
|
591
|
+
await followUp(deps.copy.permissionSelectOperatorOnly);
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
const wireOptions = Array.isArray(subcommand?.options) ? subcommand.options : [];
|
|
595
|
+
const typedPreset = wireOptions.find(option => option.name === 'preset')?.value;
|
|
596
|
+
if (typeof typedPreset !== 'string' || typedPreset === '') {
|
|
597
|
+
await followUp(deps.copy.permissionNotInCatalog);
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
if (requiresConfirmation(typedPreset)) {
|
|
601
|
+
// The dangerous preset(s) go through an explicit risk
|
|
602
|
+
// confirmation — the web UI shows the same gate (16.61).
|
|
603
|
+
const nowMs = Date.now();
|
|
604
|
+
const expiresAtMs = nowMs + 15 * 60 * 1000;
|
|
605
|
+
const base = { kind: 'permission-confirm', sessionId, preset: typedPreset, guildId: event.guildId, actorId: event.actorId, expiresAtMs };
|
|
606
|
+
const confirmId = deps.registry.register({ ...base, action: 'confirm' });
|
|
607
|
+
const cancelId = deps.registry.register({ ...base, action: 'cancel' });
|
|
608
|
+
const label = permissionPresetLabel(typedPreset, deps.copy);
|
|
609
|
+
const content = `${deps.copy.permissionConfirmHeader(label)}\n${deps.copy.permissionConfirmBody}`;
|
|
610
|
+
const promptMessageId = await followUp(content, [{
|
|
611
|
+
type: 1,
|
|
612
|
+
components: [
|
|
613
|
+
{ type: 2, style: 3, label: deps.copy.permissionConfirmButton, custom_id: confirmId },
|
|
614
|
+
{ type: 2, style: 4, label: deps.copy.permissionConfirmCancelButton, custom_id: cancelId },
|
|
615
|
+
],
|
|
616
|
+
}]);
|
|
617
|
+
// Remember the posted prompt so a settle can gray the buttons
|
|
618
|
+
// out — an unsettled-looking live control invites second clicks.
|
|
619
|
+
sweepPermissionPrompts(nowMs);
|
|
620
|
+
if (promptMessageId !== undefined) {
|
|
621
|
+
const record = {
|
|
622
|
+
messageId: promptMessageId,
|
|
623
|
+
interactionToken,
|
|
624
|
+
content,
|
|
625
|
+
confirmCustomId: confirmId,
|
|
626
|
+
cancelCustomId: cancelId,
|
|
627
|
+
confirmLabel: deps.copy.permissionConfirmButton,
|
|
628
|
+
cancelLabel: deps.copy.permissionConfirmCancelButton,
|
|
629
|
+
createdAtMs: nowMs,
|
|
630
|
+
};
|
|
631
|
+
permissionPrompts.set(confirmId, record);
|
|
632
|
+
permissionPrompts.set(cancelId, record);
|
|
633
|
+
}
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
const result = await applyPermissionPreset(deps.permission, {
|
|
637
|
+
decision,
|
|
638
|
+
sessionId,
|
|
639
|
+
preset: typedPreset,
|
|
640
|
+
requireHostOperator: requireOperator,
|
|
641
|
+
});
|
|
642
|
+
await followUp(renderPermissionApply(deps.copy, result));
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
await followUp(deps.copy.unknownSubcommand);
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
508
648
|
if (event.commandName === 'session') {
|
|
509
649
|
// /session resume: cold-adopt an existing DSH session into a new
|
|
510
650
|
// thread of the bound project channel (16.44). /session new is
|
|
@@ -664,6 +804,92 @@ export function createInteractionRouter(deps) {
|
|
|
664
804
|
if (posted.outcome !== 'completed')
|
|
665
805
|
deps.log('discord_followup_failed', posted.outcome);
|
|
666
806
|
}
|
|
807
|
+
/**
|
|
808
|
+
* The danger-full-access risk confirmation (16.61): ack, then re-authorize
|
|
809
|
+
* the clicker (deny-first — the initiating command's grant proves nothing
|
|
810
|
+
* at click time), settle as confirm-then-switch or cancel.
|
|
811
|
+
*/
|
|
812
|
+
async function routePermissionConfirmComponent(event, interactionToken) {
|
|
813
|
+
const customId = event.data['custom_id'];
|
|
814
|
+
if (typeof customId !== 'string')
|
|
815
|
+
return;
|
|
816
|
+
const resolved = deps.registry.resolve(customId, Date.now());
|
|
817
|
+
const context = resolved.found ? resolved.context : undefined;
|
|
818
|
+
if (context?.['kind'] !== 'permission-confirm')
|
|
819
|
+
return;
|
|
820
|
+
const rest = await deps.rest();
|
|
821
|
+
if (rest === undefined) {
|
|
822
|
+
deps.log('discord_ack_failed', 'missing-token');
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
const clicked = await rest.request('POST', `/interactions/${event.interactionId}/${interactionToken}/callback`, { type: 6 });
|
|
826
|
+
if (clicked.outcome !== 'completed') {
|
|
827
|
+
deps.log('discord_ack_failed', clicked.outcome);
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
const owner = context['actorId'];
|
|
831
|
+
const sessionId = context['sessionId'];
|
|
832
|
+
const preset = context['preset'];
|
|
833
|
+
if (owner !== event.actorId || typeof sessionId !== 'string' || typeof preset !== 'string') {
|
|
834
|
+
const denied = await rest.request('POST', `/webhooks/${deps.applicationId()}/${interactionToken}`, { content: deps.copy.confirmNotOwner, flags: OUTBOUND_EPHEMERAL_FLAGS });
|
|
835
|
+
if (denied.outcome !== 'completed')
|
|
836
|
+
deps.log('discord_followup_failed', denied.outcome);
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
const respond = async (content) => {
|
|
840
|
+
const posted = await rest.request('POST', `/webhooks/${deps.applicationId()}/${interactionToken}`, { content, flags: OUTBOUND_EPHEMERAL_FLAGS });
|
|
841
|
+
if (posted.outcome !== 'completed')
|
|
842
|
+
deps.log('discord_followup_failed', posted.outcome);
|
|
843
|
+
};
|
|
844
|
+
/**
|
|
845
|
+
* Retire the posted prompt: both buttons gray out so the settled
|
|
846
|
+
* control never looks live again. The prompt was posted by the SLASH
|
|
847
|
+
* interaction's webhook, so only that token can edit it; a late click
|
|
848
|
+
* past the webhook token's lifetime fails the edit loudly but the
|
|
849
|
+
* outcome followup still reaches the clicker (the registry TTL already
|
|
850
|
+
* bounds double-clicks well inside the window).
|
|
851
|
+
*/
|
|
852
|
+
const retirePrompt = async () => {
|
|
853
|
+
const record = permissionPrompts.get(customId);
|
|
854
|
+
permissionPrompts.delete(record?.confirmCustomId ?? customId);
|
|
855
|
+
permissionPrompts.delete(record?.cancelCustomId ?? customId);
|
|
856
|
+
if (record === undefined)
|
|
857
|
+
return;
|
|
858
|
+
const patched = await rest.request('PATCH', `/webhooks/${deps.applicationId()}/${record.interactionToken}/messages/${record.messageId}`, {
|
|
859
|
+
content: record.content,
|
|
860
|
+
flags: OUTBOUND_EPHEMERAL_FLAGS,
|
|
861
|
+
components: [{
|
|
862
|
+
type: 1,
|
|
863
|
+
components: [
|
|
864
|
+
{ type: 2, style: 3, label: record.confirmLabel, custom_id: record.confirmCustomId, disabled: true },
|
|
865
|
+
{ type: 2, style: 4, label: record.cancelLabel, custom_id: record.cancelCustomId, disabled: true },
|
|
866
|
+
],
|
|
867
|
+
}],
|
|
868
|
+
});
|
|
869
|
+
if (patched.outcome !== 'completed') {
|
|
870
|
+
deps.warn('discord_permission_prompt_retire_failed', patched.outcome === 'rejected' ? `HTTP ${String(patched.status)}` : patched.reason);
|
|
871
|
+
}
|
|
872
|
+
};
|
|
873
|
+
if (context['action'] !== 'confirm') {
|
|
874
|
+
await retirePrompt();
|
|
875
|
+
await respond(deps.copy.permissionCancelled);
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
// Re-run the full authorization + catalog validation at click time; the
|
|
879
|
+
// settings flag may have flipped since the prompt was posted.
|
|
880
|
+
const decision = authorize(event);
|
|
881
|
+
await retirePrompt();
|
|
882
|
+
const result = await applyPermissionPreset(deps.permission, {
|
|
883
|
+
decision,
|
|
884
|
+
sessionId,
|
|
885
|
+
preset,
|
|
886
|
+
requireHostOperator: deps.permissionSelectOperatorOnly(),
|
|
887
|
+
});
|
|
888
|
+
deps.log('discord_permission_confirm', { outcome: result.outcome, preset });
|
|
889
|
+
await respond(result.outcome === 'applied'
|
|
890
|
+
? `${deps.copy.permissionConfirmed(permissionPresetLabel(preset, deps.copy))}\n${renderPermissionApply(deps.copy, result)}`
|
|
891
|
+
: renderPermissionApply(deps.copy, result));
|
|
892
|
+
}
|
|
667
893
|
/** One cascade stage: ack, re-authorize, then advance or apply (16.35). */
|
|
668
894
|
async function routeModelComponent(event, interactionToken) {
|
|
669
895
|
const customId = event.data['custom_id'];
|
|
@@ -900,6 +1126,10 @@ export function createInteractionRouter(deps) {
|
|
|
900
1126
|
await routeModelComponent(event, interactionToken);
|
|
901
1127
|
return;
|
|
902
1128
|
}
|
|
1129
|
+
if (bindContext?.['kind'] === 'permission-confirm') {
|
|
1130
|
+
await routePermissionConfirmComponent(event, interactionToken);
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
903
1133
|
// A not-found custom_id means the control was never registered or has
|
|
904
1134
|
// been retired by its TTL (approval/question registry entries stay
|
|
905
1135
|
// resolvable until expiry; claimed-vs-resolved state lives in the
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/permission show` and the guarded `/permission set` (design.md §7, tasks
|
|
3
|
+
* 16.59–16.61). A permission preset is the Host's bundle of sandbox mode +
|
|
4
|
+
* approval policy; the switch goes through the Host's own `/permission`
|
|
5
|
+
* command path (the same entry the web UI uses), so the journal records the
|
|
6
|
+
* command/run + command/done audit pair and the Host runs its own admission
|
|
7
|
+
* (unknown preset → error text). The adapter validates against the live
|
|
8
|
+
* catalog first — deployments configure their own preset tables.
|
|
9
|
+
*
|
|
10
|
+
* The default gate is stricter than /model's: `danger-full-access` disables
|
|
11
|
+
* sandbox AND approval at once, so only the explicit Host-operator allowlist
|
|
12
|
+
* may switch (`permissionSelectOperatorOnly: true`); single-user deployments
|
|
13
|
+
* may loosen it, and a member-level decision still never authorizes.
|
|
14
|
+
*/
|
|
15
|
+
import type { AccessDecision } from '../policy/authorization.js';
|
|
16
|
+
export interface DshPermissionPort {
|
|
17
|
+
/** The deployment's live preset catalog (read from `permissionPresets`). */
|
|
18
|
+
catalog(): Promise<{
|
|
19
|
+
outcome: 'completed';
|
|
20
|
+
entries: ReadonlyArray<{
|
|
21
|
+
value: string;
|
|
22
|
+
name?: string;
|
|
23
|
+
}>;
|
|
24
|
+
} | {
|
|
25
|
+
outcome: 'failed';
|
|
26
|
+
} | {
|
|
27
|
+
outcome: 'unknown';
|
|
28
|
+
}>;
|
|
29
|
+
/** The session's current preset off the `permissions` projection view. */
|
|
30
|
+
current(sessionId: string): Promise<{
|
|
31
|
+
outcome: 'completed';
|
|
32
|
+
preset: string;
|
|
33
|
+
} | {
|
|
34
|
+
outcome: 'failed';
|
|
35
|
+
} | {
|
|
36
|
+
outcome: 'unknown';
|
|
37
|
+
}>;
|
|
38
|
+
/** Switch through the Host `/permission` command path. */
|
|
39
|
+
set(sessionId: string, preset: string): Promise<{
|
|
40
|
+
outcome: 'completed';
|
|
41
|
+
preset: string;
|
|
42
|
+
} | {
|
|
43
|
+
outcome: 'rejected';
|
|
44
|
+
reason: string;
|
|
45
|
+
} | {
|
|
46
|
+
outcome: 'unknown';
|
|
47
|
+
}>;
|
|
48
|
+
}
|
|
49
|
+
export type PermissionShowResult = {
|
|
50
|
+
outcome: 'ok';
|
|
51
|
+
current: string | undefined;
|
|
52
|
+
entries: ReadonlyArray<string>;
|
|
53
|
+
} | {
|
|
54
|
+
outcome: 'failed';
|
|
55
|
+
};
|
|
56
|
+
/** `/permission show`: the current preset plus the switchable catalog. */
|
|
57
|
+
export declare function showPermission(port: DshPermissionPort, request: {
|
|
58
|
+
sessionId: string;
|
|
59
|
+
}): Promise<PermissionShowResult>;
|
|
60
|
+
export type PermissionApplyResult = {
|
|
61
|
+
outcome: 'applied';
|
|
62
|
+
preset: string;
|
|
63
|
+
} | {
|
|
64
|
+
outcome: 'rejected';
|
|
65
|
+
reason: string;
|
|
66
|
+
} | {
|
|
67
|
+
outcome: 'unknown';
|
|
68
|
+
} | {
|
|
69
|
+
outcome: 'refused';
|
|
70
|
+
reason: 'not-host-operator' | 'preset-not-in-catalog';
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* The presets that strip BOTH confinement layers at once; switching to one
|
|
74
|
+
* requires an explicit risk confirmation (the web UI shows the same gate).
|
|
75
|
+
*/
|
|
76
|
+
export declare const CONFIRM_REQUIRED_PRESETS: readonly string[];
|
|
77
|
+
export declare function requiresConfirmation(preset: string): boolean;
|
|
78
|
+
export declare function applyPermissionPreset(port: DshPermissionPort, request: {
|
|
79
|
+
decision: AccessDecision;
|
|
80
|
+
sessionId: string;
|
|
81
|
+
preset: string;
|
|
82
|
+
/**
|
|
83
|
+
* Keep the Host-operator restriction (default, stricter than /model).
|
|
84
|
+
* Single-user deployments may drop it so administrators of the
|
|
85
|
+
* allowlisted guild can switch (16.60); members are denied either way.
|
|
86
|
+
*/
|
|
87
|
+
requireHostOperator?: boolean | undefined;
|
|
88
|
+
}): Promise<PermissionApplyResult>;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/permission show` and the guarded `/permission set` (design.md §7, tasks
|
|
3
|
+
* 16.59–16.61). A permission preset is the Host's bundle of sandbox mode +
|
|
4
|
+
* approval policy; the switch goes through the Host's own `/permission`
|
|
5
|
+
* command path (the same entry the web UI uses), so the journal records the
|
|
6
|
+
* command/run + command/done audit pair and the Host runs its own admission
|
|
7
|
+
* (unknown preset → error text). The adapter validates against the live
|
|
8
|
+
* catalog first — deployments configure their own preset tables.
|
|
9
|
+
*
|
|
10
|
+
* The default gate is stricter than /model's: `danger-full-access` disables
|
|
11
|
+
* sandbox AND approval at once, so only the explicit Host-operator allowlist
|
|
12
|
+
* may switch (`permissionSelectOperatorOnly: true`); single-user deployments
|
|
13
|
+
* may loosen it, and a member-level decision still never authorizes.
|
|
14
|
+
*/
|
|
15
|
+
/** `/permission show`: the current preset plus the switchable catalog. */
|
|
16
|
+
export async function showPermission(port, request) {
|
|
17
|
+
const [catalog, current] = await Promise.all([port.catalog(), port.current(request.sessionId)]);
|
|
18
|
+
if (catalog.outcome !== 'completed')
|
|
19
|
+
return { outcome: 'failed' };
|
|
20
|
+
return {
|
|
21
|
+
outcome: 'ok',
|
|
22
|
+
...(current.outcome === 'completed' ? { current: current.preset } : { current: undefined }),
|
|
23
|
+
entries: catalog.entries.map(entry => entry.value),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The presets that strip BOTH confinement layers at once; switching to one
|
|
28
|
+
* requires an explicit risk confirmation (the web UI shows the same gate).
|
|
29
|
+
*/
|
|
30
|
+
export const CONFIRM_REQUIRED_PRESETS = ['danger-full-access'];
|
|
31
|
+
export function requiresConfirmation(preset) {
|
|
32
|
+
return CONFIRM_REQUIRED_PRESETS.includes(preset);
|
|
33
|
+
}
|
|
34
|
+
export async function applyPermissionPreset(port, request) {
|
|
35
|
+
const requireOperator = request.requireHostOperator ?? true;
|
|
36
|
+
if (requireOperator) {
|
|
37
|
+
// Host-operator authority ONLY: a Guild administrator is not enough —
|
|
38
|
+
// the switch can strip sandbox and approval from the Session.
|
|
39
|
+
if (!request.decision.allowed || request.decision.level !== 'host-operator') {
|
|
40
|
+
return { outcome: 'refused', reason: 'not-host-operator' };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (!request.decision.allowed) {
|
|
44
|
+
return { outcome: 'refused', reason: 'not-host-operator' };
|
|
45
|
+
}
|
|
46
|
+
const catalog = await port.catalog();
|
|
47
|
+
if (catalog.outcome !== 'completed') {
|
|
48
|
+
return { outcome: 'refused', reason: 'preset-not-in-catalog' };
|
|
49
|
+
}
|
|
50
|
+
if (!catalog.entries.some(entry => entry.value === request.preset)) {
|
|
51
|
+
return { outcome: 'refused', reason: 'preset-not-in-catalog' };
|
|
52
|
+
}
|
|
53
|
+
const applied = await port.set(request.sessionId, request.preset);
|
|
54
|
+
if (applied.outcome === 'completed')
|
|
55
|
+
return { outcome: 'applied', preset: applied.preset };
|
|
56
|
+
if (applied.outcome === 'rejected')
|
|
57
|
+
return { outcome: 'rejected', reason: applied.reason };
|
|
58
|
+
return { outcome: 'unknown' };
|
|
59
|
+
}
|
package/lib/i18n.d.ts
CHANGED
|
@@ -118,6 +118,26 @@ declare const zh: {
|
|
|
118
118
|
modelNotInCatalog: string;
|
|
119
119
|
modelInvalidReasoning: string;
|
|
120
120
|
modelTypedParseFailed: string;
|
|
121
|
+
permissionNeedsThread: string;
|
|
122
|
+
permissionShowUnavailable: string;
|
|
123
|
+
permissionShowHeader: (current: string, entries: string) => string;
|
|
124
|
+
permissionShowUnknownCurrent: string;
|
|
125
|
+
permissionPresetReadOnly: string;
|
|
126
|
+
permissionPresetWorkspaceWrite: string;
|
|
127
|
+
permissionPresetFullAccess: string;
|
|
128
|
+
permissionApplied: (label: string) => string;
|
|
129
|
+
permissionRejected: (reason: string) => string;
|
|
130
|
+
permissionUnknown: string;
|
|
131
|
+
permissionSelectOperatorOnly: string;
|
|
132
|
+
permissionNotInCatalog: string;
|
|
133
|
+
permissionConfirmHeader: (label: string) => string;
|
|
134
|
+
permissionConfirmBody: string;
|
|
135
|
+
permissionConfirmButton: string;
|
|
136
|
+
permissionConfirmCancelButton: string;
|
|
137
|
+
permissionConfirmed: (label: string) => string;
|
|
138
|
+
permissionCancelled: string;
|
|
139
|
+
permissionConfirmExpired: string;
|
|
140
|
+
permissionSystemLine: (label: string) => string;
|
|
121
141
|
sessionResumeNeedsBoundChannel: string;
|
|
122
142
|
sessionResumeStarted: (threadId: string) => string;
|
|
123
143
|
sessionResumeAlreadyBound: (threadId: string) => string;
|
|
@@ -136,4 +156,10 @@ declare const zh: {
|
|
|
136
156
|
export type CopyTable = typeof zh;
|
|
137
157
|
/** Discord-visible copy for the configured language. */
|
|
138
158
|
export declare function createCopy(language: Language): CopyTable;
|
|
159
|
+
/**
|
|
160
|
+
* Localize one permission-preset key for display. The dsh-base canonical
|
|
161
|
+
* three map to Discord copy; deployment-custom keys fall back to the raw
|
|
162
|
+
* host key (never invented labels).
|
|
163
|
+
*/
|
|
164
|
+
export declare function permissionPresetLabel(preset: string, copy: CopyTable): string;
|
|
139
165
|
export {};
|
package/lib/i18n.js
CHANGED
|
@@ -129,6 +129,27 @@ const zh = {
|
|
|
129
129
|
modelNotInCatalog: '⚠️ 该 provider/模型不在当前会话的目录中。',
|
|
130
130
|
modelInvalidReasoning: '⚠️ 该推理强度对此模型无效。',
|
|
131
131
|
modelTypedParseFailed: '⚠️ 模型需按 `provider/model` 格式填写,或留空进入交互式选择。',
|
|
132
|
+
// ── /permission show / set(16.59–16.61)─────────────────────────────
|
|
133
|
+
permissionNeedsThread: '⚠️ /permission 需要在已绑定 Session 的任务线程中使用(先在项目频道 @ 机器人)。',
|
|
134
|
+
permissionShowUnavailable: '⚠️ 权限目录暂时不可用,请稍后重试。',
|
|
135
|
+
permissionShowHeader: (current, entries) => `**当前权限模式:** ${current}\n**可切换:** ${entries}`,
|
|
136
|
+
permissionShowUnknownCurrent: '未知(投影暂不可读)',
|
|
137
|
+
permissionPresetReadOnly: '仅可查看',
|
|
138
|
+
permissionPresetWorkspaceWrite: '工作区内修改',
|
|
139
|
+
permissionPresetFullAccess: '完全权限',
|
|
140
|
+
permissionApplied: (label) => `✅ 权限模式已切换:${label}(沙箱与审批策略随预设生效)。`,
|
|
141
|
+
permissionRejected: (reason) => `⚠️ DSH 拒绝了此次切换:${reason}`,
|
|
142
|
+
permissionUnknown: '⚠️ 切换结果未知(命令可能已执行),请用 /permission show 确认。',
|
|
143
|
+
permissionSelectOperatorOnly: '⚠️ 只有 Host 操作员可以切换权限模式(完全权限会同时解除沙箱与审批)。',
|
|
144
|
+
permissionNotInCatalog: '⚠️ 该预设不在当前部署的权限目录中。',
|
|
145
|
+
permissionConfirmHeader: (label) => `⚠️ 确认启用「${label}」?`,
|
|
146
|
+
permissionConfirmBody: '启用后本会话将减少确认步骤,可直接执行敏感操作、文件修改或外部命令。仅建议在你信任后续任务时使用。',
|
|
147
|
+
permissionConfirmButton: '我已了解风险,启用',
|
|
148
|
+
permissionConfirmCancelButton: '取消',
|
|
149
|
+
permissionConfirmed: (label) => `✅ 已确认,正在切换到「${label}」……`,
|
|
150
|
+
permissionCancelled: '已取消,权限模式未变更。',
|
|
151
|
+
permissionConfirmExpired: '⚠️ 该确认已过期,请重新运行 /permission set。',
|
|
152
|
+
permissionSystemLine: (label) => `🔧 权限模式 → ${label}`,
|
|
132
153
|
// ── /session resume ──────────────────────────────────────────────────
|
|
133
154
|
sessionResumeNeedsBoundChannel: '⚠️ /session resume 需要在已绑定工作区的项目频道中使用。',
|
|
134
155
|
sessionResumeStarted: (threadId) => `✅ 会话已恢复到 <#${threadId}>——历史在 Web 界面查看,线程内直接续聊。`,
|
|
@@ -261,6 +282,27 @@ const en = {
|
|
|
261
282
|
modelNotInCatalog: "⚠️ That provider/model is not in this session's catalog.",
|
|
262
283
|
modelInvalidReasoning: '⚠️ That reasoning effort is not valid for this model.',
|
|
263
284
|
modelTypedParseFailed: '⚠️ The model must be `provider/model`, or left empty for the interactive cascade.',
|
|
285
|
+
// ── /permission show / set (16.59–16.61) ─────────────────────────────
|
|
286
|
+
permissionNeedsThread: '⚠️ /permission needs a task Thread bound to a Session (mention the bot in a project channel first).',
|
|
287
|
+
permissionShowUnavailable: '⚠️ The permission catalog is temporarily unavailable; try again shortly.',
|
|
288
|
+
permissionShowHeader: (current, entries) => `**Current permission mode:** ${current}\n**Switchable:** ${entries}`,
|
|
289
|
+
permissionShowUnknownCurrent: 'unknown (projection unreadable)',
|
|
290
|
+
permissionPresetReadOnly: 'View only',
|
|
291
|
+
permissionPresetWorkspaceWrite: 'Workspace edit',
|
|
292
|
+
permissionPresetFullAccess: 'Full access',
|
|
293
|
+
permissionApplied: (label) => `✅ Permission mode switched: ${label} (sandbox and approval follow the preset).`,
|
|
294
|
+
permissionRejected: (reason) => `⚠️ DSH rejected this switch: ${reason}`,
|
|
295
|
+
permissionUnknown: '⚠️ The switch outcome is unknown (the command may have run); confirm with /permission show.',
|
|
296
|
+
permissionSelectOperatorOnly: '⚠️ Only Host operators can switch the permission mode (full access disables both sandbox and approval).',
|
|
297
|
+
permissionNotInCatalog: "⚠️ That preset is not in this deployment's permission catalog.",
|
|
298
|
+
permissionConfirmHeader: (label) => `⚠️ Enable "${label}"?`,
|
|
299
|
+
permissionConfirmBody: 'After enabling, this Session will require fewer confirmations and can directly perform sensitive operations, file changes, or external commands. Only recommended when you trust the upcoming work.',
|
|
300
|
+
permissionConfirmButton: 'I understand the risk, enable',
|
|
301
|
+
permissionConfirmCancelButton: 'Cancel',
|
|
302
|
+
permissionConfirmed: (label) => `✅ Confirmed — switching to "${label}"…`,
|
|
303
|
+
permissionCancelled: 'Cancelled; the permission mode is unchanged.',
|
|
304
|
+
permissionConfirmExpired: '⚠️ That confirmation expired; run /permission set again.',
|
|
305
|
+
permissionSystemLine: (label) => `🔧 Permission mode → ${label}`,
|
|
264
306
|
// ── /session resume ──────────────────────────────────────────────────
|
|
265
307
|
sessionResumeNeedsBoundChannel: '⚠️ /session resume must run in a bound project channel.',
|
|
266
308
|
sessionResumeStarted: threadId => `✅ Session resumed into <#${threadId}> — full history lives in the web UI; continue in the thread.`,
|
|
@@ -282,3 +324,17 @@ const en = {
|
|
|
282
324
|
export function createCopy(language) {
|
|
283
325
|
return language === 'en' ? en : zh;
|
|
284
326
|
}
|
|
327
|
+
/**
|
|
328
|
+
* Localize one permission-preset key for display. The dsh-base canonical
|
|
329
|
+
* three map to Discord copy; deployment-custom keys fall back to the raw
|
|
330
|
+
* host key (never invented labels).
|
|
331
|
+
*/
|
|
332
|
+
export function permissionPresetLabel(preset, copy) {
|
|
333
|
+
if (preset === 'read-only')
|
|
334
|
+
return copy.permissionPresetReadOnly;
|
|
335
|
+
if (preset === 'workspace-write')
|
|
336
|
+
return copy.permissionPresetWorkspaceWrite;
|
|
337
|
+
if (preset === 'danger-full-access')
|
|
338
|
+
return copy.permissionPresetFullAccess;
|
|
339
|
+
return preset;
|
|
340
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -9,14 +9,14 @@ import { createRestThreadPort } from './discord/thread-port.js';
|
|
|
9
9
|
import { createComponentRegistry } from './discord/components.js';
|
|
10
10
|
import { buildCommandRegistrations } from './discord/commands.js';
|
|
11
11
|
import { startDiscordAdapter } from './compose.js';
|
|
12
|
-
import { createModelPort, createWorkspaceCatalogPort, createWorkspaceResolver, readWorkspaceDetail, promptSession, createSessionViaProxy, cancelSessionViaProxy, steerSession, removeQueueItemViaProxy, resolveHostFace } from './dsh/host-face.js';
|
|
12
|
+
import { createModelPort, createPermissionPort, createWorkspaceCatalogPort, createWorkspaceResolver, readWorkspaceDetail, promptSession, createSessionViaProxy, cancelSessionViaProxy, steerSession, removeQueueItemViaProxy, resolveHostFace } from './dsh/host-face.js';
|
|
13
13
|
import { createApprovalStore } from './features/approval-store.js';
|
|
14
14
|
import { createAskWiring } from './features/ask-wiring.js';
|
|
15
15
|
import { sweepExpiredApprovals } from './features/approval-expiry.js';
|
|
16
16
|
import { sweepExpiredQuestions } from './features/question-expiry.js';
|
|
17
17
|
import { createQuestionStore } from './features/question-store.js';
|
|
18
18
|
import { handleSelectInput, handleModalSubmit } from './features/question-routing.js';
|
|
19
|
-
import { createCopy } from './i18n.js';
|
|
19
|
+
import { createCopy, permissionPresetLabel } from './i18n.js';
|
|
20
20
|
import { createInteractionRouter } from './features/interaction-router.js';
|
|
21
21
|
import { channelBindingKey, parseChannelBindingKey, threadBindingKey, parseThreadBindingKey, discordDomainSpec, CHANNEL_BINDINGS_TABLE, THREAD_BINDINGS_TABLE, INTENTS_TABLE } from './state/domain.js';
|
|
22
22
|
import { planBindingReconciliation } from './features/reconcile-bindings.js';
|
|
@@ -855,6 +855,8 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
|
|
|
855
855
|
rpcLog('discord_reconcile_channel_retired', { channelId, reason: 'discord-deleted' });
|
|
856
856
|
},
|
|
857
857
|
model: createModelPort(dsh, { log: rpcLog }),
|
|
858
|
+
permission: createPermissionPort(dsh, { log: rpcLog }),
|
|
859
|
+
permissionSelectOperatorOnly: () => current.permissionSelectOperatorOnly,
|
|
858
860
|
modelSelectOperatorOnly: () => current.modelSelectOperatorOnly,
|
|
859
861
|
resumeCandidates: createResumeCandidatesPort({
|
|
860
862
|
listSessions: () => listSessionSummaries(dsh, { log: rpcLog }),
|
|
@@ -1154,6 +1156,7 @@ export function apply(ctx, config = DEFAULT_DISCORD_SETTINGS) {
|
|
|
1154
1156
|
nonText: copy.userInputNonText,
|
|
1155
1157
|
truncated: copy.userInputTruncated,
|
|
1156
1158
|
}),
|
|
1159
|
+
permissionLineCopy: () => ({ line: presetKey => copy.permissionSystemLine(permissionPresetLabel(presetKey, copy)) }),
|
|
1157
1160
|
onQueueSnapshot: (sessionId, items) => { queueSnapshots.set(sessionId, items); },
|
|
1158
1161
|
onTurnEnded: (sessionId, info) => {
|
|
1159
1162
|
const turn = turnTracker.active(sessionId);
|
package/lib/settings.d.ts
CHANGED
|
@@ -30,6 +30,14 @@ export interface DiscordSettings {
|
|
|
30
30
|
* re-tighten to the operator allowlist.
|
|
31
31
|
*/
|
|
32
32
|
modelSelectOperatorOnly: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Restrict /permission set to the explicit Host-operator allowlist.
|
|
35
|
+
* Defaults to true — stricter than /model because the danger-full-access
|
|
36
|
+
* preset disables BOTH sandbox and approval (16.60); single-user
|
|
37
|
+
* deployments may loosen it so any authorized member can switch, mirroring
|
|
38
|
+
* the loosened /model select vocabulary.
|
|
39
|
+
*/
|
|
40
|
+
permissionSelectOperatorOnly: boolean;
|
|
33
41
|
}
|
|
34
42
|
/** The archive durations Discord's API accepts (minutes). */
|
|
35
43
|
export type ThreadAutoArchiveMinutes = 60 | 1440 | 4320 | 10080;
|
package/lib/settings.js
CHANGED
|
@@ -20,6 +20,7 @@ export const DEFAULT_DISCORD_SETTINGS = Object.freeze({
|
|
|
20
20
|
questionTimeoutMs: 30 * 60_000,
|
|
21
21
|
threadAutoArchiveMinutes: 1440,
|
|
22
22
|
modelSelectOperatorOnly: false,
|
|
23
|
+
permissionSelectOperatorOnly: true,
|
|
23
24
|
});
|
|
24
25
|
const discordIdList = z.array(z.string()).default([]);
|
|
25
26
|
export const DiscordSettingsSchema = z.object({
|
|
@@ -42,6 +43,7 @@ export const DiscordSettingsSchema = z.object({
|
|
|
42
43
|
approvalTimeoutMs: z.number().step(1).min(30_000).max(86_400_000).default(600_000),
|
|
43
44
|
questionTimeoutMs: z.number().step(1).min(30_000).max(86_400_000).default(1_800_000),
|
|
44
45
|
modelSelectOperatorOnly: z.boolean().default(false),
|
|
46
|
+
permissionSelectOperatorOnly: z.boolean().default(true),
|
|
45
47
|
});
|
|
46
48
|
const DISCORD_SNOWFLAKE = /^\d{17,20}$/u;
|
|
47
49
|
const ID_FIELDS = [
|
package/lib/startup.d.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* with a single actionable diagnostic naming every gap.
|
|
7
7
|
*/
|
|
8
8
|
/** The exact Host service roster the adapter requires (design decision 1). */
|
|
9
|
-
export declare const REQUIRED_HOST_SERVICES: readonly ["sessionController", "workspaceController", "sessionQuery", "webServer", "credentials", "settings", "storageDomain", "connection"];
|
|
9
|
+
export declare const REQUIRED_HOST_SERVICES: readonly ["sessionController", "workspaceController", "sessionQuery", "webServer", "credentials", "settings", "storageDomain", "connection", "commands", "permissionPresets"];
|
|
10
10
|
export type HostServiceName = (typeof REQUIRED_HOST_SERVICES)[number];
|
|
11
11
|
/**
|
|
12
12
|
* Validate every required Host capability through `resolveService`, which
|
package/lib/startup.js
CHANGED
|
@@ -15,6 +15,10 @@ export const REQUIRED_HOST_SERVICES = [
|
|
|
15
15
|
'settings',
|
|
16
16
|
'storageDomain',
|
|
17
17
|
'connection',
|
|
18
|
+
// The /permission surface (16.59): the Host command runtime and the
|
|
19
|
+
// permission-preset catalog (both ship in dsh-base).
|
|
20
|
+
'commands',
|
|
21
|
+
'permissionPresets',
|
|
18
22
|
];
|
|
19
23
|
/**
|
|
20
24
|
* Minimum contract members per service, confirmed against the 0.1.6
|
|
@@ -23,7 +27,7 @@ export const REQUIRED_HOST_SERVICES = [
|
|
|
23
27
|
* the startup probe should have refused; refuse rather than probe at runtime.
|
|
24
28
|
*/
|
|
25
29
|
const REQUIRED_CONTRACT_MEMBERS = {
|
|
26
|
-
sessionController: ['prompt', 'create', 'list', 'cancel', 'updateQueue', 'selectModel', 'modelCatalog', 'follow', 'control'],
|
|
30
|
+
sessionController: ['prompt', 'create', 'list', 'cancel', 'updateQueue', 'selectModel', 'modelCatalog', 'follow', 'control', 'resolveAgent'],
|
|
27
31
|
workspaceController: ['follow'],
|
|
28
32
|
sessionQuery: ['observeSession'],
|
|
29
33
|
webServer: [],
|
|
@@ -31,6 +35,8 @@ const REQUIRED_CONTRACT_MEMBERS = {
|
|
|
31
35
|
settings: ['installSection'],
|
|
32
36
|
storageDomain: ['open'],
|
|
33
37
|
connection: ['rpc'],
|
|
38
|
+
commands: ['execute'],
|
|
39
|
+
permissionPresets: ['catalog'],
|
|
34
40
|
};
|
|
35
41
|
/** Actionable remediation hint appended to every activation failure. */
|
|
36
42
|
const REMEDIATION = 'install @addozhang/dsh-discord into a dsh web profile providing the 0.1.6 host controller services';
|
package/lib/stream/live.d.ts
CHANGED
|
@@ -120,6 +120,13 @@ export interface LiveRenderDeps {
|
|
|
120
120
|
nonText: string;
|
|
121
121
|
truncated: string;
|
|
122
122
|
};
|
|
123
|
+
/**
|
|
124
|
+
* Localized permission system-line copy, resolved live (language can
|
|
125
|
+
* change). Unset: permission/preset records never render (16.62).
|
|
126
|
+
*/
|
|
127
|
+
permissionLineCopy?: () => {
|
|
128
|
+
line: (presetLabel: string) => string;
|
|
129
|
+
};
|
|
123
130
|
/** Turn ownership release on turn/end; `info` carries the consumed watermark. */
|
|
124
131
|
onTurnEnded?: (sessionId: string, info?: {
|
|
125
132
|
threadId: string;
|
package/lib/stream/live.js
CHANGED
|
@@ -344,6 +344,27 @@ export function startLiveRender(deps) {
|
|
|
344
344
|
echoUserInput(threadId, runtime, data, event);
|
|
345
345
|
return;
|
|
346
346
|
}
|
|
347
|
+
case 'permission/preset': {
|
|
348
|
+
// Durable preset intent (16.62): one audit line per switch, from
|
|
349
|
+
// ANY surface (web UI or Discord). The knob events that follow
|
|
350
|
+
// (sandbox/mode, approval/policy) stay unrendered — the preset
|
|
351
|
+
// table already implies them.
|
|
352
|
+
const copy = deps.permissionLineCopy?.();
|
|
353
|
+
if (copy === undefined)
|
|
354
|
+
return;
|
|
355
|
+
const preset = data['preset'];
|
|
356
|
+
if (typeof preset !== 'string' || preset === '')
|
|
357
|
+
return;
|
|
358
|
+
const payload = buildOutboundMessage({ kind: 'tool', content: copy.line(preset) });
|
|
359
|
+
void deps.delivery.send({ channelId: threadId, content: payload.content }).then(sent => {
|
|
360
|
+
if (sent.outcome !== 'completed') {
|
|
361
|
+
deps.log?.('discord_live_permission_line_failed', { threadId, outcome: sent.outcome });
|
|
362
|
+
}
|
|
363
|
+
}).catch((cause) => {
|
|
364
|
+
deps.log?.('discord_live_permission_line_threw', { threadId, cause: String(cause) });
|
|
365
|
+
});
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
347
368
|
case 'turn/start': {
|
|
348
369
|
if (typeof turnId !== 'string')
|
|
349
370
|
return;
|