@bridge4dev/runner 0.31.0 → 0.33.0
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/adapters/claude.js +160 -45
- package/dist/adapters/codex.js +40 -14
- package/dist/adapters/types.d.ts +16 -1
- package/dist/index.js +13 -0
- package/dist/policy.d.ts +52 -1
- package/dist/policy.js +387 -53
- package/dist/protocol.d.ts +148 -18
- package/dist/protocol.js +38 -0
- package/dist/supervisor.js +102 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/adapters/claude.js
CHANGED
|
@@ -4,7 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import { AsyncQueue } from '../async-queue.js';
|
|
5
5
|
import { log } from '../log.js';
|
|
6
6
|
import { mcpConfigPath } from '../paths.js';
|
|
7
|
-
import { evaluateToolUse, maskSecrets, maskString } from '../policy.js';
|
|
7
|
+
import { evaluateToolUse, maskSecrets, maskString, } from '../policy.js';
|
|
8
8
|
import { availableModes, MODE_REFUSED_TEXT, MODE_WITHDRAWN_TEXT, } from './types.js';
|
|
9
9
|
import { answerSummary, answerValue, discussMessage, invalidationMessage, mirrorOptions, newAskId, MAX_OPTIONS, MAX_QUESTIONS, OPTION_TEXT_LIMIT, QUESTION_TEXT_LIMIT, } from './questions.js';
|
|
10
10
|
// Claude adapter over the Agent SDK. Three live-verified gotchas (plan §2):
|
|
@@ -138,22 +138,49 @@ const MODE_TO_PERMISSION = {
|
|
|
138
138
|
auto: 'default',
|
|
139
139
|
full: 'bypassPermissions',
|
|
140
140
|
};
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
141
|
+
/**
|
|
142
|
+
* DevBridge's own rules — composed per session since session 18.
|
|
143
|
+
*
|
|
144
|
+
* It was a flat constant, and two of its lines were untrue for most sessions.
|
|
145
|
+
* «a dedicated git worktree on a session branch» is false in `workMode: DIRECT`,
|
|
146
|
+
* which has been the DEFAULT since session 16; and the push sentence is now
|
|
147
|
+
* only true when the project has «Принудительно запретить push» switched on.
|
|
148
|
+
*
|
|
149
|
+
* A system-prompt line that does not match the rule underneath it is worse than
|
|
150
|
+
* no line: the agent plans around a restriction that is not there, or walks
|
|
151
|
+
* into one it was told did not exist.
|
|
152
|
+
*/
|
|
153
|
+
function systemAppendFor(spec) {
|
|
154
|
+
const pushBanned = spec.gitPolicy?.agentPushBan !== false;
|
|
155
|
+
const guarded = spec.gitPolicy?.agentProtectedBranches ?? ['main', 'master'];
|
|
156
|
+
return [
|
|
157
|
+
'You are running inside a DevBridge dev session, controlled from the DevBridge dashboard.',
|
|
158
|
+
'Rules:',
|
|
159
|
+
'- Work ONLY inside the current working directory.',
|
|
160
|
+
// Session 13: pushing was refused outright by layer 1 in every trust mode,
|
|
161
|
+
// and saying so here saved the agent a turn spent discovering it. Session
|
|
162
|
+
// 18 makes it the project's decision — so the sentence has to follow the
|
|
163
|
+
// decision, which is the whole of what the owner asked for: switch it off
|
|
164
|
+
// and nothing about push is added here at all.
|
|
165
|
+
pushBanned
|
|
166
|
+
? '- Commit your work in the current branch with clear messages. You cannot push: `git push` is blocked for this project. A human presses «Push» and «Apply» in DevBridge when the branch is ready.'
|
|
167
|
+
: guarded.length > 0
|
|
168
|
+
? // Not a restriction invented here: it is the rule layer 1 will apply,
|
|
169
|
+
// stated in advance. The branch names come from the project's own
|
|
170
|
+
// settings and are already validated as git refs by the API and the
|
|
171
|
+
// protocol schema, so nothing user-typed reaches this text unchecked.
|
|
172
|
+
`- Commit your work in the current branch with clear messages. You may push, except to these protected branches: ${guarded.join(', ')}. Name the branch explicitly — \`git push <remote> <branch>\`.`
|
|
173
|
+
: '- Commit your work in the current branch with clear messages.',
|
|
174
|
+
'- If DevBridge MCP tools (mcp__devbridge__*) are available and the task mentions tickets: fetch the ticket first, set its status to IN_PROGRESS when you start and READY_FOR_REVIEW when your implementation is complete, and leave a short summary comment.',
|
|
175
|
+
'- The user is not in a terminal, but they DO answer: when you need a decision, use the AskUserQuestion tool. It is rendered as a card in the DevBridge dashboard and the call waits — however long it takes — until a human answers it. Only ask in plain text if the tool is unavailable.',
|
|
176
|
+
'- Never decide for the user when you asked them a question. If the tool comes back saying the question was withdrawn, stop and wait rather than guessing.',
|
|
177
|
+
'- Never print secrets (tokens, API keys, private keys) in your output.',
|
|
178
|
+
].join('\n');
|
|
179
|
+
}
|
|
154
180
|
/** DevBridge's own rules, then whatever this workspace adds (session 13). */
|
|
155
|
-
function composeSystemAppend(
|
|
156
|
-
|
|
181
|
+
function composeSystemAppend(spec) {
|
|
182
|
+
const base = systemAppendFor(spec);
|
|
183
|
+
return spec.workspaceContext ? `${base}\n\n${spec.workspaceContext}` : base;
|
|
157
184
|
}
|
|
158
185
|
/**
|
|
159
186
|
* The five levels the Agent SDK declares, and the one gate that keeps a
|
|
@@ -210,12 +237,24 @@ const TASK_LIST_CAP = 20;
|
|
|
210
237
|
* `DevSessionEvent`, so the tray coalesces rather than narrating.
|
|
211
238
|
*/
|
|
212
239
|
const TASK_PUBLISH_INTERVAL_MS = 1_500;
|
|
213
|
-
/**
|
|
240
|
+
/**
|
|
241
|
+
* What the agent calls a finished task, mapped onto the tray's three states.
|
|
242
|
+
*
|
|
243
|
+
* Every way a task can STOP has to be named here, not just the two happy ones.
|
|
244
|
+
* An unlisted outcome fell through to `running`, which used to be merely
|
|
245
|
+
* cosmetic — a cancelled subagent sat in the tray spinning until the live set
|
|
246
|
+
* happened to drop it. Since the turn counters are derived from these statuses
|
|
247
|
+
* (#147) it is no longer cosmetic: a settled row degrading back to `running`
|
|
248
|
+
* would make «7 of 21 done» step backwards to «6 of 21 done» on screen.
|
|
249
|
+
*/
|
|
214
250
|
function taskStatus(raw) {
|
|
215
251
|
if (raw === 'completed')
|
|
216
252
|
return 'done';
|
|
217
253
|
if (raw === 'failed' || raw === 'killed')
|
|
218
254
|
return 'failed';
|
|
255
|
+
if (raw === 'cancelled' || raw === 'canceled' || raw === 'timed_out' || raw === 'aborted') {
|
|
256
|
+
return 'failed';
|
|
257
|
+
}
|
|
219
258
|
return 'running';
|
|
220
259
|
}
|
|
221
260
|
/**
|
|
@@ -311,9 +350,18 @@ class ClaudeSession {
|
|
|
311
350
|
tasks = new Map();
|
|
312
351
|
/** Ids currently in the agent's live set — the tray shows exactly these. */
|
|
313
352
|
liveTaskIds = [];
|
|
314
|
-
/**
|
|
315
|
-
|
|
316
|
-
|
|
353
|
+
/**
|
|
354
|
+
* Which turn is being counted (#147).
|
|
355
|
+
*
|
|
356
|
+
* There are no `started` / `done` accumulators any more, and that is the
|
|
357
|
+
* whole fix. Two counters with different lifetimes could disagree, and did:
|
|
358
|
+
* `endTaskTurn` zeroed both while deliberately keeping the ROWS of tasks
|
|
359
|
+
* still running, so a background shell that outlived its turn was counted as
|
|
360
|
+
* finished in the next one without ever having been counted as started —
|
|
361
|
+
* «22 of 21 done». Both numbers are now derived from this map in one pass, so
|
|
362
|
+
* `done <= total` holds by cardinality rather than by clamping.
|
|
363
|
+
*/
|
|
364
|
+
turnEpoch = 0;
|
|
317
365
|
taskPublishTimer = null;
|
|
318
366
|
taskPublishedAt = 0;
|
|
319
367
|
/** Last published snapshot, minus the ages — see `flushTasks` (QA-111 M4). */
|
|
@@ -436,7 +484,7 @@ class ClaudeSession {
|
|
|
436
484
|
systemPrompt: {
|
|
437
485
|
type: 'preset',
|
|
438
486
|
preset: 'claude_code',
|
|
439
|
-
append: composeSystemAppend(spec
|
|
487
|
+
append: composeSystemAppend(spec),
|
|
440
488
|
},
|
|
441
489
|
canUseTool: (toolName, input, opts) => this.onCanUseTool(toolName, input, opts),
|
|
442
490
|
// Ticket #113: a running subagent forks its own conversation every ~30s
|
|
@@ -874,6 +922,12 @@ class ClaudeSession {
|
|
|
874
922
|
? {}
|
|
875
923
|
: { agentAutoCommit: this.spec.agentAutoCommit }),
|
|
876
924
|
...(this.spec.agentPromptFile ? { agentPromptFile: this.spec.agentPromptFile } : {}),
|
|
925
|
+
// The fourth entry point into `evaluateToolUse`, and it was the one
|
|
926
|
+
// that did not carry the git policy (QA-134 MINOR-1). Safe direction —
|
|
927
|
+
// without the fields everything resolves to «refused» and this function
|
|
928
|
+
// only ever releases — but a project that allows `git clean` would have
|
|
929
|
+
// left a parked card unreleased in Claude while Codex released it.
|
|
930
|
+
...(this.spec.gitPolicy ?? {}),
|
|
877
931
|
worktreePath: this.spec.cwd,
|
|
878
932
|
});
|
|
879
933
|
if (verdict.decision !== 'allow')
|
|
@@ -899,6 +953,17 @@ class ClaudeSession {
|
|
|
899
953
|
this.spec.trustMode = policy.trustMode;
|
|
900
954
|
if (policy.agentAutoCommit !== undefined)
|
|
901
955
|
this.spec.agentAutoCommit = policy.agentAutoCommit;
|
|
956
|
+
// Replaced whole (session 18). The four fields are one decision and the API
|
|
957
|
+
// always sends them together; merging them one by one is how «push is
|
|
958
|
+
// allowed now» could arrive while a stale protected list stayed behind.
|
|
959
|
+
//
|
|
960
|
+
// Only the RULES change live. The system-prompt sentence composed at launch
|
|
961
|
+
// stays as it was until the session is restarted — telling an agent
|
|
962
|
+
// mid-turn that a rule it was given no longer applies is a bigger surprise
|
|
963
|
+
// than letting it discover the refusal is gone, and the rule is what
|
|
964
|
+
// actually enforces anything.
|
|
965
|
+
if (policy.gitPolicy !== undefined)
|
|
966
|
+
this.spec.gitPolicy = policy.gitPolicy;
|
|
902
967
|
if (!trustChanged)
|
|
903
968
|
return;
|
|
904
969
|
// The manager tightened to STRICT while this session was running with
|
|
@@ -1013,6 +1078,18 @@ class ClaudeSession {
|
|
|
1013
1078
|
switch (msg.subtype) {
|
|
1014
1079
|
case 'background_tasks_changed': {
|
|
1015
1080
|
const rows = Array.isArray(msg['tasks']) ? msg['tasks'] : [];
|
|
1081
|
+
// The level signal is the authority on what EXISTS, so it is also the
|
|
1082
|
+
// right place to forget an ambient id (#147, QA-131 m3). Without this
|
|
1083
|
+
// the set only ever shrank on a settling `task_notification`, and a
|
|
1084
|
+
// housekeeping task that is killed, or that reports a status this
|
|
1085
|
+
// runner does not recognise, left its id remembered for the life of the
|
|
1086
|
+
// session — where a reused id would be invisible forever, in the tray
|
|
1087
|
+
// and in the counters, with nothing on screen to explain why.
|
|
1088
|
+
const announced = new Set(rows.map((row) => (typeof row['task_id'] === 'string' ? row['task_id'] : '')));
|
|
1089
|
+
for (const id of [...this.skipTaskIds]) {
|
|
1090
|
+
if (!announced.has(id))
|
|
1091
|
+
this.skipTaskIds.delete(id);
|
|
1092
|
+
}
|
|
1016
1093
|
this.liveTaskIds = [];
|
|
1017
1094
|
for (const row of rows) {
|
|
1018
1095
|
const id = typeof row['task_id'] === 'string' ? row['task_id'] : null;
|
|
@@ -1067,7 +1144,11 @@ class ClaudeSession {
|
|
|
1067
1144
|
}
|
|
1068
1145
|
case 'task_progress': {
|
|
1069
1146
|
const id = typeof msg['task_id'] === 'string' ? msg['task_id'] : null;
|
|
1070
|
-
|
|
1147
|
+
// Housekeeping stays hidden here too. `task_started` deletes an ambient
|
|
1148
|
+
// row from the map, and without this guard the next progress frame
|
|
1149
|
+
// simply created it again — as a visible row, and as work in the turn's
|
|
1150
|
+
// denominator (#147).
|
|
1151
|
+
if (!id || this.skipTaskIds.has(id))
|
|
1071
1152
|
break;
|
|
1072
1153
|
this.touchTask(id, {
|
|
1073
1154
|
...(typeof msg['description'] === 'string' ? { title: msg['description'] } : {}),
|
|
@@ -1097,6 +1178,16 @@ class ClaudeSession {
|
|
|
1097
1178
|
const id = typeof msg['task_id'] === 'string' ? msg['task_id'] : null;
|
|
1098
1179
|
if (!id)
|
|
1099
1180
|
break;
|
|
1181
|
+
if (this.skipTaskIds.has(id)) {
|
|
1182
|
+
// Same guard as `task_progress`, plus the id's retirement: a settled
|
|
1183
|
+
// ambient task can never re-enter the live set, so this is the moment
|
|
1184
|
+
// the set can forget it without letting it back in (#147).
|
|
1185
|
+
if (taskStatus(typeof msg['status'] === 'string' ? msg['status'] : 'completed') !==
|
|
1186
|
+
'running') {
|
|
1187
|
+
this.skipTaskIds.delete(id);
|
|
1188
|
+
}
|
|
1189
|
+
break;
|
|
1190
|
+
}
|
|
1100
1191
|
this.touchTask(id, {
|
|
1101
1192
|
status: taskStatus(typeof msg['status'] === 'string' ? msg['status'] : 'completed'),
|
|
1102
1193
|
...(typeof msg['summary'] === 'string' ? { summary: msg['summary'] } : {}),
|
|
@@ -1129,31 +1220,29 @@ class ClaudeSession {
|
|
|
1129
1220
|
touchTask(id, patch) {
|
|
1130
1221
|
const existing = this.tasks.get(id);
|
|
1131
1222
|
if (existing) {
|
|
1132
|
-
const
|
|
1223
|
+
const settledAs = existing.status === 'running' ? null : existing.status;
|
|
1133
1224
|
Object.assign(existing, patch);
|
|
1134
1225
|
if (patch.title)
|
|
1135
1226
|
existing.title = truncate(patch.title, TASK_TITLE_LIMIT);
|
|
1136
1227
|
if (patch.summary)
|
|
1137
1228
|
existing.summary = truncate(patch.summary, TASK_TITLE_LIMIT);
|
|
1138
|
-
//
|
|
1139
|
-
//
|
|
1140
|
-
//
|
|
1141
|
-
|
|
1142
|
-
|
|
1229
|
+
// Settling is one-way. A task that has stopped cannot start again under
|
|
1230
|
+
// the same id: the only messages that could say so are a late
|
|
1231
|
+
// `task_updated` carrying a status this runner does not recognise, or a
|
|
1232
|
+
// frame redelivered after a reconnect — neither of which is news that the
|
|
1233
|
+
// work resumed. Letting either through would make the derived `done`
|
|
1234
|
+
// count downwards on screen.
|
|
1235
|
+
if (settledAs && existing.status === 'running')
|
|
1236
|
+
existing.status = settledAs;
|
|
1143
1237
|
return;
|
|
1144
1238
|
}
|
|
1145
|
-
this.turnTasksStarted += 1;
|
|
1146
|
-
const status = patch.status ?? 'running';
|
|
1147
|
-
// A row that arrives already finished (a notification for a task we never
|
|
1148
|
-
// saw start) counts as both, or `done` could exceed nothing at all.
|
|
1149
|
-
if (status !== 'running')
|
|
1150
|
-
this.turnTasksDone += 1;
|
|
1151
1239
|
this.tasks.set(id, {
|
|
1152
1240
|
...patch,
|
|
1153
1241
|
id,
|
|
1154
1242
|
kind: patch.kind ?? 'task',
|
|
1155
1243
|
title: truncate(patch.title ?? 'Working…', TASK_TITLE_LIMIT),
|
|
1156
|
-
status,
|
|
1244
|
+
status: patch.status ?? 'running',
|
|
1245
|
+
turnEpoch: this.turnEpoch,
|
|
1157
1246
|
startedAt: Date.now(),
|
|
1158
1247
|
// The only free-text field an agent writes with no length of its own:
|
|
1159
1248
|
// a subagent's closing `summary` runs to kilobytes, and twenty of them
|
|
@@ -1201,13 +1290,31 @@ class ClaudeSession {
|
|
|
1201
1290
|
// this machine's clock and the browser's is a different one — subtracting
|
|
1202
1291
|
// across them put the dev server's clock skew straight into the number,
|
|
1203
1292
|
// so a host ten minutes behind showed «10m 03s» on a task one second old.
|
|
1204
|
-
.map((
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1293
|
+
.map(({ turnEpoch: _turnEpoch, ...task }) => ({
|
|
1294
|
+
...task,
|
|
1295
|
+
ageMs: Math.max(0, now - task.startedAt),
|
|
1296
|
+
}));
|
|
1297
|
+
// Both numbers, one pass, one map (#147). `done` counts a SUBSET of what
|
|
1298
|
+
// `total` counts, so `done <= total` is a property of set cardinality and
|
|
1299
|
+
// cannot be broken by a message ordering, a redelivery, a re-title, an
|
|
1300
|
+
// ambient row being deleted or a turn boundary. There is no pair of
|
|
1301
|
+
// counters to keep in agreement, because there is no pair.
|
|
1302
|
+
//
|
|
1303
|
+
// Tasks that outlived an earlier turn keep that turn's epoch: they still
|
|
1304
|
+
// render as live rows — background work outliving its turn is the whole
|
|
1305
|
+
// point of the tray — but they belong to neither number here, exactly as
|
|
1306
|
+
// this file's own doctrine says («done/total are per-turn by definition.
|
|
1307
|
+
// The live set does NOT»).
|
|
1308
|
+
let total = 0;
|
|
1309
|
+
let done = 0;
|
|
1310
|
+
for (const task of this.tasks.values()) {
|
|
1311
|
+
if (task.turnEpoch !== this.turnEpoch)
|
|
1312
|
+
continue;
|
|
1313
|
+
total += 1;
|
|
1314
|
+
if (task.status !== 'running')
|
|
1315
|
+
done += 1;
|
|
1316
|
+
}
|
|
1317
|
+
const payload = { type: 'agent_tasks', tasks, done, total };
|
|
1211
1318
|
// A frame that says exactly what the last one said is not worth a row in
|
|
1212
1319
|
// the session feed (QA-111 M4). `task_progress` fires every ~30s per
|
|
1213
1320
|
// running subagent and usually carries nothing new, and with twenty of
|
|
@@ -1250,9 +1357,13 @@ class ClaudeSession {
|
|
|
1250
1357
|
if (!live.has(id))
|
|
1251
1358
|
this.tasks.delete(id);
|
|
1252
1359
|
}
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1360
|
+
// `skipTaskIds` is NOT cleared here any more (#147). An ambient task the
|
|
1361
|
+
// SDK asked consumers to hide can span a turn boundary, and clearing the
|
|
1362
|
+
// set let its id back into the live list on the next
|
|
1363
|
+
// `background_tasks_changed` — where it was drawn as a tray row and counted
|
|
1364
|
+
// as work. The id is forgotten when that task settles instead, which is the
|
|
1365
|
+
// moment it stops being able to come back.
|
|
1366
|
+
this.turnEpoch += 1;
|
|
1256
1367
|
// Straight through `flushTasks` rather than an empty frame of its own: the
|
|
1257
1368
|
// counters changed, so the fingerprint differs and it will publish — and
|
|
1258
1369
|
// what it publishes is the truth about what is still running.
|
|
@@ -1302,6 +1413,10 @@ class ClaudeSession {
|
|
|
1302
1413
|
? {}
|
|
1303
1414
|
: { agentAutoCommit: this.spec.agentAutoCommit }),
|
|
1304
1415
|
...(this.spec.agentPromptFile ? { agentPromptFile: this.spec.agentPromptFile } : {}),
|
|
1416
|
+
// Session 18. Spread WHOLE rather than field by field: `resolveGitPolicy`
|
|
1417
|
+
// gives every absent field its safe reading, and an object assembled here
|
|
1418
|
+
// with three of the four would be a fourth place to get a polarity wrong.
|
|
1419
|
+
...(this.spec.gitPolicy ?? {}),
|
|
1305
1420
|
worktreePath: this.spec.cwd,
|
|
1306
1421
|
});
|
|
1307
1422
|
if (verdict.decision === 'allow') {
|
package/dist/adapters/codex.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { AsyncQueue } from '../async-queue.js';
|
|
2
2
|
import { log } from '../log.js';
|
|
3
|
-
import { evaluateToolUse, maskSecrets, maskString } from '../policy.js';
|
|
3
|
+
import { evaluateToolUse, maskSecrets, maskString, } from '../policy.js';
|
|
4
4
|
import { RUNNER_VERSION } from '../version.js';
|
|
5
5
|
import { repairCodexAuth } from './codex-home.js';
|
|
6
6
|
import { AppServerClient, asRecord, num, str } from './codex-protocol.js';
|
|
@@ -59,18 +59,36 @@ const MODE_POLICY = {
|
|
|
59
59
|
// exactly like Claude's bypassPermissions. The dashboard says so.
|
|
60
60
|
full: { approvalPolicy: 'never', sandbox: 'danger-full-access', plan: false },
|
|
61
61
|
};
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
62
|
+
/**
|
|
63
|
+
* DevBridge's own rules — composed per session since session 18, and kept
|
|
64
|
+
* deliberately in step with `systemAppendFor` in the Claude adapter.
|
|
65
|
+
*
|
|
66
|
+
* The two texts were byte-identical on the push line and both were wrong in the
|
|
67
|
+
* same two ways: «a dedicated git worktree on a session branch» is false in
|
|
68
|
+
* `workMode: DIRECT` (the default since session 16), and the push sentence is
|
|
69
|
+
* only true when the project has «Принудительно запретить push» switched on.
|
|
70
|
+
*/
|
|
71
|
+
function systemAppendFor(spec) {
|
|
72
|
+
const pushBanned = spec.gitPolicy?.agentPushBan !== false;
|
|
73
|
+
const guarded = spec.gitPolicy?.agentProtectedBranches ?? ['main', 'master'];
|
|
74
|
+
return [
|
|
75
|
+
'You are running inside a DevBridge dev session, controlled from the DevBridge dashboard.',
|
|
76
|
+
'Rules:',
|
|
77
|
+
'- Work ONLY inside the current working directory.',
|
|
78
|
+
pushBanned
|
|
79
|
+
? '- Commit your work in the current branch with clear messages. You cannot push: `git push` is blocked for this project. A human presses «Push» and «Apply» in DevBridge when the branch is ready.'
|
|
80
|
+
: guarded.length > 0
|
|
81
|
+
? `- Commit your work in the current branch with clear messages. You may push, except to these protected branches: ${guarded.join(', ')}. Name the branch explicitly — \`git push <remote> <branch>\`.`
|
|
82
|
+
: '- Commit your work in the current branch with clear messages.',
|
|
83
|
+
'- If DevBridge MCP tools are available and the task mentions tickets: fetch the ticket first, set its status to IN_PROGRESS when you start and READY_FOR_REVIEW when your implementation is complete, and leave a short summary comment.',
|
|
84
|
+
'- The user is not in a terminal: if you need a decision, use your question tool or ask in plain text and end your turn.',
|
|
85
|
+
'- Never print secrets (tokens, API keys, private keys) in your output.',
|
|
86
|
+
].join('\n');
|
|
87
|
+
}
|
|
71
88
|
/** DevBridge's own rules, then whatever this workspace adds (session 13). */
|
|
72
|
-
function composeSystemAppend(
|
|
73
|
-
|
|
89
|
+
function composeSystemAppend(spec) {
|
|
90
|
+
const base = systemAppendFor(spec);
|
|
91
|
+
return spec.workspaceContext ? `${base}\n\n${spec.workspaceContext}` : base;
|
|
74
92
|
}
|
|
75
93
|
// Allowlist, not denylist: whatever secrets live in the daemon's environment
|
|
76
94
|
// must not reach the agent process. OPENAI_API_KEY is absent by design — it
|
|
@@ -411,7 +429,7 @@ class CodexSession {
|
|
|
411
429
|
cwd: this.spec.cwd,
|
|
412
430
|
approvalPolicy: policy.approvalPolicy,
|
|
413
431
|
sandbox: policy.sandbox,
|
|
414
|
-
developerInstructions: composeSystemAppend(this.spec
|
|
432
|
+
developerInstructions: composeSystemAppend(this.spec),
|
|
415
433
|
...(this.model ? { model: this.model } : {}),
|
|
416
434
|
...(this.spec.mcp ? { config: this.mcpOverlay() } : {}),
|
|
417
435
|
};
|
|
@@ -479,7 +497,7 @@ class CodexSession {
|
|
|
479
497
|
settings: {
|
|
480
498
|
model: this.model ?? this.threadModel ?? 'gpt-5.5',
|
|
481
499
|
reasoning_effort: null,
|
|
482
|
-
developer_instructions: composeSystemAppend(this.spec
|
|
500
|
+
developer_instructions: composeSystemAppend(this.spec),
|
|
483
501
|
},
|
|
484
502
|
};
|
|
485
503
|
this.lastCollabMode = wantCollab;
|
|
@@ -607,6 +625,10 @@ class CodexSession {
|
|
|
607
625
|
this.spec.trustMode = policy.trustMode;
|
|
608
626
|
if (policy.agentAutoCommit !== undefined)
|
|
609
627
|
this.spec.agentAutoCommit = policy.agentAutoCommit;
|
|
628
|
+
// Replaced whole — see the note in the Claude adapter. Only the RULES move
|
|
629
|
+
// live; the system-prompt sentence composed at launch stays until restart.
|
|
630
|
+
if (policy.gitPolicy !== undefined)
|
|
631
|
+
this.spec.gitPolicy = policy.gitPolicy;
|
|
610
632
|
if (!trustChanged)
|
|
611
633
|
return;
|
|
612
634
|
// A manager tightened the project out from under a session that is running
|
|
@@ -670,6 +692,8 @@ class CodexSession {
|
|
|
670
692
|
? {}
|
|
671
693
|
: { agentAutoCommit: this.spec.agentAutoCommit }),
|
|
672
694
|
...(this.spec.agentPromptFile ? { agentPromptFile: this.spec.agentPromptFile } : {}),
|
|
695
|
+
// Session 18 — spread whole; see the note in the Claude adapter.
|
|
696
|
+
...(this.spec.gitPolicy ?? {}),
|
|
673
697
|
worktreePath: this.spec.cwd,
|
|
674
698
|
});
|
|
675
699
|
if (verdict.decision !== 'allow')
|
|
@@ -790,6 +814,8 @@ class CodexSession {
|
|
|
790
814
|
? {}
|
|
791
815
|
: { agentAutoCommit: this.spec.agentAutoCommit }),
|
|
792
816
|
...(this.spec.agentPromptFile ? { agentPromptFile: this.spec.agentPromptFile } : {}),
|
|
817
|
+
// Session 18 — spread whole; see the note in the Claude adapter.
|
|
818
|
+
...(this.spec.gitPolicy ?? {}),
|
|
793
819
|
worktreePath: this.spec.cwd,
|
|
794
820
|
});
|
|
795
821
|
if (verdict.decision === 'allow') {
|
package/dist/adapters/types.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { TrustMode } from '../policy.js';
|
|
1
|
+
import type { AgentGitPolicy, TrustMode } from '../policy.js';
|
|
2
2
|
export interface McpConfig {
|
|
3
3
|
url: string;
|
|
4
4
|
token: string;
|
|
@@ -185,6 +185,14 @@ export interface SessionSpec {
|
|
|
185
185
|
* so without this the agent could rewrite its own next system prompt.
|
|
186
186
|
*/
|
|
187
187
|
agentPromptFile?: string;
|
|
188
|
+
/**
|
|
189
|
+
* Session 18: the project's git policy. Rides with `trustMode` and
|
|
190
|
+
* `agentAutoCommit` for the same reason — it lands in the same
|
|
191
|
+
* `PolicyContext` and answers the same question, may this Bash call go
|
|
192
|
+
* through. It also decides one sentence of the system prompt, which is the
|
|
193
|
+
* part `agentAutoCommit` does not do.
|
|
194
|
+
*/
|
|
195
|
+
gitPolicy?: AgentGitPolicy;
|
|
188
196
|
mode?: AgentMode;
|
|
189
197
|
model?: string;
|
|
190
198
|
effort?: string;
|
|
@@ -462,6 +470,13 @@ export interface AgentSession {
|
|
|
462
470
|
setWorkspacePolicy(policy: {
|
|
463
471
|
trustMode?: TrustMode;
|
|
464
472
|
agentAutoCommit?: boolean;
|
|
473
|
+
/**
|
|
474
|
+
* Session 18. Replaced WHOLE, never merged field by field: the API sends
|
|
475
|
+
* all four together precisely so that a change to one cannot leave the
|
|
476
|
+
* runner holding a stale value of another — «push is allowed now» must not
|
|
477
|
+
* arrive without the list of branches it is still not allowed into.
|
|
478
|
+
*/
|
|
479
|
+
gitPolicy?: AgentGitPolicy;
|
|
465
480
|
}): void;
|
|
466
481
|
/** Interrupt the current turn (session stays resumable). */
|
|
467
482
|
interrupt(): Promise<void>;
|
package/dist/index.js
CHANGED
|
@@ -275,6 +275,19 @@ function runnerCapabilities(apiUrlOverride) {
|
|
|
275
275
|
* runner incapable of the setting it honours.
|
|
276
276
|
*/
|
|
277
277
|
agentAutoCommit: true,
|
|
278
|
+
/**
|
|
279
|
+
* Session 18: the agent's git rules come from the PROJECT, not from this
|
|
280
|
+
* binary.
|
|
281
|
+
*
|
|
282
|
+
* `agentPushBan`, `agentProtectedBranches`, `agentAllowForcePush` and
|
|
283
|
+
* `agentAllowDestructiveGit` are read off the workspace and applied on
|
|
284
|
+
* every tool call. Announced so the settings card can warn that a server
|
|
285
|
+
* without it will go on refusing every push whatever the switch says —
|
|
286
|
+
* warn, not refuse: the fleet updates one machine at a time and a manager
|
|
287
|
+
* who cannot record the intention until the last of them is current has
|
|
288
|
+
* been handed a worse problem.
|
|
289
|
+
*/
|
|
290
|
+
agentGitPolicy: true,
|
|
278
291
|
/**
|
|
279
292
|
* Session 16: git is git.
|
|
280
293
|
*
|
package/dist/policy.d.ts
CHANGED
|
@@ -1,6 +1,47 @@
|
|
|
1
1
|
import type { AgentMode } from './adapters/types.js';
|
|
2
2
|
export type TrustMode = 'STRICT' | 'NORMAL' | 'AUTO';
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* The project's git policy for agents (session 18) — one shape, carried
|
|
5
|
+
* unchanged from the API's descriptor through the supervisor and the adapters
|
|
6
|
+
* into `PolicyContext`.
|
|
7
|
+
*
|
|
8
|
+
* A named interface rather than four fields repeated in five places, because
|
|
9
|
+
* these are the fields whose DIRECTION matters: see the polarity note on
|
|
10
|
+
* `agentPushBan`. A copy that drifts is a copy that eventually gets one of
|
|
11
|
+
* them the wrong way round.
|
|
12
|
+
*/
|
|
13
|
+
export interface AgentGitPolicy {
|
|
14
|
+
/**
|
|
15
|
+
* «Принудительно запретить push» — the project's switch.
|
|
16
|
+
*
|
|
17
|
+
* READ AS `!== false`. Not `=== true`, and not truthiness.
|
|
18
|
+
*
|
|
19
|
+
* `undefined` means an API too old to send the field, and there the answer
|
|
20
|
+
* must be what it has always been: refused. This is the OPPOSITE polarity to
|
|
21
|
+
* `agentAutoCommit`, where silence permits — and they sit close together on
|
|
22
|
+
* purpose so that whoever copies one reads why the other differs. Getting it
|
|
23
|
+
* backwards does not fail a test, it silently hands every runner on an old
|
|
24
|
+
* API permission to push.
|
|
25
|
+
*/
|
|
26
|
+
agentPushBan?: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Branches the agent may never push to, whatever `agentPushBan` says.
|
|
29
|
+
*
|
|
30
|
+
* `undefined` means «nobody said», which is `['main', 'master']` — the pair
|
|
31
|
+
* that was hardcoded until this release. An EMPTY ARRAY is a real answer and
|
|
32
|
+
* means «nothing is protected»; the two must not be conflated, which is why
|
|
33
|
+
* nothing here writes `list?.length ? … : DEFAULT`.
|
|
34
|
+
*/
|
|
35
|
+
agentProtectedBranches?: string[];
|
|
36
|
+
/**
|
|
37
|
+
* `undefined` and `false` both mean no. Force into a protected branch is
|
|
38
|
+
* refused whatever this says — the two rules are AND-ed, not OR-ed.
|
|
39
|
+
*/
|
|
40
|
+
agentAllowForcePush?: boolean;
|
|
41
|
+
/** `git reset --hard` and `git clean`. `undefined` and `false` mean no. */
|
|
42
|
+
agentAllowDestructiveGit?: boolean;
|
|
43
|
+
}
|
|
44
|
+
export interface PolicyContext extends AgentGitPolicy {
|
|
4
45
|
trustMode: TrustMode;
|
|
5
46
|
/**
|
|
6
47
|
* The interaction mode the SESSION is in — the dial in the composer, as
|
|
@@ -76,6 +117,16 @@ export declare function isInsideWorktree(p: string, worktreePath: string): boole
|
|
|
76
117
|
* next git invocation. Neither is ever ordinary agent work.
|
|
77
118
|
*/
|
|
78
119
|
export declare function isGitInternalPath(p: string): boolean;
|
|
120
|
+
/**
|
|
121
|
+
* The project's git rules, applied to one Bash command.
|
|
122
|
+
*
|
|
123
|
+
* Returns a refusal, or null when the command has nothing to do with them.
|
|
124
|
+
* Every branch of this function is reachable only when the project has
|
|
125
|
+
* DELIBERATELY switched something on — with the shipped defaults the first
|
|
126
|
+
* check refuses every push and the rest never run, which is byte for byte the
|
|
127
|
+
* behaviour of the four regexes it replaces.
|
|
128
|
+
*/
|
|
129
|
+
export declare function evaluateGitPolicy(command: string, ctx: PolicyContext): PolicyDecision | null;
|
|
79
130
|
export interface RecipeCommandContext {
|
|
80
131
|
/**
|
|
81
132
|
* The docker compose project this preview owns, from `preview.project` in the
|