shennian 0.3.85 → 0.3.86
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/dist/bin/shennian.js +1 -1
- package/dist/publish-build-manifest.json +28 -28
- package/dist/src/agents/adapter.js +19 -1
- package/dist/src/agents/claude-managed-permissions.js +248 -1
- package/dist/src/agents/claude.js +393 -8
- package/dist/src/agents/codex-managed-permissions.js +294 -1
- package/dist/src/agents/codex-managed-room-tools.js +249 -1
- package/dist/src/agents/codex-utils.js +200 -7
- package/dist/src/agents/codex.js +1352 -17
- package/dist/src/agents/config-status.js +226 -1
- package/dist/src/agents/cursor-managed-permissions.js +299 -2
- package/dist/src/agents/cursor.js +314 -1
- package/dist/src/agents/custom.js +418 -4
- package/dist/src/agents/external-channel-instructions.js +94 -11
- package/dist/src/agents/gemini.js +180 -1
- package/dist/src/agents/manager.d.ts +2 -0
- package/dist/src/agents/manager.js +250 -18
- package/dist/src/agents/model-registry/cache.js +37 -1
- package/dist/src/agents/model-registry/discovery.js +188 -2
- package/dist/src/agents/model-registry/parsers.js +521 -4
- package/dist/src/agents/model-registry/runner.js +30 -1
- package/dist/src/agents/model-registry/service.js +115 -1
- package/dist/src/agents/model-registry/types.js +8 -1
- package/dist/src/agents/model-registry.js +19 -1
- package/dist/src/agents/native-capabilities.js +237 -1
- package/dist/src/agents/openclaw.js +293 -2
- package/dist/src/agents/opencode-managed-permissions.js +187 -2
- package/dist/src/agents/opencode.js +298 -1
- package/dist/src/agents/platform-instructions.js +46 -17
- package/dist/src/agents/runtime-compatibility.js +44 -1
- package/dist/src/agents/workbuddy-managed-permissions.js +329 -2
- package/dist/src/agents/workbuddy.js +444 -3
- package/dist/src/commands/room.js +2 -2
- package/dist/src/fs/boundary.js +185 -1
- package/dist/src/fs/handler.js +130 -1
- package/dist/src/fs/security.js +32 -1
- package/dist/src/fs/text-decoder.js +110 -1
- package/dist/src/index.js +587 -3
- package/dist/src/manager/prompt.js +34 -29
- package/dist/src/manager/registry.js +317 -1
- package/dist/src/manager/retired-external-channel-compat.js +40 -1
- package/dist/src/manager/runtime.d.ts +4 -0
- package/dist/src/manager/runtime.js +1514 -23
- package/dist/src/native-fusion/codex-parser.js +660 -4
- package/dist/src/native-fusion/codex-user-input.js +102 -5
- package/dist/src/native-fusion/config.js +5 -1
- package/dist/src/native-fusion/file-watcher.js +191 -1
- package/dist/src/native-fusion/local-repository.js +569 -2
- package/dist/src/native-fusion/opencode-parser.js +130 -3
- package/dist/src/native-fusion/parsers.js +140 -3
- package/dist/src/native-fusion/service.js +739 -2
- package/dist/src/native-fusion/session-header.js +101 -2
- package/dist/src/native-fusion/state.js +104 -2
- package/dist/src/native-fusion/types.js +1 -1
- package/dist/src/personal-sync/machine-authority.js +34 -1
- package/dist/src/room/activation-audit.js +45 -2
- package/dist/src/room/activation-policy.js +30 -1
- package/dist/src/room/activation-service.d.ts +1 -0
- package/dist/src/room/activation-service.js +408 -8
- package/dist/src/room/invite-bootstrap.js +156 -1
- package/dist/src/room/managed-context.d.ts +1 -1
- package/dist/src/room/managed-context.js +1 -1
- package/dist/src/room/managed-prompt-composer.d.ts +1 -1
- package/dist/src/room/managed-prompt-composer.js +6 -5
- package/dist/src/room/managed-room-binding.js +167 -1
- package/dist/src/room/managed-room-ipc.d.ts +2 -2
- package/dist/src/room/managed-room-ipc.js +2 -2
- package/dist/src/room/mirror-sync-service.js +183 -1
- package/dist/src/room/room-action-client.d.ts +11 -0
- package/dist/src/room/room-action-client.js +1 -1
- package/dist/src/room/room-binding-client.d.ts +1 -0
- package/dist/src/room/room-binding-client.js +151 -1
- package/dist/src/security/managed-agent-policy.d.ts +3 -2
- package/dist/src/security/managed-agent-policy.js +1 -1
- package/dist/src/session/archive-zip.js +220 -1
- package/dist/src/session/deletion-sync-state.js +136 -2
- package/dist/src/session/handlers/agent-capabilities.js +14 -1
- package/dist/src/session/handlers/agent-config.js +150 -1
- package/dist/src/session/handlers/agents.js +55 -1
- package/dist/src/session/handlers/chat.d.ts +1 -1
- package/dist/src/session/handlers/chat.js +1030 -2
- package/dist/src/session/handlers/control.js +61 -1
- package/dist/src/session/handlers/fs.js +594 -1
- package/dist/src/session/handlers/message-status.js +15 -1
- package/dist/src/session/handlers/remote-access.js +43 -1
- package/dist/src/session/handlers/room-managed.js +101 -1
- package/dist/src/session/handlers/session-refresh.js +35 -1
- package/dist/src/session/handlers/title.js +79 -1
- package/dist/src/session/handlers/tool-detail.js +149 -1
- package/dist/src/session/handlers/voice-window.js +62 -1
- package/dist/src/session/history-subscriptions.js +470 -1
- package/dist/src/session/manager.js +658 -1
- package/dist/src/session/native-cleanup-outbox.js +287 -2
- package/dist/src/session/native-session-lifecycle.js +327 -6
- package/dist/src/session/queue.d.ts +5 -0
- package/dist/src/session/queue.js +8 -8
- package/dist/src/upgrade/scheduler.js +126 -1
- package/node_modules/@shennian/wire/dist/agent-workspace.js +1 -1
- package/package.json +1 -1
|
@@ -1 +1,294 @@
|
|
|
1
|
-
|
|
1
|
+
// @arch docs/features/managed-agent-restricted-runtime.md
|
|
2
|
+
// @test src/__tests__/codex-managed-permissions.test.ts
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { resolveProtectedShennianDirectories } from '../security/managed-agent-credential-paths.js';
|
|
7
|
+
export const CODEX_MANAGED_PERMISSION_PROFILE_ID = 'shennian-managed';
|
|
8
|
+
const SENSITIVE_WORKSPACE_RULES = {
|
|
9
|
+
'.env': 'deny',
|
|
10
|
+
'.env.*': 'deny',
|
|
11
|
+
'**/.env': 'deny',
|
|
12
|
+
'**/.env.*': 'deny',
|
|
13
|
+
'*.key': 'deny',
|
|
14
|
+
'**/*.key': 'deny',
|
|
15
|
+
'*.pem': 'deny',
|
|
16
|
+
'**/*.pem': 'deny',
|
|
17
|
+
'.ssh': 'deny',
|
|
18
|
+
'**/.ssh': 'deny',
|
|
19
|
+
'.aws': 'deny',
|
|
20
|
+
'**/.aws': 'deny',
|
|
21
|
+
'.azure': 'deny',
|
|
22
|
+
'**/.azure': 'deny',
|
|
23
|
+
'.config/gcloud': 'deny',
|
|
24
|
+
'**/.config/gcloud': 'deny',
|
|
25
|
+
};
|
|
26
|
+
const allExternalCapabilities = (allow) => ({
|
|
27
|
+
commandNetwork: { allow, deny: true },
|
|
28
|
+
webSearch: { allow, deny: true },
|
|
29
|
+
browser: { allow, deny: true },
|
|
30
|
+
computerUse: { allow, deny: true },
|
|
31
|
+
mcp: { allow, deny: true },
|
|
32
|
+
});
|
|
33
|
+
/**
|
|
34
|
+
* Codex 0.138+ permission profiles are available on all supported local
|
|
35
|
+
* platforms. A profile is still verified at thread start; registration alone
|
|
36
|
+
* never permits a silent fallback to a legacy sandbox.
|
|
37
|
+
*/
|
|
38
|
+
export const CODEX_MANAGED_RUNTIME_SECURITY = {
|
|
39
|
+
minimumRuntimeVersion: '0.138.0',
|
|
40
|
+
platforms: {
|
|
41
|
+
darwin: {
|
|
42
|
+
level: 'hard',
|
|
43
|
+
fileAccess: { 'workspace-read': true, 'workspace-write': true, 'host-full': true },
|
|
44
|
+
externalCapabilities: allExternalCapabilities(true),
|
|
45
|
+
},
|
|
46
|
+
linux: {
|
|
47
|
+
level: 'hard',
|
|
48
|
+
fileAccess: { 'workspace-read': true, 'workspace-write': true, 'host-full': true },
|
|
49
|
+
externalCapabilities: allExternalCapabilities(true),
|
|
50
|
+
},
|
|
51
|
+
win32: {
|
|
52
|
+
// Codex 0.150.1 elevated on Windows 11 enforces workspace writes and
|
|
53
|
+
// command-network denial, but its native ACL sandbox still permits broad
|
|
54
|
+
// reads (including workspace secret globs and symlink targets). Never
|
|
55
|
+
// present that version as equivalent to macOS/Linux hard isolation or
|
|
56
|
+
// allow a managed thread to start with a falsely advertised file mode.
|
|
57
|
+
level: 'partial',
|
|
58
|
+
fileAccess: { 'workspace-read': false, 'workspace-write': false, 'host-full': false },
|
|
59
|
+
externalCapabilities: allExternalCapabilities(true),
|
|
60
|
+
knownGaps: ['sandbox.nativeWindowsReadIsolationUnavailable'],
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Compile one immutable Codex thread configuration from daemon-verified
|
|
66
|
+
* effective permissions. The config is passed only as an app-server thread
|
|
67
|
+
* override and never written into the user's global Codex configuration.
|
|
68
|
+
*/
|
|
69
|
+
export function compileCodexManagedPermissions(enforcement, resolvedWorkDir, options = {}) {
|
|
70
|
+
const workDir = validateResolvedWorkDir(resolvedWorkDir);
|
|
71
|
+
const policy = enforcement.effectivePolicy;
|
|
72
|
+
const runtimeReadPaths = normalizeAbsolutePaths(codexRuntimeReadPaths(enforcement.platform));
|
|
73
|
+
const workspaceAccess = policy.fileAccess === 'workspace-write' ? 'write' : 'read';
|
|
74
|
+
const parentProfile = policy.fileAccess === 'workspace-read'
|
|
75
|
+
? ':read-only'
|
|
76
|
+
: policy.fileAccess === 'workspace-write'
|
|
77
|
+
? ':workspace'
|
|
78
|
+
: null;
|
|
79
|
+
const filesystem = {
|
|
80
|
+
':root': policy.fileAccess === 'host-full' ? 'write' : 'deny',
|
|
81
|
+
...(policy.fileAccess === 'host-full'
|
|
82
|
+
? {}
|
|
83
|
+
: {
|
|
84
|
+
':minimal': 'read',
|
|
85
|
+
// `:workspace` normally grants temp writes. Managed Agents only get
|
|
86
|
+
// their verified workspace plus fixed operating-system runtime paths.
|
|
87
|
+
':tmpdir': 'deny',
|
|
88
|
+
':slash_tmp': 'deny',
|
|
89
|
+
...Object.fromEntries(runtimeReadPaths.map((entry) => [entry, 'read'])),
|
|
90
|
+
}),
|
|
91
|
+
// These product-owned credentials must remain invisible even when the
|
|
92
|
+
// selected file mode would otherwise allow their parent directory. Keep
|
|
93
|
+
// this after internal path grants so callers cannot accidentally override
|
|
94
|
+
// the deny with a broad daemon runtime path.
|
|
95
|
+
...hostSensitivePathRules(),
|
|
96
|
+
// Workspace secret rules remain active even in the explicit host-full mode.
|
|
97
|
+
// This preserves the product's separate "sensitive files default deny" rule.
|
|
98
|
+
glob_scan_max_depth: 8,
|
|
99
|
+
':workspace_roots': {
|
|
100
|
+
'.': policy.fileAccess === 'host-full' ? 'write' : workspaceAccess,
|
|
101
|
+
...SENSITIVE_WORKSPACE_RULES,
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
const commandNetwork = policy.externalCapabilities.commandNetwork;
|
|
105
|
+
const network = {
|
|
106
|
+
// Keep the proxy enabled when command networking is off so Codex can
|
|
107
|
+
// enforce the empty host allowlist. Managed Room tools use app-server RPC,
|
|
108
|
+
// never a Unix socket visible to the Agent.
|
|
109
|
+
enabled: true,
|
|
110
|
+
domains: commandNetwork ? { '*': 'allow' } : {},
|
|
111
|
+
unix_sockets: {},
|
|
112
|
+
allow_local_binding: commandNetwork,
|
|
113
|
+
dangerously_allow_all_unix_sockets: false,
|
|
114
|
+
};
|
|
115
|
+
return {
|
|
116
|
+
profileId: CODEX_MANAGED_PERMISSION_PROFILE_ID,
|
|
117
|
+
threadConfig: {
|
|
118
|
+
default_permissions: CODEX_MANAGED_PERMISSION_PROFILE_ID,
|
|
119
|
+
permissions: {
|
|
120
|
+
[CODEX_MANAGED_PERMISSION_PROFILE_ID]: {
|
|
121
|
+
description: 'Shennian managed Agent permissions',
|
|
122
|
+
...(parentProfile ? { extends: parentProfile } : {}),
|
|
123
|
+
workspace_roots: { [workDir]: true },
|
|
124
|
+
filesystem,
|
|
125
|
+
network,
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
web_search: policy.externalCapabilities.webSearch ? 'live' : 'disabled',
|
|
129
|
+
features: codexFeatureOverrides(policy.externalCapabilities),
|
|
130
|
+
allow_login_shell: false,
|
|
131
|
+
shell_environment_policy: codexShellEnvironmentPolicy(options.shellSubprocessPolicy),
|
|
132
|
+
...mcpServerOverrides(policy.externalCapabilities.mcp, options.configuredMcpServerNames),
|
|
133
|
+
...(enforcement.platform === 'win32' ? { windows: { sandbox: 'elevated' } } : {}),
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
function codexRuntimeReadPaths(platform) {
|
|
138
|
+
// NVM/Homebrew Node on macOS consults this system-owned OpenSSL directory at
|
|
139
|
+
// process startup. Codex's current `:minimal` set does not include it, which
|
|
140
|
+
// otherwise prevents even an allowed Node command from starting. This is a
|
|
141
|
+
// fixed read-only operating-system path, not a user or daemon environment.
|
|
142
|
+
if (platform === 'darwin' && fs.existsSync('/System/Library/OpenSSL')) {
|
|
143
|
+
return ['/System/Library/OpenSSL'];
|
|
144
|
+
}
|
|
145
|
+
return [];
|
|
146
|
+
}
|
|
147
|
+
function hostSensitivePathRules() {
|
|
148
|
+
const home = path.normalize(os.homedir());
|
|
149
|
+
const roots = [
|
|
150
|
+
...resolveProtectedShennianDirectories(),
|
|
151
|
+
...(home && path.isAbsolute(home)
|
|
152
|
+
? ['.shennian', '.ssh', '.aws', '.azure', path.join('.config', 'gcloud'), '.netrc', '.npmrc']
|
|
153
|
+
.map((entry) => path.join(home, entry))
|
|
154
|
+
: []),
|
|
155
|
+
].filter((entry) => entry && path.isAbsolute(entry));
|
|
156
|
+
return Object.fromEntries([...new Set(roots)].map((entry) => [entry, 'deny']));
|
|
157
|
+
}
|
|
158
|
+
function codexShellEnvironmentPolicy(policy) {
|
|
159
|
+
const allowed = [...new Set(policy?.allowedEnvKeys ?? [
|
|
160
|
+
'PATH',
|
|
161
|
+
'HOME',
|
|
162
|
+
'USERPROFILE',
|
|
163
|
+
'LANG',
|
|
164
|
+
'LC_ALL',
|
|
165
|
+
'LC_CTYPE',
|
|
166
|
+
'TMPDIR',
|
|
167
|
+
'TMP',
|
|
168
|
+
'TEMP',
|
|
169
|
+
])]
|
|
170
|
+
.filter(isSafeEnvKey)
|
|
171
|
+
.filter((key) => !CODEX_FORBIDDEN_ROOM_TRANSPORT_ENV.has(key))
|
|
172
|
+
.sort();
|
|
173
|
+
const denied = [...new Set(policy?.deniedEnvKeys ?? [
|
|
174
|
+
'OPENAI_BASE_URL',
|
|
175
|
+
'OPENAI_API_KEY',
|
|
176
|
+
])].filter(isSafeEnvKey).sort();
|
|
177
|
+
return {
|
|
178
|
+
// Codex needs to select the explicitly allowed values from its already
|
|
179
|
+
// minimized Agent process environment. The include filters reduce the
|
|
180
|
+
// effective child environment to that set; no values are serialized into
|
|
181
|
+
// the persisted thread config.
|
|
182
|
+
inherit: 'all',
|
|
183
|
+
experimental_use_profile: false,
|
|
184
|
+
// The include rules below form the complete allowlist, so Codex's broad
|
|
185
|
+
// default secret-name heuristics are unnecessary and cannot accidentally
|
|
186
|
+
// change which exact Room context keys reach a Shell subprocess.
|
|
187
|
+
ignore_default_excludes: true,
|
|
188
|
+
filters: {
|
|
189
|
+
...Object.fromEntries(allowed.map((key) => [key, 'include'])),
|
|
190
|
+
...Object.fromEntries(denied.map((key) => [key, 'exclude'])),
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
const CODEX_FORBIDDEN_ROOM_TRANSPORT_ENV = new Set([
|
|
195
|
+
'SHENNIAN_MANAGED_ROOM_CONTEXT',
|
|
196
|
+
'SHENNIAN_MANAGED_ROOM_IPC_ROOT',
|
|
197
|
+
]);
|
|
198
|
+
function isSafeEnvKey(value) {
|
|
199
|
+
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);
|
|
200
|
+
}
|
|
201
|
+
export function extractCodexMcpServerNames(configReadResponse) {
|
|
202
|
+
if (!configReadResponse || typeof configReadResponse !== 'object') {
|
|
203
|
+
throw new Error('codex_managed_config_read_invalid');
|
|
204
|
+
}
|
|
205
|
+
const config = configReadResponse.config;
|
|
206
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
|
207
|
+
throw new Error('codex_managed_config_read_invalid');
|
|
208
|
+
}
|
|
209
|
+
const servers = config.mcp_servers;
|
|
210
|
+
if (servers === undefined)
|
|
211
|
+
return [];
|
|
212
|
+
if (!servers || typeof servers !== 'object' || Array.isArray(servers)) {
|
|
213
|
+
throw new Error('codex_managed_config_read_invalid');
|
|
214
|
+
}
|
|
215
|
+
return Object.keys(servers).sort();
|
|
216
|
+
}
|
|
217
|
+
export function assertCodexManagedPermissionProfile(response, expectedProfileId = CODEX_MANAGED_PERMISSION_PROFILE_ID) {
|
|
218
|
+
const profile = response && typeof response === 'object'
|
|
219
|
+
? response.activePermissionProfile
|
|
220
|
+
: undefined;
|
|
221
|
+
if (profile?.id !== expectedProfileId) {
|
|
222
|
+
throw new Error('codex_managed_permission_profile_unavailable');
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
function codexFeatureOverrides(capabilities) {
|
|
226
|
+
return {
|
|
227
|
+
network_proxy: true,
|
|
228
|
+
apps: capabilities.mcp,
|
|
229
|
+
plugins: capabilities.mcp,
|
|
230
|
+
hooks: capabilities.mcp,
|
|
231
|
+
request_permissions_tool: false,
|
|
232
|
+
browser_use: capabilities.browser,
|
|
233
|
+
browser_use_external: capabilities.browser,
|
|
234
|
+
// Full CDP reaches browser internals beyond the product's single Browser
|
|
235
|
+
// permission, so keep it disabled even when normal browser use is enabled.
|
|
236
|
+
browser_use_full_cdp_access: false,
|
|
237
|
+
computer_use: capabilities.computerUse,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
function mcpServerOverrides(enabled, names) {
|
|
241
|
+
if (enabled)
|
|
242
|
+
return {};
|
|
243
|
+
const uniqueNames = [...new Set(names ?? [])].filter(Boolean).sort();
|
|
244
|
+
if (!uniqueNames.length)
|
|
245
|
+
return {};
|
|
246
|
+
return {
|
|
247
|
+
mcp_servers: Object.fromEntries(uniqueNames.map((name) => [name, { enabled: false }])),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
function validateResolvedWorkDir(value) {
|
|
251
|
+
const normalized = path.normalize(value.trim());
|
|
252
|
+
if (!normalized || !path.isAbsolute(normalized)) {
|
|
253
|
+
throw new Error('codex_managed_workdir_invalid');
|
|
254
|
+
}
|
|
255
|
+
let realPath;
|
|
256
|
+
try {
|
|
257
|
+
realPath = fs.realpathSync(normalized);
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
throw new Error('codex_managed_workdir_unavailable');
|
|
261
|
+
}
|
|
262
|
+
if (path.normalize(realPath) !== normalized) {
|
|
263
|
+
throw new Error('codex_managed_workdir_not_realpath');
|
|
264
|
+
}
|
|
265
|
+
return normalized;
|
|
266
|
+
}
|
|
267
|
+
function normalizeAbsolutePaths(values) {
|
|
268
|
+
const normalized = [...new Set(values ?? [])]
|
|
269
|
+
.map((entry) => path.normalize(entry.trim()))
|
|
270
|
+
.filter((entry) => entry && path.isAbsolute(entry))
|
|
271
|
+
.flatMap((entry) => {
|
|
272
|
+
try {
|
|
273
|
+
// macOS commonly exposes TMPDIR through /var while Seatbelt evaluates
|
|
274
|
+
// the same socket as /private/var. Keep both spellings: the filesystem
|
|
275
|
+
// sandbox canonicalizes, while the Unix-socket proxy matches the path
|
|
276
|
+
// string used by the client.
|
|
277
|
+
const realPath = fs.realpathSync(entry);
|
|
278
|
+
return realPath === entry ? [entry] : [entry, realPath];
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
try {
|
|
282
|
+
// Some fixed runtime entries may not exist until startup, but their
|
|
283
|
+
// containing directory can still have a canonical platform spelling.
|
|
284
|
+
const realParent = fs.realpathSync(path.dirname(entry));
|
|
285
|
+
const realPath = path.join(realParent, path.basename(entry));
|
|
286
|
+
return realPath === entry ? [entry] : [entry, realPath];
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
return [entry];
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
return [...new Set(normalized)].sort();
|
|
294
|
+
}
|
|
@@ -1 +1,249 @@
|
|
|
1
|
-
|
|
1
|
+
// @arch docs/features/managed-agent-restricted-runtime.md
|
|
2
|
+
// @test src/__tests__/codex-managed-room-tools.test.ts
|
|
3
|
+
const TOOL_ACTIONS = {
|
|
4
|
+
shennian_room_status: 'status',
|
|
5
|
+
shennian_room_read: 'read',
|
|
6
|
+
shennian_room_send: 'send',
|
|
7
|
+
shennian_room_attachment_get: 'attachment.get',
|
|
8
|
+
shennian_room_pause: 'pause',
|
|
9
|
+
};
|
|
10
|
+
const TOOL_ARGUMENT_KEYS = {
|
|
11
|
+
shennian_room_status: new Set(),
|
|
12
|
+
shennian_room_read: new Set(['before', 'after', 'limit']),
|
|
13
|
+
shennian_room_send: new Set(['text', 'clientMessageId']),
|
|
14
|
+
shennian_room_attachment_get: new Set(['attachmentId']),
|
|
15
|
+
shennian_room_pause: new Set(['reason']),
|
|
16
|
+
};
|
|
17
|
+
export const CODEX_MANAGED_ROOM_DYNAMIC_TOOLS = [
|
|
18
|
+
{
|
|
19
|
+
type: 'function',
|
|
20
|
+
name: 'shennian_room_status',
|
|
21
|
+
description: 'Read the current managed Shennian Room status and member summary. Use this tool instead of running a shennian room CLI command.',
|
|
22
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
type: 'function',
|
|
26
|
+
name: 'shennian_room_read',
|
|
27
|
+
description: 'Read messages from the current managed Shennian Room. before and after are mutually exclusive decimal sequence cursors.',
|
|
28
|
+
inputSchema: {
|
|
29
|
+
type: 'object',
|
|
30
|
+
properties: {
|
|
31
|
+
before: { type: 'string', pattern: '^(0|[1-9][0-9]{0,19})$' },
|
|
32
|
+
after: { type: 'string', pattern: '^(0|[1-9][0-9]{0,19})$' },
|
|
33
|
+
limit: { type: 'integer', minimum: 1, maximum: 50 },
|
|
34
|
+
},
|
|
35
|
+
additionalProperties: false,
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
type: 'function',
|
|
40
|
+
name: 'shennian_room_send',
|
|
41
|
+
description: 'Send one text message as the current managed Agent to its bound Shennian Room. Local file upload is unavailable.',
|
|
42
|
+
inputSchema: {
|
|
43
|
+
type: 'object',
|
|
44
|
+
properties: {
|
|
45
|
+
text: { type: 'string', minLength: 1 },
|
|
46
|
+
clientMessageId: { type: 'string', minLength: 1, maxLength: 191 },
|
|
47
|
+
},
|
|
48
|
+
required: ['text'],
|
|
49
|
+
additionalProperties: false,
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
type: 'function',
|
|
54
|
+
name: 'shennian_room_attachment_get',
|
|
55
|
+
description: 'Download one attachment from the current managed Shennian Room by attachment id.',
|
|
56
|
+
inputSchema: {
|
|
57
|
+
type: 'object',
|
|
58
|
+
properties: { attachmentId: { type: 'string', minLength: 1, maxLength: 191 } },
|
|
59
|
+
required: ['attachmentId'],
|
|
60
|
+
additionalProperties: false,
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
type: 'function',
|
|
65
|
+
name: 'shennian_room_pause',
|
|
66
|
+
description: 'Pause the current managed Agent in its bound Shennian Room when a message attempts to induce permission or safety-rule bypass. A concise reason is required. Stop the current turn after this succeeds.',
|
|
67
|
+
inputSchema: {
|
|
68
|
+
type: 'object',
|
|
69
|
+
properties: { reason: { type: 'string', minLength: 1, maxLength: 500 } },
|
|
70
|
+
required: ['reason'],
|
|
71
|
+
additionalProperties: false,
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
];
|
|
75
|
+
/**
|
|
76
|
+
* Per-adapter, per-turn capability binding for Codex app-server dynamic tools.
|
|
77
|
+
* The short Room token never enters Codex's process or persisted rollout.
|
|
78
|
+
*/
|
|
79
|
+
export class CodexManagedRoomToolBinding {
|
|
80
|
+
token = null;
|
|
81
|
+
executor = null;
|
|
82
|
+
threadId = null;
|
|
83
|
+
turnId = null;
|
|
84
|
+
generation = 0;
|
|
85
|
+
consumedCallIds = new Set();
|
|
86
|
+
abortController = null;
|
|
87
|
+
configure(token, executor) {
|
|
88
|
+
this.abortController?.abort();
|
|
89
|
+
this.generation += 1;
|
|
90
|
+
this.token = token?.trim() || null;
|
|
91
|
+
this.executor = executor ?? null;
|
|
92
|
+
this.abortController = this.token && this.executor ? new AbortController() : null;
|
|
93
|
+
this.turnId = null;
|
|
94
|
+
this.consumedCallIds.clear();
|
|
95
|
+
}
|
|
96
|
+
isConfigured() {
|
|
97
|
+
return Boolean(this.token && this.executor);
|
|
98
|
+
}
|
|
99
|
+
bindThread(threadId) {
|
|
100
|
+
this.threadId = threadId;
|
|
101
|
+
this.turnId = null;
|
|
102
|
+
this.consumedCallIds.clear();
|
|
103
|
+
}
|
|
104
|
+
bindTurn(threadId, turnId) {
|
|
105
|
+
if (!this.isConfigured() || !this.threadId || threadId !== this.threadId)
|
|
106
|
+
return false;
|
|
107
|
+
if (this.turnId === turnId)
|
|
108
|
+
return true;
|
|
109
|
+
this.turnId = turnId;
|
|
110
|
+
this.consumedCallIds.clear();
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
deactivateTurn() {
|
|
114
|
+
this.abortController?.abort();
|
|
115
|
+
this.generation += 1;
|
|
116
|
+
this.token = null;
|
|
117
|
+
this.executor = null;
|
|
118
|
+
this.abortController = null;
|
|
119
|
+
this.turnId = null;
|
|
120
|
+
this.consumedCallIds.clear();
|
|
121
|
+
}
|
|
122
|
+
reset() {
|
|
123
|
+
this.deactivateTurn();
|
|
124
|
+
this.threadId = null;
|
|
125
|
+
}
|
|
126
|
+
async execute(params) {
|
|
127
|
+
const threadId = readIdentifier(params.threadId);
|
|
128
|
+
const turnId = readIdentifier(params.turnId);
|
|
129
|
+
const callId = readIdentifier(params.callId);
|
|
130
|
+
const tool = readIdentifier(params.tool);
|
|
131
|
+
const action = TOOL_ACTIONS[tool];
|
|
132
|
+
const token = this.token;
|
|
133
|
+
const executor = this.executor;
|
|
134
|
+
const abortController = this.abortController;
|
|
135
|
+
const generation = this.generation;
|
|
136
|
+
if (!threadId ||
|
|
137
|
+
!turnId ||
|
|
138
|
+
!callId ||
|
|
139
|
+
!action ||
|
|
140
|
+
params.namespace !== null ||
|
|
141
|
+
!token ||
|
|
142
|
+
!executor ||
|
|
143
|
+
!abortController ||
|
|
144
|
+
threadId !== this.threadId ||
|
|
145
|
+
turnId !== this.turnId ||
|
|
146
|
+
this.consumedCallIds.has(callId)) {
|
|
147
|
+
return failure('managed_room_tool_context_unavailable');
|
|
148
|
+
}
|
|
149
|
+
const toolArguments = readArguments(tool, params.arguments);
|
|
150
|
+
if (!toolArguments)
|
|
151
|
+
return failure('invalid_params');
|
|
152
|
+
this.consumedCallIds.add(callId);
|
|
153
|
+
try {
|
|
154
|
+
// Host-owned authority fields must be written last. Even if a malformed
|
|
155
|
+
// app-server call bypasses its JSON schema, model-controlled arguments
|
|
156
|
+
// can never replace the current capability token or selected action.
|
|
157
|
+
const result = await executor({ ...toolArguments, context: token, action }, {
|
|
158
|
+
signal: abortController.signal,
|
|
159
|
+
...(action === 'attachment.get' ? { attachmentOutput: 'codex-inline' } : {}),
|
|
160
|
+
});
|
|
161
|
+
if (action === 'pause') {
|
|
162
|
+
const contentItems = [{
|
|
163
|
+
type: 'inputText',
|
|
164
|
+
text: JSON.stringify(result),
|
|
165
|
+
}];
|
|
166
|
+
this.deactivateTurn();
|
|
167
|
+
return { success: true, contentItems };
|
|
168
|
+
}
|
|
169
|
+
if (generation !== this.generation ||
|
|
170
|
+
token !== this.token ||
|
|
171
|
+
executor !== this.executor ||
|
|
172
|
+
threadId !== this.threadId ||
|
|
173
|
+
turnId !== this.turnId) {
|
|
174
|
+
return failure('managed_room_tool_context_revoked');
|
|
175
|
+
}
|
|
176
|
+
if (action === 'attachment.get') {
|
|
177
|
+
const contentItems = readCodexAttachmentContent(result);
|
|
178
|
+
return contentItems
|
|
179
|
+
? { success: true, contentItems }
|
|
180
|
+
: failure('managed_room_attachment_presentation_invalid');
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
success: true,
|
|
184
|
+
contentItems: [{ type: 'inputText', text: JSON.stringify(result) }],
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
const code = readSafeErrorCode(error);
|
|
189
|
+
return failure(code);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
function readCodexAttachmentContent(value) {
|
|
194
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
195
|
+
return null;
|
|
196
|
+
const items = value.codexContentItems;
|
|
197
|
+
if (!Array.isArray(items) || items.length < 1 || items.length > 2)
|
|
198
|
+
return null;
|
|
199
|
+
const parsed = [];
|
|
200
|
+
for (const item of items) {
|
|
201
|
+
if (!item || typeof item !== 'object' || Array.isArray(item))
|
|
202
|
+
return null;
|
|
203
|
+
const object = item;
|
|
204
|
+
if (object.type === 'inputText' && typeof object.text === 'string') {
|
|
205
|
+
parsed.push({ type: 'inputText', text: object.text });
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (object.type === 'inputImage' &&
|
|
209
|
+
typeof object.imageUrl === 'string' &&
|
|
210
|
+
/^data:image\/(?:jpeg|png|webp|gif);base64,[A-Za-z0-9+/]*={0,2}$/.test(object.imageUrl)) {
|
|
211
|
+
parsed.push({ type: 'inputImage', imageUrl: object.imageUrl });
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (object.type === 'inputAudio' &&
|
|
215
|
+
typeof object.audioUrl === 'string' &&
|
|
216
|
+
/^data:audio\/(?:mpeg|mp4|ogg|wav|webm|x-wav);base64,[A-Za-z0-9+/]*={0,2}$/.test(object.audioUrl)) {
|
|
217
|
+
parsed.push({ type: 'inputAudio', audioUrl: object.audioUrl });
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
return parsed;
|
|
223
|
+
}
|
|
224
|
+
function readIdentifier(value) {
|
|
225
|
+
return typeof value === 'string' && value.trim() ? value.trim() : '';
|
|
226
|
+
}
|
|
227
|
+
function readArguments(tool, value) {
|
|
228
|
+
if (value == null)
|
|
229
|
+
return {};
|
|
230
|
+
if (typeof value !== 'object' || Array.isArray(value))
|
|
231
|
+
return null;
|
|
232
|
+
const object = value;
|
|
233
|
+
const allowedKeys = TOOL_ARGUMENT_KEYS[tool];
|
|
234
|
+
if (Object.keys(object).some((key) => !allowedKeys.has(key)))
|
|
235
|
+
return null;
|
|
236
|
+
return object;
|
|
237
|
+
}
|
|
238
|
+
function readSafeErrorCode(error) {
|
|
239
|
+
const code = error && typeof error === 'object' ? error.code : undefined;
|
|
240
|
+
return typeof code === 'string' && /^[a-z0-9_]{1,80}$/.test(code)
|
|
241
|
+
? code
|
|
242
|
+
: 'managed_room_tool_failed';
|
|
243
|
+
}
|
|
244
|
+
function failure(code) {
|
|
245
|
+
return {
|
|
246
|
+
success: false,
|
|
247
|
+
contentItems: [{ type: 'inputText', text: code }],
|
|
248
|
+
};
|
|
249
|
+
}
|