myrlin-workbook 1.2.0-alpha.2 → 1.2.0-alpha.4
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/package.json +1 -1
- package/src/providers/codex/spawn.js +154 -62
- package/src/state/store.js +60 -0
- package/src/web/pty-manager.js +12 -0
- package/src/web/public/app.js +197 -0
- package/src/web/public/styles.css +16 -4
- package/src/web/server.js +104 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "myrlin-workbook",
|
|
3
|
-
"version": "1.2.0-alpha.
|
|
3
|
+
"version": "1.2.0-alpha.4",
|
|
4
4
|
"description": "Browser-based project manager for AI coding assistants (Claude Code, ChatGPT Codex). Multi-provider session discovery, search, live terminals, docs, kanban, themes.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -1,35 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Codex provider spawn descriptor builder.
|
|
3
3
|
*
|
|
4
|
-
* Phase 17 Plan 17-02 (CDX-07 spawn half)
|
|
4
|
+
* Phase 17 Plan 17-02 (CDX-07 spawn half) shipped the minimum surface:
|
|
5
|
+
* `resume <id>` when providerSessionId is set, fresh-session otherwise.
|
|
6
|
+
* Phase 21 Plan 21-01 extends this with optional providerSettings so the
|
|
7
|
+
* frontend right-click menu can drive Codex CLI flags (model, sandbox,
|
|
8
|
+
* approval policy, reasoning effort, bypass, feature enables).
|
|
5
9
|
*
|
|
6
|
-
* Pure function
|
|
7
|
-
* the
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
+
* Pure function. Pty-manager (Plan 14-04) reads the returned descriptor and
|
|
11
|
+
* owns the actual node-pty.spawn call. No filesystem touches, no env
|
|
12
|
+
* mutation, no network. Validation is defensive: unsafe values trigger a
|
|
13
|
+
* console.warn and are dropped silently rather than throwing, so a stale
|
|
14
|
+
* frontend value never blocks a pane spawn.
|
|
10
15
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
16
|
+
* Flag translation (providerSettings -> argv):
|
|
17
|
+
* model -> ['-m', model]
|
|
18
|
+
* sandbox -> ['-s', sandbox]
|
|
19
|
+
* approvalPolicy -> ['-a', approvalPolicy]
|
|
20
|
+
* reasoningEffort -> ['-c', 'model_reasoning_effort="<effort>"']
|
|
21
|
+
* bypassApprovalsAndSandbox: true
|
|
22
|
+
* -> ['--dangerously-bypass-approvals-and-sandbox']
|
|
23
|
+
* features: [name,..] -> ['--enable', name] pairs
|
|
17
24
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* which works in the common case, but explicit propagation is the
|
|
22
|
-
* documentation-friendly form: the descriptor advertises which env keys
|
|
23
|
-
* matter). When the parent env does NOT set CODEX_HOME, env.CODEX_HOME is
|
|
24
|
-
* set to undefined which pty-manager treats as DELETE-this-key. Net effect
|
|
25
|
-
* for the unset case is identical to leaving the env alone; the explicit
|
|
26
|
-
* assign documents the contract.
|
|
25
|
+
* Positional ordering invariant: any `resume <id>` positional pair stays
|
|
26
|
+
* LAST in args so flags cannot be misparsed as the resume id. This matches
|
|
27
|
+
* the Codex CLI convention (subcommand positions trail option flags).
|
|
27
28
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* claude/spawn.js verbatim so any input that passes the SHELL_UNSAFE gate
|
|
32
|
-
* for Claude also passes for Codex.
|
|
29
|
+
* Enum allow-lists are intentionally short and conservative. Drop-unknown
|
|
30
|
+
* with a console.warn keeps the spawn path resilient against typos and
|
|
31
|
+
* stale fields from older clients.
|
|
33
32
|
*
|
|
34
33
|
* SPDX-License-Identifier: AGPL-3.0-only
|
|
35
34
|
*
|
|
@@ -38,68 +37,161 @@
|
|
|
38
37
|
|
|
39
38
|
'use strict';
|
|
40
39
|
|
|
41
|
-
// The literal 'codex' below is the CLI binary name (the npm-published Codex
|
|
42
|
-
// CLI installs a `codex` executable). This file lives inside src/providers/codex/,
|
|
43
|
-
// which the grep gate (Plan 14-05) skips, so the marker is defensive (extra
|
|
44
|
-
// signal for future readers) rather than required.
|
|
45
40
|
const CODEX_BINARY = 'codex'; // gsd:provider-literal-allowed (Codex provider CLI binary)
|
|
46
41
|
|
|
42
|
+
// Same shell-safe regex Claude uses for its providerSessionId gate.
|
|
43
|
+
const SAFE_ID_RE = /^[a-zA-Z0-9_-]+$/;
|
|
44
|
+
|
|
45
|
+
// Shell-unsafe characters for free-form values (model names, etc.). Mirrors
|
|
46
|
+
// pty-manager.js SHELL_UNSAFE so anything that passes here also passes the
|
|
47
|
+
// outer gate; we keep the local copy so this module is testable standalone.
|
|
48
|
+
const SHELL_UNSAFE_RE = /[;&|`$(){}[\]<>!#*?\n\r\\'"]/;
|
|
49
|
+
|
|
50
|
+
// Enum allow-lists. Conservative on purpose: only values the Codex CLI
|
|
51
|
+
// accepts today. Unknown values get dropped with a console.warn instead of
|
|
52
|
+
// crashing the spawn so a stale frontend cache never blocks a pane.
|
|
53
|
+
const SANDBOX_VALUES = new Set([
|
|
54
|
+
'read-only',
|
|
55
|
+
'workspace-write',
|
|
56
|
+
'danger-full-access',
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
const APPROVAL_VALUES = new Set([
|
|
60
|
+
'untrusted',
|
|
61
|
+
'on-failure',
|
|
62
|
+
'on-request',
|
|
63
|
+
'never',
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
const EFFORT_VALUES = new Set([
|
|
67
|
+
'minimal',
|
|
68
|
+
'low',
|
|
69
|
+
'medium',
|
|
70
|
+
'high',
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
// Feature name format: short alphanumeric + dash/underscore. Matches Codex
|
|
74
|
+
// CLI --enable token shape (e.g. web_search, view_image).
|
|
75
|
+
const FEATURE_NAME_RE = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
|
|
76
|
+
|
|
77
|
+
// Model id format: alphanumeric + dot/dash/underscore/colon. Cap length so
|
|
78
|
+
// argv stays bounded. The colon allows version-suffixed model ids
|
|
79
|
+
// ("gpt-5:2025-09-01"-shape).
|
|
80
|
+
const MODEL_ID_RE = /^[a-zA-Z0-9._:-]{1,128}$/;
|
|
81
|
+
|
|
47
82
|
/**
|
|
48
|
-
* Validate providerSessionId
|
|
49
|
-
* UUIDs (with or without hyphens) and slug-style identifiers pass; anything
|
|
50
|
-
* with spaces, quotes, semicolons, backticks, or other shell-special bytes
|
|
51
|
-
* trips the gate.
|
|
83
|
+
* Validate providerSessionId. Reused by tests.
|
|
52
84
|
*
|
|
53
|
-
* @param {
|
|
54
|
-
* @returns {boolean}
|
|
85
|
+
* @param {*} id
|
|
86
|
+
* @returns {boolean}
|
|
55
87
|
*/
|
|
56
88
|
function isSafeProviderSessionId(id) {
|
|
57
|
-
return typeof id === 'string' &&
|
|
89
|
+
return typeof id === 'string' && SAFE_ID_RE.test(id);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Translate an optional providerSettings bundle to Codex CLI argv tokens.
|
|
94
|
+
* Pure function. Unknown values are dropped with a single console.warn so
|
|
95
|
+
* one bad field does not abort the spawn.
|
|
96
|
+
*
|
|
97
|
+
* @param {Object} [settings] - providerSettings.codex bundle. Optional.
|
|
98
|
+
* @returns {string[]} Flag tokens ordered model, sandbox, approval, effort,
|
|
99
|
+
* bypass, then feature pairs. Caller appends positional args after.
|
|
100
|
+
*/
|
|
101
|
+
function buildFlagsFromSettings(settings) {
|
|
102
|
+
if (!settings || typeof settings !== 'object') return [];
|
|
103
|
+
const flags = [];
|
|
104
|
+
|
|
105
|
+
if (typeof settings.model === 'string' && settings.model.length > 0) {
|
|
106
|
+
if (MODEL_ID_RE.test(settings.model) && !SHELL_UNSAFE_RE.test(settings.model)) {
|
|
107
|
+
flags.push('-m', settings.model);
|
|
108
|
+
} else {
|
|
109
|
+
console.warn('[codex/spawn] dropping unsafe/oversized model: ' + settings.model);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (typeof settings.sandbox === 'string' && settings.sandbox.length > 0) {
|
|
114
|
+
if (SANDBOX_VALUES.has(settings.sandbox)) {
|
|
115
|
+
flags.push('-s', settings.sandbox);
|
|
116
|
+
} else {
|
|
117
|
+
console.warn('[codex/spawn] dropping unknown sandbox: ' + settings.sandbox);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (typeof settings.approvalPolicy === 'string' && settings.approvalPolicy.length > 0) {
|
|
122
|
+
if (APPROVAL_VALUES.has(settings.approvalPolicy)) {
|
|
123
|
+
flags.push('-a', settings.approvalPolicy);
|
|
124
|
+
} else {
|
|
125
|
+
console.warn('[codex/spawn] dropping unknown approvalPolicy: ' + settings.approvalPolicy);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (typeof settings.reasoningEffort === 'string' && settings.reasoningEffort.length > 0) {
|
|
130
|
+
if (EFFORT_VALUES.has(settings.reasoningEffort)) {
|
|
131
|
+
// TOML-safe value formatting: quote the string so the Codex `-c key=val`
|
|
132
|
+
// parser treats it as a literal even if a future effort name contains
|
|
133
|
+
// chars TOML would interpret. Today all enum values are bare words and
|
|
134
|
+
// quoting them is a no-op, but the quotes future-proof the contract.
|
|
135
|
+
flags.push('-c', 'model_reasoning_effort="' + settings.reasoningEffort + '"');
|
|
136
|
+
} else {
|
|
137
|
+
console.warn('[codex/spawn] dropping unknown reasoningEffort: ' + settings.reasoningEffort);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (settings.bypassApprovalsAndSandbox === true) {
|
|
142
|
+
flags.push('--dangerously-bypass-approvals-and-sandbox');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (Array.isArray(settings.features)) {
|
|
146
|
+
for (const name of settings.features) {
|
|
147
|
+
if (typeof name === 'string' && FEATURE_NAME_RE.test(name) && !SHELL_UNSAFE_RE.test(name)) {
|
|
148
|
+
flags.push('--enable', name);
|
|
149
|
+
} else {
|
|
150
|
+
console.warn('[codex/spawn] dropping unsafe feature name: ' + name);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return flags;
|
|
58
156
|
}
|
|
59
157
|
|
|
60
158
|
/**
|
|
61
159
|
* Build a SpawnDescriptor for the Codex CLI.
|
|
62
160
|
*
|
|
63
161
|
* Pure function. Does NOT touch the filesystem, the network, or any state.
|
|
64
|
-
* Does NOT mutate process.env. Throws on invalid
|
|
65
|
-
*
|
|
162
|
+
* Does NOT mutate process.env. Throws on invalid providerSessionId. Unknown
|
|
163
|
+
* providerSettings values are dropped (warn only) rather than thrown.
|
|
66
164
|
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* - providerSessionId unset -> []
|
|
165
|
+
* argv ordering:
|
|
166
|
+
* [<flag tokens from providerSettings...>, 'resume', '<id>']
|
|
70
167
|
*
|
|
71
|
-
*
|
|
72
|
-
* the
|
|
73
|
-
* plumbing has shipped and the Codex flag surface is locked.
|
|
168
|
+
* The positional resume pair stays LAST so flag-shaped resume ids cannot be
|
|
169
|
+
* misparsed (the Codex CLI takes the subcommand as a trailing position).
|
|
74
170
|
*
|
|
75
|
-
* @param {Object} [init]
|
|
76
|
-
* @param {string|null} [init.providerSessionId] - Codex session UUID for
|
|
77
|
-
* Validated against /^[a-zA-Z0-9_-]+$/. Throws on unsafe input.
|
|
78
|
-
* @param {string|null} [init.cwd] - Working directory;
|
|
79
|
-
*
|
|
80
|
-
*
|
|
171
|
+
* @param {Object} [init]
|
|
172
|
+
* @param {string|null} [init.providerSessionId] - Codex session UUID for
|
|
173
|
+
* `resume`. Validated against /^[a-zA-Z0-9_-]+$/. Throws on unsafe input.
|
|
174
|
+
* @param {string|null} [init.cwd] - Working directory; pass-through.
|
|
175
|
+
* @param {Object} [init.providerSettings] - Optional Codex settings bundle.
|
|
176
|
+
* See buildFlagsFromSettings for accepted keys.
|
|
177
|
+
* @returns {{cmd:string, args:string[], cwd:(string|null), env:Object}}
|
|
81
178
|
* @throws {Error} when providerSessionId fails the safety regex.
|
|
82
179
|
*/
|
|
83
|
-
function spawnCommand({ providerSessionId = null, cwd = null } = {}) {
|
|
84
|
-
// Validate first so we never produce an unsafe argv even partially.
|
|
180
|
+
function spawnCommand({ providerSessionId = null, cwd = null, providerSettings = null } = {}) {
|
|
85
181
|
if (providerSessionId && !isSafeProviderSessionId(providerSessionId)) {
|
|
86
182
|
throw new Error('unsafe providerSessionId: ' + providerSessionId);
|
|
87
183
|
}
|
|
88
184
|
|
|
89
|
-
|
|
185
|
+
// Flags first, positional resume <id> last so flag/value pairs cannot
|
|
186
|
+
// accidentally swallow the resume id at argv-parse time.
|
|
187
|
+
const args = buildFlagsFromSettings(providerSettings);
|
|
90
188
|
if (providerSessionId) {
|
|
91
|
-
|
|
92
|
-
// The exact CLI invocation is: codex resume <uuid>
|
|
93
|
-
args.push('resume');
|
|
94
|
-
args.push(providerSessionId);
|
|
189
|
+
args.push('resume', providerSessionId);
|
|
95
190
|
}
|
|
96
|
-
// Phase 17 deliberately stops here. Additional flags belong in Phase 19.
|
|
97
191
|
|
|
98
192
|
// CODEX_HOME scoping: read process.env at call time so a test that
|
|
99
193
|
// mutates the env in a try/finally sees the change reflected here.
|
|
100
|
-
//
|
|
101
|
-
// semantic is honored by pty-manager (see pty-manager.js: any env value
|
|
102
|
-
// that is `=== undefined` is removed from the spawn env via delete).
|
|
194
|
+
// Undefined-means-delete semantic honored by pty-manager.
|
|
103
195
|
const codexHomeFromEnv = process.env.CODEX_HOME;
|
|
104
196
|
const envOverride = {
|
|
105
197
|
CODEX_HOME: typeof codexHomeFromEnv === 'string' && codexHomeFromEnv.length > 0
|
|
@@ -115,4 +207,4 @@ function spawnCommand({ providerSessionId = null, cwd = null } = {}) {
|
|
|
115
207
|
};
|
|
116
208
|
}
|
|
117
209
|
|
|
118
|
-
module.exports = { spawnCommand };
|
|
210
|
+
module.exports = { spawnCommand, buildFlagsFromSettings };
|
package/src/state/store.js
CHANGED
|
@@ -747,6 +747,66 @@ class Store extends EventEmitter {
|
|
|
747
747
|
return this.updateSession(id, { status, pid });
|
|
748
748
|
}
|
|
749
749
|
|
|
750
|
+
/**
|
|
751
|
+
* Persist per-session provider settings (Phase 21 Plan 21-01).
|
|
752
|
+
*
|
|
753
|
+
* Shape:
|
|
754
|
+
* session.providerSettings = { [providerId]: { ...settings } }
|
|
755
|
+
*
|
|
756
|
+
* Per-provider merging: the new settings object REPLACES the existing
|
|
757
|
+
* bundle for that providerId. Other providers' bundles on the same
|
|
758
|
+
* session are untouched. Caller is responsible for shallow-merging if
|
|
759
|
+
* a partial update is desired (the route currently sends a full
|
|
760
|
+
* canonical bundle, so replace-on-write is the right default).
|
|
761
|
+
*
|
|
762
|
+
* @param {string} sessionId
|
|
763
|
+
* @param {string} providerId - Provider id string (registry-issued).
|
|
764
|
+
* @param {Object} settings - The canonical settings bundle to persist.
|
|
765
|
+
* @returns {Object|null} Updated session or null if not found.
|
|
766
|
+
*/
|
|
767
|
+
updateSessionProviderSettings(sessionId, providerId, settings) {
|
|
768
|
+
const session = this._state.sessions[sessionId];
|
|
769
|
+
if (!session) return null;
|
|
770
|
+
if (!session.providerSettings || typeof session.providerSettings !== 'object') {
|
|
771
|
+
session.providerSettings = {};
|
|
772
|
+
}
|
|
773
|
+
session.providerSettings[providerId] = settings;
|
|
774
|
+
session.lastActive = new Date().toISOString();
|
|
775
|
+
this.save();
|
|
776
|
+
this.emit('session:updated', session);
|
|
777
|
+
return session;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* Read the effective provider settings for a session, with fallback to
|
|
782
|
+
* the per-provider defaults in `state.settings.providerDefaults[providerId]`.
|
|
783
|
+
* Used by the route handler so the UI sees defaults when a session has
|
|
784
|
+
* no overrides yet.
|
|
785
|
+
*
|
|
786
|
+
* @param {string} sessionId
|
|
787
|
+
* @param {string} providerId
|
|
788
|
+
* @returns {Object} Settings bundle (may be empty object).
|
|
789
|
+
*/
|
|
790
|
+
getSessionProviderSettings(sessionId, providerId) {
|
|
791
|
+
const session = this._state.sessions[sessionId];
|
|
792
|
+
const fromSession = session
|
|
793
|
+
&& session.providerSettings
|
|
794
|
+
&& typeof session.providerSettings === 'object'
|
|
795
|
+
&& session.providerSettings[providerId]
|
|
796
|
+
&& typeof session.providerSettings[providerId] === 'object'
|
|
797
|
+
? session.providerSettings[providerId]
|
|
798
|
+
: null;
|
|
799
|
+
if (fromSession) return fromSession;
|
|
800
|
+
const defaults = this._state.settings
|
|
801
|
+
&& this._state.settings.providerDefaults
|
|
802
|
+
&& typeof this._state.settings.providerDefaults === 'object'
|
|
803
|
+
&& this._state.settings.providerDefaults[providerId]
|
|
804
|
+
&& typeof this._state.settings.providerDefaults[providerId] === 'object'
|
|
805
|
+
? this._state.settings.providerDefaults[providerId]
|
|
806
|
+
: {};
|
|
807
|
+
return defaults;
|
|
808
|
+
}
|
|
809
|
+
|
|
750
810
|
addSessionLog(id, message) {
|
|
751
811
|
const session = this._state.sessions[id];
|
|
752
812
|
if (!session) return;
|
package/src/web/pty-manager.js
CHANGED
|
@@ -349,6 +349,17 @@ class PtySessionManager {
|
|
|
349
349
|
// ── Block B (Plan 14-04): Build descriptor (provider OR inline) ──
|
|
350
350
|
let descriptor;
|
|
351
351
|
if (useProvider) {
|
|
352
|
+
// Phase 21 Plan 21-01: per-session providerSettings drives provider CLI flags.
|
|
353
|
+
// Read the bundle for THIS provider so a Claude session never receives
|
|
354
|
+
// a Codex-shaped bundle and vice versa. Missing/non-object yields null;
|
|
355
|
+
// the provider's spawnCommand treats null as "no extra flags".
|
|
356
|
+
const providerSettingsBundle = storeSession
|
|
357
|
+
&& storeSession.providerSettings
|
|
358
|
+
&& typeof storeSession.providerSettings === 'object'
|
|
359
|
+
&& storeSession.providerSettings[providerId]
|
|
360
|
+
&& typeof storeSession.providerSettings[providerId] === 'object'
|
|
361
|
+
? storeSession.providerSettings[providerId]
|
|
362
|
+
: null;
|
|
352
363
|
try {
|
|
353
364
|
descriptor = provider.spawnCommand({
|
|
354
365
|
sessionId,
|
|
@@ -359,6 +370,7 @@ class PtySessionManager {
|
|
|
359
370
|
model,
|
|
360
371
|
verbose,
|
|
361
372
|
initialPrompt,
|
|
373
|
+
providerSettings: providerSettingsBundle,
|
|
362
374
|
});
|
|
363
375
|
} catch (err) {
|
|
364
376
|
console.error('[PTY] Provider ' + providerId + ' spawnCommand failed for ' + sessionId + ': ' + err.message);
|
package/src/web/public/app.js
CHANGED
|
@@ -11397,6 +11397,188 @@ class CWMApp {
|
|
|
11397
11397
|
this._renderContextItems('Open View', items, x, y);
|
|
11398
11398
|
}
|
|
11399
11399
|
|
|
11400
|
+
/**
|
|
11401
|
+
* Build the "Codex settings" submenu (Plan 21-01).
|
|
11402
|
+
*
|
|
11403
|
+
* Pure factory: returns an array of menu items suitable for the existing
|
|
11404
|
+
* _renderContextItems renderer (label/submenu/check/hint/action/danger
|
|
11405
|
+
* shape). Reads the session's current providerSettings from
|
|
11406
|
+
* this.state.allSessions (or this.state.sessions) and uses the values to
|
|
11407
|
+
* mark the active option with a check in each submenu.
|
|
11408
|
+
*
|
|
11409
|
+
* Each leaf action PUTs the new bundle to the server and shows a toast
|
|
11410
|
+
* hinting the user to restart the pane for the change to take effect
|
|
11411
|
+
* (pty-manager only reads providerSettings on spawn). The bypass toggle
|
|
11412
|
+
* routes through showConfirmModal first because it weakens the sandbox.
|
|
11413
|
+
*
|
|
11414
|
+
* Dispatched from showTerminalContextMenu when the pane's
|
|
11415
|
+
* dataset.provider matches the Codex provider id. Pane menus for other
|
|
11416
|
+
* providers skip this branch so a Claude pane never shows a Codex submenu.
|
|
11417
|
+
*
|
|
11418
|
+
* @param {number} slotIdx - Terminal pane slot index.
|
|
11419
|
+
* @param {Object} tp - The TerminalPane instance for this slot.
|
|
11420
|
+
* @returns {Array<Object>} Menu items to splice into the pane context menu.
|
|
11421
|
+
*/
|
|
11422
|
+
_buildCodexPaneMenu(slotIdx, tp) { // gsd:provider-literal-allowed (Codex pane menu factory)
|
|
11423
|
+
const sessionId = tp.sessionId;
|
|
11424
|
+
// Look up the session record to read providerSettings.codex. Defensive
|
|
11425
|
+
// because some panes (Codex Desktop project sessions) may not exist in
|
|
11426
|
+
// the live sessions list yet; in that case we render with empty
|
|
11427
|
+
// settings and let the user dial them in.
|
|
11428
|
+
const allSessions = [
|
|
11429
|
+
...(this.state.sessions || []),
|
|
11430
|
+
...(this.state.allSessions || []),
|
|
11431
|
+
];
|
|
11432
|
+
const sess = allSessions.find(s => s && s.id === sessionId) || {};
|
|
11433
|
+
const codexSettings = (sess.providerSettings && sess.providerSettings.codex) /* gsd:provider-literal-allowed */ || {};
|
|
11434
|
+
|
|
11435
|
+
const putSettings = async (partial) => {
|
|
11436
|
+
const next = { ...codexSettings, ...partial };
|
|
11437
|
+
try {
|
|
11438
|
+
await this.api('PUT', '/api/sessions/' + encodeURIComponent(sessionId) + '/provider-settings', { settings: next });
|
|
11439
|
+
// Mutate the cached session so subsequent menu opens reflect the
|
|
11440
|
+
// change without a full refetch. The next saveTerminalLayout or
|
|
11441
|
+
// loadSessions tick will rehydrate from the server.
|
|
11442
|
+
if (!sess.providerSettings) sess.providerSettings = {};
|
|
11443
|
+
sess.providerSettings.codex = next; // gsd:provider-literal-allowed
|
|
11444
|
+
this.showToast('Codex settings updated — restart pane to apply', 'info');
|
|
11445
|
+
} catch (err) {
|
|
11446
|
+
this.showToast(err.message || 'Failed to update Codex settings', 'error');
|
|
11447
|
+
}
|
|
11448
|
+
};
|
|
11449
|
+
|
|
11450
|
+
// Catalog of accepted values per setting. Mirrors backend allow-lists.
|
|
11451
|
+
const MODEL_OPTIONS = [
|
|
11452
|
+
{ id: 'gpt-5-codex', label: 'gpt-5-codex' },
|
|
11453
|
+
{ id: 'gpt-5', label: 'gpt-5' },
|
|
11454
|
+
{ id: 'o3', label: 'o3' },
|
|
11455
|
+
];
|
|
11456
|
+
const SANDBOX_OPTIONS = [
|
|
11457
|
+
{ id: 'read-only', label: 'read-only' },
|
|
11458
|
+
{ id: 'workspace-write', label: 'workspace-write' },
|
|
11459
|
+
{ id: 'danger-full-access', label: 'danger-full-access (risky)' },
|
|
11460
|
+
];
|
|
11461
|
+
const APPROVAL_OPTIONS = [
|
|
11462
|
+
{ id: 'untrusted', label: 'untrusted (prompt for unknown commands)' },
|
|
11463
|
+
{ id: 'on-failure', label: 'on-failure' },
|
|
11464
|
+
{ id: 'on-request', label: 'on-request' },
|
|
11465
|
+
{ id: 'never', label: 'never (auto-approve everything)' },
|
|
11466
|
+
];
|
|
11467
|
+
const EFFORT_OPTIONS = [
|
|
11468
|
+
{ id: 'minimal', label: 'minimal' },
|
|
11469
|
+
{ id: 'low', label: 'low' },
|
|
11470
|
+
{ id: 'medium', label: 'medium' },
|
|
11471
|
+
{ id: 'high', label: 'high' },
|
|
11472
|
+
];
|
|
11473
|
+
const FEATURE_OPTIONS = [
|
|
11474
|
+
{ id: 'web_search', label: 'Web search' },
|
|
11475
|
+
{ id: 'view_image', label: 'View images' },
|
|
11476
|
+
{ id: 'plan_tool', label: 'Plan tool' },
|
|
11477
|
+
{ id: 'apply_patch_tool', label: 'Apply patch tool' },
|
|
11478
|
+
];
|
|
11479
|
+
|
|
11480
|
+
const codexItems = [];
|
|
11481
|
+
|
|
11482
|
+
// 1. Model submenu
|
|
11483
|
+
codexItems.push({
|
|
11484
|
+
label: 'Model',
|
|
11485
|
+
icon: '🧠',
|
|
11486
|
+
hint: codexSettings.model || 'default',
|
|
11487
|
+
submenu: MODEL_OPTIONS.map(opt => ({
|
|
11488
|
+
label: opt.label,
|
|
11489
|
+
action: () => putSettings({ model: opt.id }),
|
|
11490
|
+
check: codexSettings.model === opt.id,
|
|
11491
|
+
})),
|
|
11492
|
+
});
|
|
11493
|
+
|
|
11494
|
+
// 2. Sandbox submenu
|
|
11495
|
+
codexItems.push({
|
|
11496
|
+
label: 'Sandbox',
|
|
11497
|
+
icon: '🔒',
|
|
11498
|
+
hint: codexSettings.sandbox || 'default',
|
|
11499
|
+
submenu: SANDBOX_OPTIONS.map(opt => ({
|
|
11500
|
+
label: opt.label,
|
|
11501
|
+
action: () => putSettings({ sandbox: opt.id }),
|
|
11502
|
+
check: codexSettings.sandbox === opt.id,
|
|
11503
|
+
danger: opt.id === 'danger-full-access',
|
|
11504
|
+
})),
|
|
11505
|
+
});
|
|
11506
|
+
|
|
11507
|
+
// 3. Approval Policy submenu
|
|
11508
|
+
codexItems.push({
|
|
11509
|
+
label: 'Approval Policy',
|
|
11510
|
+
icon: '✅',
|
|
11511
|
+
hint: codexSettings.approvalPolicy || 'default',
|
|
11512
|
+
submenu: APPROVAL_OPTIONS.map(opt => ({
|
|
11513
|
+
label: opt.label,
|
|
11514
|
+
action: () => putSettings({ approvalPolicy: opt.id }),
|
|
11515
|
+
check: codexSettings.approvalPolicy === opt.id,
|
|
11516
|
+
danger: opt.id === 'never',
|
|
11517
|
+
})),
|
|
11518
|
+
});
|
|
11519
|
+
|
|
11520
|
+
// 4. Reasoning Effort submenu
|
|
11521
|
+
codexItems.push({
|
|
11522
|
+
label: 'Reasoning Effort',
|
|
11523
|
+
icon: '💭',
|
|
11524
|
+
hint: codexSettings.reasoningEffort || 'default',
|
|
11525
|
+
submenu: EFFORT_OPTIONS.map(opt => ({
|
|
11526
|
+
label: opt.label,
|
|
11527
|
+
action: () => putSettings({ reasoningEffort: opt.id }),
|
|
11528
|
+
check: codexSettings.reasoningEffort === opt.id,
|
|
11529
|
+
})),
|
|
11530
|
+
});
|
|
11531
|
+
|
|
11532
|
+
// 5. Bypass toggle with confirmation modal
|
|
11533
|
+
const isBypassOn = codexSettings.bypassApprovalsAndSandbox === true;
|
|
11534
|
+
codexItems.push({
|
|
11535
|
+
label: isBypassOn ? 'Bypass: ON (click to disable)' : 'Bypass Approvals & Sandbox',
|
|
11536
|
+
icon: '⚠',
|
|
11537
|
+
danger: true,
|
|
11538
|
+
check: isBypassOn,
|
|
11539
|
+
action: async () => {
|
|
11540
|
+
if (isBypassOn) {
|
|
11541
|
+
// Turning OFF a dangerous flag is safe; no confirmation needed.
|
|
11542
|
+
await putSettings({ bypassApprovalsAndSandbox: false });
|
|
11543
|
+
return;
|
|
11544
|
+
}
|
|
11545
|
+
// Turning ON: require explicit confirmation. The bypass flag
|
|
11546
|
+
// disables BOTH the approval workflow AND the sandbox, so the
|
|
11547
|
+
// session can read/write/exec anything the user can. Worth a
|
|
11548
|
+
// second click before flipping.
|
|
11549
|
+
const confirmed = await this.showConfirmModal({
|
|
11550
|
+
title: 'Enable Bypass for Codex?',
|
|
11551
|
+
message: 'This disables BOTH the approval workflow AND the sandbox for this Codex session. The session can read/write/execute anything you can. Continue?',
|
|
11552
|
+
confirmText: 'Enable Bypass',
|
|
11553
|
+
confirmClass: 'btn-danger',
|
|
11554
|
+
});
|
|
11555
|
+
if (confirmed) {
|
|
11556
|
+
await putSettings({ bypassApprovalsAndSandbox: true });
|
|
11557
|
+
}
|
|
11558
|
+
},
|
|
11559
|
+
});
|
|
11560
|
+
|
|
11561
|
+
// 6. Features submenu (multi-select via per-item toggle)
|
|
11562
|
+
const activeFeatures = Array.isArray(codexSettings.features) ? codexSettings.features : [];
|
|
11563
|
+
codexItems.push({
|
|
11564
|
+
label: 'Features',
|
|
11565
|
+
icon: '⚙',
|
|
11566
|
+
hint: activeFeatures.length ? activeFeatures.join(', ') : 'none',
|
|
11567
|
+
submenu: FEATURE_OPTIONS.map(opt => ({
|
|
11568
|
+
label: opt.label,
|
|
11569
|
+
action: () => {
|
|
11570
|
+
const next = activeFeatures.includes(opt.id)
|
|
11571
|
+
? activeFeatures.filter(f => f !== opt.id)
|
|
11572
|
+
: [...activeFeatures, opt.id];
|
|
11573
|
+
putSettings({ features: next });
|
|
11574
|
+
},
|
|
11575
|
+
check: activeFeatures.includes(opt.id),
|
|
11576
|
+
})),
|
|
11577
|
+
});
|
|
11578
|
+
|
|
11579
|
+
return codexItems;
|
|
11580
|
+
}
|
|
11581
|
+
|
|
11400
11582
|
showTerminalContextMenu(slotIdx, x, y) {
|
|
11401
11583
|
const tp = this.terminalPanes[slotIdx];
|
|
11402
11584
|
if (!tp) return;
|
|
@@ -11556,6 +11738,21 @@ class CWMApp {
|
|
|
11556
11738
|
});
|
|
11557
11739
|
}
|
|
11558
11740
|
|
|
11741
|
+
// ── Provider-specific submenu (Plan 21-01) ────────────────
|
|
11742
|
+
// Dispatch by data-provider on the pane element. The Codex pane gets
|
|
11743
|
+
// a "Codex settings" submenu with model/sandbox/approval/effort/bypass/
|
|
11744
|
+
// features; Claude panes get nothing here and fall through unchanged.
|
|
11745
|
+
const paneElForProvider = document.getElementById(`term-pane-${slotIdx}`);
|
|
11746
|
+
const paneProvider = paneElForProvider && paneElForProvider.dataset && paneElForProvider.dataset.provider;
|
|
11747
|
+
if (paneProvider === 'codex') { // gsd:provider-literal-allowed (per-provider dispatch)
|
|
11748
|
+
items.push({ type: 'sep' });
|
|
11749
|
+
items.push({
|
|
11750
|
+
label: 'Codex settings',
|
|
11751
|
+
icon: '🧠',
|
|
11752
|
+
submenu: this._buildCodexPaneMenu(slotIdx, tp),
|
|
11753
|
+
});
|
|
11754
|
+
}
|
|
11755
|
+
|
|
11559
11756
|
// ── Pane management ───────────────────────────────────────
|
|
11560
11757
|
items.push({ type: 'sep' });
|
|
11561
11758
|
|
|
@@ -10224,13 +10224,25 @@ html {
|
|
|
10224
10224
|
fades to transparent before reaching the xterm canvas; the xterm
|
|
10225
10225
|
canvas paints over the pane background, so text contrast is never
|
|
10226
10226
|
reduced by the tint (Pitfall F mitigation). */
|
|
10227
|
+
/* Per-design 2026-05-11: pane-provider visual treatment bumped to be
|
|
10228
|
+
noticeable at-a-glance for mixed-provider grids. Combines a 3px solid
|
|
10229
|
+
accent strip at top (was 1px), a deeper top-fade tint (was 24px), and
|
|
10230
|
+
a 2px bottom accent. Whole-pane subtle background tint via color-mix
|
|
10231
|
+
(12% saturation) sits BEHIND the xterm canvas; xterm paints opaque
|
|
10232
|
+
over its own area so text contrast is never reduced (Pitfall F). */
|
|
10227
10233
|
.terminal-pane[data-provider="claude"]:not(.terminal-pane-empty) {
|
|
10228
|
-
border-top:
|
|
10229
|
-
|
|
10234
|
+
border-top: 3px solid var(--provider-claude-accent);
|
|
10235
|
+
border-bottom: 2px solid var(--provider-claude-accent);
|
|
10236
|
+
background:
|
|
10237
|
+
linear-gradient(180deg, var(--provider-claude-tint) 0, transparent 64px),
|
|
10238
|
+
color-mix(in srgb, var(--mauve) 4%, var(--bg-primary));
|
|
10230
10239
|
}
|
|
10231
10240
|
.terminal-pane[data-provider="codex"]:not(.terminal-pane-empty) {
|
|
10232
|
-
border-top:
|
|
10233
|
-
|
|
10241
|
+
border-top: 3px solid var(--provider-codex-accent);
|
|
10242
|
+
border-bottom: 2px solid var(--provider-codex-accent);
|
|
10243
|
+
background:
|
|
10244
|
+
linear-gradient(180deg, var(--provider-codex-tint) 0, transparent 64px),
|
|
10245
|
+
color-mix(in srgb, var(--green) 4%, var(--bg-primary));
|
|
10234
10246
|
}
|
|
10235
10247
|
|
|
10236
10248
|
/* 2px provider stripe on the left edge of the project accordion. */
|
package/src/web/server.js
CHANGED
|
@@ -1503,6 +1503,110 @@ app.delete('/api/sessions/:id', requireAuth, (req, res) => {
|
|
|
1503
1503
|
return res.json({ success: true });
|
|
1504
1504
|
});
|
|
1505
1505
|
|
|
1506
|
+
/**
|
|
1507
|
+
* PUT /api/sessions/:id/provider-settings
|
|
1508
|
+
*
|
|
1509
|
+
* Phase 21 Plan 21-01: persist per-session provider settings.
|
|
1510
|
+
*
|
|
1511
|
+
* Request body: { settings: { ...providerSpecificFields } }
|
|
1512
|
+
* For Codex sessions, accepted keys are: model, sandbox, approvalPolicy,
|
|
1513
|
+
* reasoningEffort, bypassApprovalsAndSandbox, features.
|
|
1514
|
+
*
|
|
1515
|
+
* Validation:
|
|
1516
|
+
* - 404 if session id is unknown
|
|
1517
|
+
* - 400 if body.settings is not a plain object
|
|
1518
|
+
* - 400 if any key is unknown or its value fails the per-key allow-list
|
|
1519
|
+
* - 400 if a free-form string value contains shell-unsafe characters
|
|
1520
|
+
*
|
|
1521
|
+
* Response: 200 with the canonical persisted bundle.
|
|
1522
|
+
*
|
|
1523
|
+
* Note: pty-manager re-reads providerSettings from the store on every
|
|
1524
|
+
* spawn, so a setting change takes effect the next time the pane is
|
|
1525
|
+
* (re)started. The frontend surfaces a toast hint after a successful PUT.
|
|
1526
|
+
*/
|
|
1527
|
+
const CODEX_SANDBOX_VALUES = new Set(['read-only', 'workspace-write', 'danger-full-access']);
|
|
1528
|
+
const CODEX_APPROVAL_VALUES = new Set(['untrusted', 'on-failure', 'on-request', 'never']);
|
|
1529
|
+
const CODEX_EFFORT_VALUES = new Set(['minimal', 'low', 'medium', 'high']);
|
|
1530
|
+
const CODEX_FEATURE_NAME_RE = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
|
|
1531
|
+
const CODEX_MODEL_ID_RE = /^[a-zA-Z0-9._:-]{1,128}$/;
|
|
1532
|
+
const CODEX_ALLOWED_KEYS = new Set([
|
|
1533
|
+
'model', 'sandbox', 'approvalPolicy', 'reasoningEffort',
|
|
1534
|
+
'bypassApprovalsAndSandbox', 'features',
|
|
1535
|
+
]);
|
|
1536
|
+
|
|
1537
|
+
function validateCodexProviderSettings(settings) {
|
|
1538
|
+
if (settings === null || typeof settings !== 'object' || Array.isArray(settings)) {
|
|
1539
|
+
return { ok: false, error: 'settings must be a plain object' };
|
|
1540
|
+
}
|
|
1541
|
+
for (const key of Object.keys(settings)) {
|
|
1542
|
+
if (!CODEX_ALLOWED_KEYS.has(key)) {
|
|
1543
|
+
return { ok: false, error: 'unknown setting key: ' + key };
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
if (settings.model !== undefined) {
|
|
1547
|
+
if (typeof settings.model !== 'string' || !CODEX_MODEL_ID_RE.test(settings.model) || SHELL_UNSAFE.test(settings.model)) {
|
|
1548
|
+
return { ok: false, error: 'invalid model id' };
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
if (settings.sandbox !== undefined && !CODEX_SANDBOX_VALUES.has(settings.sandbox)) {
|
|
1552
|
+
return { ok: false, error: 'invalid sandbox value' };
|
|
1553
|
+
}
|
|
1554
|
+
if (settings.approvalPolicy !== undefined && !CODEX_APPROVAL_VALUES.has(settings.approvalPolicy)) {
|
|
1555
|
+
return { ok: false, error: 'invalid approvalPolicy value' };
|
|
1556
|
+
}
|
|
1557
|
+
if (settings.reasoningEffort !== undefined && !CODEX_EFFORT_VALUES.has(settings.reasoningEffort)) {
|
|
1558
|
+
return { ok: false, error: 'invalid reasoningEffort value' };
|
|
1559
|
+
}
|
|
1560
|
+
if (settings.bypassApprovalsAndSandbox !== undefined && typeof settings.bypassApprovalsAndSandbox !== 'boolean') {
|
|
1561
|
+
return { ok: false, error: 'bypassApprovalsAndSandbox must be boolean' };
|
|
1562
|
+
}
|
|
1563
|
+
if (settings.features !== undefined) {
|
|
1564
|
+
if (!Array.isArray(settings.features)) {
|
|
1565
|
+
return { ok: false, error: 'features must be an array' };
|
|
1566
|
+
}
|
|
1567
|
+
for (const name of settings.features) {
|
|
1568
|
+
if (typeof name !== 'string' || !CODEX_FEATURE_NAME_RE.test(name) || SHELL_UNSAFE.test(name)) {
|
|
1569
|
+
return { ok: false, error: 'invalid feature name: ' + name };
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
return { ok: true };
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
app.put('/api/sessions/:id/provider-settings', requireAuth, (req, res) => {
|
|
1577
|
+
const store = getStore();
|
|
1578
|
+
const sessionId = req.params.id;
|
|
1579
|
+
const session = store.getSession(sessionId);
|
|
1580
|
+
if (!session) {
|
|
1581
|
+
return res.status(404).json({ error: 'Session not found.' });
|
|
1582
|
+
}
|
|
1583
|
+
const settings = req.body && req.body.settings;
|
|
1584
|
+
if (settings === undefined || settings === null || typeof settings !== 'object' || Array.isArray(settings)) {
|
|
1585
|
+
return res.status(400).json({ error: 'settings must be a plain object' });
|
|
1586
|
+
}
|
|
1587
|
+
const providerId = session.provider || 'claude'; // gsd:provider-literal-allowed (back-compat default; tag for un-tagged legacy sessions)
|
|
1588
|
+
// Per-provider validation. Today only Codex has a settings surface; Claude
|
|
1589
|
+
// gets a 400 until a future plan defines its bundle shape.
|
|
1590
|
+
if (providerId === 'codex') { // gsd:provider-literal-allowed
|
|
1591
|
+
const check = validateCodexProviderSettings(settings);
|
|
1592
|
+
if (!check.ok) {
|
|
1593
|
+
return res.status(400).json({ error: check.error });
|
|
1594
|
+
}
|
|
1595
|
+
} else {
|
|
1596
|
+
return res.status(400).json({ error: 'provider does not accept provider-settings: ' + providerId });
|
|
1597
|
+
}
|
|
1598
|
+
const updated = store.updateSessionProviderSettings(sessionId, providerId, settings);
|
|
1599
|
+
if (!updated) {
|
|
1600
|
+
return res.status(404).json({ error: 'Session not found.' });
|
|
1601
|
+
}
|
|
1602
|
+
return res.json({
|
|
1603
|
+
success: true,
|
|
1604
|
+
sessionId,
|
|
1605
|
+
provider: providerId,
|
|
1606
|
+
settings: updated.providerSettings[providerId],
|
|
1607
|
+
});
|
|
1608
|
+
});
|
|
1609
|
+
|
|
1506
1610
|
/**
|
|
1507
1611
|
* POST /api/sessions/:id/start
|
|
1508
1612
|
* Launch the session process and mark it as recently used.
|