@peakflo/agent-harness 0.2.9-rc.5 → 0.2.9-rc.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/dist/adapters/claude-code-adapter.js +14 -1
- package/dist/adapters/codex-app-server-adapter.d.ts +20 -0
- package/dist/adapters/codex-app-server-adapter.js +215 -54
- package/dist/adapters/codex-approval-text.d.ts +50 -0
- package/dist/adapters/codex-approval-text.js +366 -0
- package/dist/runtime/session-runner.d.ts +29 -0
- package/dist/runtime/session-runner.js +6 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -121,6 +121,16 @@ The runner never answers a request with "always allow", and does not offer
|
|
|
121
121
|
that answer to the person approving: Codex and Cursor would save it as a
|
|
122
122
|
standing rule in their own configuration.
|
|
123
123
|
|
|
124
|
+
An approval request says what will happen in its first line ("Run: npm test",
|
|
125
|
+
"Write: src/a.ts", "Change: 3 files" with the list), for every kind of request
|
|
126
|
+
Codex can send. "Deny" refuses the action and lets the turn go on, so the agent
|
|
127
|
+
says that it was not allowed; the denied action never runs.
|
|
128
|
+
|
|
129
|
+
A command too long for the card keeps its start and its end, and the card
|
|
130
|
+
says it is not shown in full. A question Codex asks the person is shown as a
|
|
131
|
+
question to answer in words, not as Allow / Deny. An MCP form that needs values
|
|
132
|
+
is declined, because nobody can fill it in from the chat.
|
|
133
|
+
|
|
124
134
|
Cursor reports an account limit as a normal reply ("Upgrade your plan to
|
|
125
135
|
continue"). The runner turns a reply that is only that sentence into a failed
|
|
126
136
|
turn that says it comes from the Cursor account on the runner.
|
|
@@ -1284,7 +1284,20 @@ class ClaudeCodeAdapter {
|
|
|
1284
1284
|
const errName = error instanceof Error ? error.name : '';
|
|
1285
1285
|
const errMsg = error instanceof Error ? error.message : String(error);
|
|
1286
1286
|
const errStack = error instanceof Error ? error.stack : undefined;
|
|
1287
|
-
|
|
1287
|
+
// CHANGED from 20x: the runner stops the session at the end of EVERY
|
|
1288
|
+
// turn (one process per turn), and the SDK reports that stop as a plain
|
|
1289
|
+
// `Error: Operation aborted`. That is the expected end of a finished
|
|
1290
|
+
// turn, not a failure: it used to be logged as "Stream error" with a
|
|
1291
|
+
// stack trace after every successful turn, and marked the session failed.
|
|
1292
|
+
// Both conditions: the adapter asked for the stop AND the error is the
|
|
1293
|
+
// SDK's own word for a stopped process. An unrelated failure that
|
|
1294
|
+
// happens to arrive after the stop is still reported below.
|
|
1295
|
+
const stoppedByUs = session.abortController?.signal.aborted === true &&
|
|
1296
|
+
/\babort(ed)?\b/i.test(errMsg);
|
|
1297
|
+
if (stoppedByUs) {
|
|
1298
|
+
console.log('[ClaudeCodeAdapter] Stream stopped at the end of the turn');
|
|
1299
|
+
}
|
|
1300
|
+
else if (errName === 'AbortError' || errMsg.includes('aborted by user')) {
|
|
1288
1301
|
console.log('[ClaudeCodeAdapter] Stream aborted by user');
|
|
1289
1302
|
// Don't set error status - this is normal when sending a new message
|
|
1290
1303
|
}
|
|
@@ -29,6 +29,11 @@ export declare function isStandingApprovalDecision(optionId: string): boolean;
|
|
|
29
29
|
* any file change — is sent to the client as an approval request before it
|
|
30
30
|
* runs. That is what "Ask before each action" says.
|
|
31
31
|
*/
|
|
32
|
+
/**
|
|
33
|
+
* The fields an MCP form asks the person to fill in. Empty for a form with no
|
|
34
|
+
* fields, and for a request that is not a form (`mode: 'url'`).
|
|
35
|
+
*/
|
|
36
|
+
export declare function elicitationFormFields(params: Record<string, unknown>): string[];
|
|
32
37
|
export declare function codexApprovalPolicy(config: Pick<SessionConfig, 'permissionMode'>): 'never' | 'untrusted';
|
|
33
38
|
interface PendingApproval {
|
|
34
39
|
requestId: string | number;
|
|
@@ -40,6 +45,8 @@ interface PendingApproval {
|
|
|
40
45
|
kind: string;
|
|
41
46
|
}>;
|
|
42
47
|
responseKind: 'execCommand' | 'commandExecution' | 'fileChange' | 'permissions' | 'elicitation' | 'userInput' | 'generic';
|
|
48
|
+
/** What `item/permissions/requestApproval` asked for; echoed back on allow. */
|
|
49
|
+
requestedPermissions?: unknown;
|
|
43
50
|
}
|
|
44
51
|
export declare class CodexAppServerAdapter implements CodingAgentAdapter {
|
|
45
52
|
private sessions;
|
|
@@ -87,6 +94,19 @@ export declare class CodexAppServerAdapter implements CodingAgentAdapter {
|
|
|
87
94
|
private setupStdoutParser;
|
|
88
95
|
private handleRpcMessage;
|
|
89
96
|
private handleServerRequest;
|
|
97
|
+
/**
|
|
98
|
+
* ADDED, not in 20x: `item/tool/requestUserInput` IS A QUESTION.
|
|
99
|
+
*
|
|
100
|
+
* It was shown as a permission card, and "Allow" sent the word "accept" to
|
|
101
|
+
* the model as the person's answer. It is now a question part, like the
|
|
102
|
+
* ones Pi asks: the person picks an option or types, and the answer goes
|
|
103
|
+
* back through `respondToQuestion` in the form this request takes
|
|
104
|
+
* (`{ answers: { <question id>: { answers: [text] } } }`). It is asked in
|
|
105
|
+
* every permission mode, bypass included: nobody can answer it but a person.
|
|
106
|
+
*/
|
|
107
|
+
private handleUserInputRequest;
|
|
108
|
+
/** The answer to a question asked with `item/tool/requestUserInput`. */
|
|
109
|
+
respondToQuestion(sessionId: string, answers: Record<string, string>, _config?: SessionConfig, requestId?: string): Promise<boolean>;
|
|
90
110
|
private handleNotification;
|
|
91
111
|
private handleApprovalRequest;
|
|
92
112
|
private buildApprovalResponse;
|
|
@@ -43,6 +43,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
43
43
|
exports.CodexAppServerAdapter = void 0;
|
|
44
44
|
exports.unwrapListedThreadItem = unwrapListedThreadItem;
|
|
45
45
|
exports.isStandingApprovalDecision = isStandingApprovalDecision;
|
|
46
|
+
exports.elicitationFormFields = elicitationFormFields;
|
|
46
47
|
exports.codexApprovalPolicy = codexApprovalPolicy;
|
|
47
48
|
/**
|
|
48
49
|
* Extracted verbatim from 20x `src/main/adapters/codex-app-server-adapter.ts`.
|
|
@@ -57,6 +58,7 @@ const os_1 = require("os");
|
|
|
57
58
|
const path_1 = require("path");
|
|
58
59
|
const util_1 = require("util");
|
|
59
60
|
const coding_agent_adapter_1 = require("./coding-agent-adapter");
|
|
61
|
+
const codex_approval_text_1 = require("./codex-approval-text");
|
|
60
62
|
const DEFAULT_CODEX_APP_SERVER_MODEL = 'gpt-5.5';
|
|
61
63
|
/**
|
|
62
64
|
* `thread/items/list` returns each item inside an envelope:
|
|
@@ -105,6 +107,26 @@ function isStandingApprovalDecision(optionId) {
|
|
|
105
107
|
* any file change — is sent to the client as an approval request before it
|
|
106
108
|
* runs. That is what "Ask before each action" says.
|
|
107
109
|
*/
|
|
110
|
+
/**
|
|
111
|
+
* The fields an MCP form asks the person to fill in. Empty for a form with no
|
|
112
|
+
* fields, and for a request that is not a form (`mode: 'url'`).
|
|
113
|
+
*/
|
|
114
|
+
function elicitationFormFields(params) {
|
|
115
|
+
// Only a request that says it is NOT a form is taken at its word. A missing
|
|
116
|
+
// or unknown mode is read from the schema it carries, like a form.
|
|
117
|
+
if (asString(params.mode) === 'url')
|
|
118
|
+
return [];
|
|
119
|
+
const schema = params.requestedSchema;
|
|
120
|
+
if (!isObject(schema)) {
|
|
121
|
+
// A form whose shape this build cannot read is treated as having fields.
|
|
122
|
+
return schema === undefined || schema === null ? [] : ['an unreadable form'];
|
|
123
|
+
}
|
|
124
|
+
const properties = isObject(schema.properties) ? Object.keys(schema.properties) : [];
|
|
125
|
+
const required = Array.isArray(schema.required)
|
|
126
|
+
? schema.required.filter((name) => typeof name === 'string')
|
|
127
|
+
: [];
|
|
128
|
+
return [...new Set([...required, ...properties])];
|
|
129
|
+
}
|
|
108
130
|
function codexApprovalPolicy(config) {
|
|
109
131
|
// `'allow'` is what `mapPermissionMode` makes of the agent's
|
|
110
132
|
// `bypassPermissions` ("Don't ask for permission"), and of nothing else.
|
|
@@ -277,12 +299,6 @@ function isSubpath(parent, child) {
|
|
|
277
299
|
const rel = (0, path_1.relative)((0, path_1.resolve)(parent), (0, path_1.resolve)(child));
|
|
278
300
|
return rel === '' || (!!rel && !rel.startsWith('..') && !(0, path_1.isAbsolute)(rel));
|
|
279
301
|
}
|
|
280
|
-
function summarizeApproval(params, fallback) {
|
|
281
|
-
const command = asString(params.command);
|
|
282
|
-
const reason = asString(params.reason);
|
|
283
|
-
const itemId = asString(params.itemId) || asString(params.callId);
|
|
284
|
-
return [command || fallback, reason, itemId ? `id: ${itemId}` : ''].filter(Boolean).join('\n');
|
|
285
|
-
}
|
|
286
302
|
function normalizeDecisionName(decision) {
|
|
287
303
|
if (typeof decision === 'string')
|
|
288
304
|
return decision;
|
|
@@ -291,24 +307,6 @@ function normalizeDecisionName(decision) {
|
|
|
291
307
|
}
|
|
292
308
|
return 'accept';
|
|
293
309
|
}
|
|
294
|
-
function decisionLabel(decision) {
|
|
295
|
-
switch (decision) {
|
|
296
|
-
case 'accept':
|
|
297
|
-
case 'approved':
|
|
298
|
-
return 'Allow';
|
|
299
|
-
case 'acceptForSession':
|
|
300
|
-
case 'approved_for_session':
|
|
301
|
-
return 'Allow for Session';
|
|
302
|
-
case 'decline':
|
|
303
|
-
case 'denied':
|
|
304
|
-
return 'Deny';
|
|
305
|
-
case 'cancel':
|
|
306
|
-
case 'abort':
|
|
307
|
-
return 'Deny and Stop';
|
|
308
|
-
default:
|
|
309
|
-
return decision;
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
310
|
class CodexAppServerAdapter {
|
|
313
311
|
sessions = new Map();
|
|
314
312
|
codexExecutablePath = null;
|
|
@@ -540,14 +538,24 @@ class CodexAppServerAdapter {
|
|
|
540
538
|
if (!approval)
|
|
541
539
|
return;
|
|
542
540
|
const selected = optionId || approval.options.find((option) => approved
|
|
543
|
-
? ['
|
|
544
|
-
: ['
|
|
545
|
-
|
|
541
|
+
? ['accept', 'approved'].includes(option.optionId)
|
|
542
|
+
: ['decline', 'denied'].includes(option.optionId))?.optionId || (
|
|
543
|
+
// The older requests take `approved` / `denied`.
|
|
544
|
+
approval.responseKind === 'execCommand'
|
|
545
|
+
? (approved ? 'approved' : 'denied')
|
|
546
|
+
: (approved ? 'accept' : 'decline'));
|
|
547
|
+
const response = this.buildApprovalResponse(approval.responseKind, selected, approved, approval.requestedPermissions);
|
|
546
548
|
this.sendRpcResponse(session, approval.requestId, response);
|
|
547
549
|
session.pendingApproval = null;
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
550
|
+
// CHANGED from 20x, which marked the session idle on every denial. A
|
|
551
|
+
// denial that continues the turn (`decline`, `denied`) is followed by the
|
|
552
|
+
// model's own answer; calling the session idle here ended the turn before
|
|
553
|
+
// that answer arrived. Only a denial that stops the turn is idle at once.
|
|
554
|
+
// (No button sends `cancel` or `abort` any more; a caller that names one
|
|
555
|
+
// explicitly still gets the stop it asked for.)
|
|
556
|
+
session.status = !approved && (selected === 'cancel' || selected === 'abort')
|
|
557
|
+
? coding_agent_adapter_1.SessionStatusType.IDLE
|
|
558
|
+
: coding_agent_adapter_1.SessionStatusType.BUSY;
|
|
551
559
|
}
|
|
552
560
|
async getRunningTools(sessionId, _config) {
|
|
553
561
|
return Array.from(this.sessions.get(sessionId)?.runningTools.values() || []);
|
|
@@ -816,12 +824,113 @@ class CodexAppServerAdapter {
|
|
|
816
824
|
this.handleApprovalRequest(session, request, params);
|
|
817
825
|
return;
|
|
818
826
|
}
|
|
819
|
-
if (request.method === '
|
|
827
|
+
if (request.method === 'item/tool/requestUserInput') {
|
|
828
|
+
this.handleUserInputRequest(session, request, params);
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
if (request.method === 'mcpServer/elicitation/request') {
|
|
832
|
+
/*
|
|
833
|
+
* CHANGED from 20x: A FORM THAT NEEDS VALUES IS DECLINED, NOT "ALLOWED".
|
|
834
|
+
*
|
|
835
|
+
* An elicitation in form mode asks the person to fill in fields. This
|
|
836
|
+
* runner can relay a yes or a no, not field values, and "Allow" answered
|
|
837
|
+
* with an empty form. A form with fields is therefore declined at once,
|
|
838
|
+
* in every permission mode: the MCP server is told no, rather than
|
|
839
|
+
* being handed values nobody entered. A form with no fields is a plain
|
|
840
|
+
* confirmation (how an MCP tool call asks to run) and is still put to
|
|
841
|
+
* the person.
|
|
842
|
+
*/
|
|
843
|
+
const fields = elicitationFormFields(params);
|
|
844
|
+
if (fields.length > 0) {
|
|
845
|
+
console.warn(`[CodexAppServerAdapter] Declining an MCP form from ${asString(params.serverName) || 'a server'}: ` +
|
|
846
|
+
`it asks for ${fields.join(', ')}, which cannot be entered from here`);
|
|
847
|
+
this.sendRpcResponse(session, request.id, { action: 'decline' });
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
820
850
|
this.handleApprovalRequest(session, request, params);
|
|
821
851
|
return;
|
|
822
852
|
}
|
|
823
853
|
this.sendRpcResponse(session, request.id, {});
|
|
824
854
|
}
|
|
855
|
+
/**
|
|
856
|
+
* ADDED, not in 20x: `item/tool/requestUserInput` IS A QUESTION.
|
|
857
|
+
*
|
|
858
|
+
* It was shown as a permission card, and "Allow" sent the word "accept" to
|
|
859
|
+
* the model as the person's answer. It is now a question part, like the
|
|
860
|
+
* ones Pi asks: the person picks an option or types, and the answer goes
|
|
861
|
+
* back through `respondToQuestion` in the form this request takes
|
|
862
|
+
* (`{ answers: { <question id>: { answers: [text] } } }`). It is asked in
|
|
863
|
+
* every permission mode, bypass included: nobody can answer it but a person.
|
|
864
|
+
*/
|
|
865
|
+
handleUserInputRequest(session, request, params) {
|
|
866
|
+
const asked = (Array.isArray(params.questions) ? params.questions : []).filter(isObject);
|
|
867
|
+
/*
|
|
868
|
+
* A SECRET IS NOT ASKED FOR IN THE CHAT. An answer typed here is stored in
|
|
869
|
+
* the transcript and shown to everyone who can read the chat; a question
|
|
870
|
+
* marked `isSecret` asks for something that must not be. The whole request
|
|
871
|
+
* is left unanswered instead.
|
|
872
|
+
*/
|
|
873
|
+
if (asked.some((question) => question.isSecret === true)) {
|
|
874
|
+
console.warn('[CodexAppServerAdapter] Not asking a question marked secret; it is left unanswered');
|
|
875
|
+
this.sendRpcResponse(session, request.id, { answers: {} });
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
// The card sends each answer back under its header, so headers are made
|
|
879
|
+
// unique here: two questions called the same would get one answer.
|
|
880
|
+
const usedHeaders = new Map();
|
|
881
|
+
const uniqueHeader = (wanted) => {
|
|
882
|
+
const count = (usedHeaders.get(wanted) ?? 0) + 1;
|
|
883
|
+
usedHeaders.set(wanted, count);
|
|
884
|
+
return count === 1 ? wanted : `${wanted} (${count})`;
|
|
885
|
+
};
|
|
886
|
+
const questions = asked
|
|
887
|
+
.map((question, index) => ({
|
|
888
|
+
id: asString(question.id) || `q${index + 1}`,
|
|
889
|
+
header: uniqueHeader(asString(question.header) || `Question ${index + 1}`),
|
|
890
|
+
question: asString(question.question) || '',
|
|
891
|
+
options: (Array.isArray(question.options) ? question.options : [])
|
|
892
|
+
.filter(isObject)
|
|
893
|
+
.map((option) => ({
|
|
894
|
+
label: asString(option.label) || '',
|
|
895
|
+
description: asString(option.description) || ''
|
|
896
|
+
}))
|
|
897
|
+
.filter((option) => option.label)
|
|
898
|
+
}));
|
|
899
|
+
if (questions.length === 0) {
|
|
900
|
+
// Nothing to ask. An empty answer set, never an invented one.
|
|
901
|
+
this.sendRpcResponse(session, request.id, { answers: {} });
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
// An earlier question that was never answered gets no answer.
|
|
905
|
+
if (session.pendingQuestion) {
|
|
906
|
+
this.sendRpcResponse(session, session.pendingQuestion.requestId, { answers: {} });
|
|
907
|
+
}
|
|
908
|
+
const partId = `codex-question-${asString(params.itemId) || String(request.id)}`;
|
|
909
|
+
session.pendingQuestion = { requestId: request.id, partId, questions };
|
|
910
|
+
session.status = coding_agent_adapter_1.SessionStatusType.WAITING_APPROVAL;
|
|
911
|
+
this.addEvent(session, { method: 'pf/question', params: { partId, questions } });
|
|
912
|
+
}
|
|
913
|
+
/** The answer to a question asked with `item/tool/requestUserInput`. */
|
|
914
|
+
async respondToQuestion(sessionId, answers, _config, requestId) {
|
|
915
|
+
const session = this.sessions.get(sessionId);
|
|
916
|
+
const pending = session?.pendingQuestion;
|
|
917
|
+
if (!session || !pending)
|
|
918
|
+
return false;
|
|
919
|
+
if (requestId && requestId !== pending.partId)
|
|
920
|
+
return false;
|
|
921
|
+
// The card sends `Header: answer` per question, or one bare answer.
|
|
922
|
+
const result = {};
|
|
923
|
+
for (const question of pending.questions) {
|
|
924
|
+
const answer = (answers[question.header] ??
|
|
925
|
+
(pending.questions.length === 1 ? answers.answer : undefined) ??
|
|
926
|
+
'').trim();
|
|
927
|
+
result[question.id] = { answers: answer ? [answer] : [] };
|
|
928
|
+
}
|
|
929
|
+
this.sendRpcResponse(session, pending.requestId, { answers: result });
|
|
930
|
+
session.pendingQuestion = null;
|
|
931
|
+
session.status = coding_agent_adapter_1.SessionStatusType.BUSY;
|
|
932
|
+
return true;
|
|
933
|
+
}
|
|
825
934
|
handleNotification(session, notification) {
|
|
826
935
|
const params = isObject(notification.params) ? notification.params : {};
|
|
827
936
|
if (notification.method === 'thread/status/changed') {
|
|
@@ -835,6 +944,9 @@ class CodexAppServerAdapter {
|
|
|
835
944
|
}
|
|
836
945
|
if (notification.method === 'turn/completed') {
|
|
837
946
|
session.activeTurnId = null;
|
|
947
|
+
// The turn is over, so a question it asked can no longer be answered:
|
|
948
|
+
// the request is gone on Codex's side. A late answer finds nothing.
|
|
949
|
+
session.pendingQuestion = null;
|
|
838
950
|
if (session.threadId) {
|
|
839
951
|
session.pendingCompletionRefreshes += 1;
|
|
840
952
|
void this.reconcileCompletedTurn(session);
|
|
@@ -866,6 +978,13 @@ class CodexAppServerAdapter {
|
|
|
866
978
|
}
|
|
867
979
|
if (notification.method === 'serverRequest/resolved') {
|
|
868
980
|
const requestId = params.requestId;
|
|
981
|
+
// Codex settled the question itself (the turn was interrupted).
|
|
982
|
+
if (session.pendingQuestion && String(session.pendingQuestion.requestId) === String(requestId)) {
|
|
983
|
+
session.pendingQuestion = null;
|
|
984
|
+
if (session.status === coding_agent_adapter_1.SessionStatusType.WAITING_APPROVAL) {
|
|
985
|
+
session.status = coding_agent_adapter_1.SessionStatusType.BUSY;
|
|
986
|
+
}
|
|
987
|
+
}
|
|
869
988
|
if (session.pendingApproval && String(session.pendingApproval.requestId) === String(requestId)) {
|
|
870
989
|
session.pendingApproval = null;
|
|
871
990
|
if (session.status === coding_agent_adapter_1.SessionStatusType.WAITING_APPROVAL) {
|
|
@@ -879,7 +998,7 @@ class CodexAppServerAdapter {
|
|
|
879
998
|
if (session.config.permissionMode === 'allow') {
|
|
880
999
|
const responseKind = this.getApprovalResponseKind(request.method);
|
|
881
1000
|
const selected = responseKind === 'execCommand' ? 'approved' : 'accept';
|
|
882
|
-
this.sendRpcResponse(session, request.id, this.buildApprovalResponse(responseKind, selected, true));
|
|
1001
|
+
this.sendRpcResponse(session, request.id, this.buildApprovalResponse(responseKind, selected, true, params.permissions));
|
|
883
1002
|
return;
|
|
884
1003
|
}
|
|
885
1004
|
const toolCallId = asString(params.approvalId) || asString(params.itemId) || asString(params.callId) || String(request.id);
|
|
@@ -889,44 +1008,63 @@ class CodexAppServerAdapter {
|
|
|
889
1008
|
// the amendment is saved into the Codex config on the runner. Offer only
|
|
890
1009
|
// the decisions known to answer this one request; the rest are logged.
|
|
891
1010
|
const offered = Array.isArray(params.availableDecisions) ? params.availableDecisions : [];
|
|
892
|
-
const
|
|
893
|
-
|
|
894
|
-
if (rawDecisions.length !== offered.length) {
|
|
895
|
-
const withheld = offered.map(normalizeDecisionName).filter(isStandingApprovalDecision);
|
|
1011
|
+
const withheld = offered.map(normalizeDecisionName).filter(isStandingApprovalDecision);
|
|
1012
|
+
if (withheld.length > 0) {
|
|
896
1013
|
console.log(`[CodexAppServerAdapter] Not offering approval decisions: ${withheld.join(', ')}`);
|
|
897
1014
|
}
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
1015
|
+
/*
|
|
1016
|
+
* CHANGED from 20x: ONE "Allow" AND ONE "Deny", AND DENY LETS THE TURN GO ON.
|
|
1017
|
+
*
|
|
1018
|
+
* Codex has two denials. `cancel` ("abort" in the older form) also
|
|
1019
|
+
* interrupts the turn: the chat went idle with no word from the agent, as
|
|
1020
|
+
* if nothing had been asked. `decline` ("denied") refuses the action and
|
|
1021
|
+
* lets the model carry on, so it can say that it was not allowed and what
|
|
1022
|
+
* it will do instead. Both are in the protocol for every request that has
|
|
1023
|
+
* a decision, whether or not `availableDecisions` lists them, so "Deny" is
|
|
1024
|
+
* always the one that continues.
|
|
1025
|
+
*/
|
|
1026
|
+
const legacy = responseKind === 'execCommand';
|
|
1027
|
+
const approvalOptions = legacy
|
|
1028
|
+
? [
|
|
1029
|
+
{ optionId: 'approved', name: 'Allow', kind: 'allow' },
|
|
1030
|
+
{ optionId: 'denied', name: 'Deny', kind: 'reject' }
|
|
1031
|
+
]
|
|
907
1032
|
: [
|
|
908
1033
|
{ optionId: 'accept', name: 'Allow', kind: 'allow' },
|
|
909
|
-
{ optionId: '
|
|
1034
|
+
{ optionId: 'decline', name: 'Deny', kind: 'reject' }
|
|
910
1035
|
];
|
|
1036
|
+
// The item the request is about, when it has been announced: a file
|
|
1037
|
+
// change's paths are on the item, not on the request.
|
|
1038
|
+
const itemId = asString(params.itemId) || asString(params.callId);
|
|
1039
|
+
const item = itemId ? session.runningTools.get(`tool-${itemId}`)?.input : undefined;
|
|
911
1040
|
session.pendingApproval = {
|
|
912
1041
|
requestId: request.id,
|
|
913
1042
|
toolCallId,
|
|
914
|
-
question:
|
|
1043
|
+
question: (0, codex_approval_text_1.describeCodexApproval)(request.method, params, item, session.config.workspaceDir),
|
|
915
1044
|
options: approvalOptions,
|
|
916
|
-
responseKind
|
|
1045
|
+
responseKind,
|
|
1046
|
+
...(responseKind === 'permissions' ? { requestedPermissions: params.permissions } : {})
|
|
917
1047
|
};
|
|
918
1048
|
session.status = coding_agent_adapter_1.SessionStatusType.WAITING_APPROVAL;
|
|
919
1049
|
this.onDataAvailable?.(session.threadId || session.sessionId);
|
|
920
1050
|
}
|
|
921
|
-
buildApprovalResponse(responseKind, selected, approved) {
|
|
1051
|
+
buildApprovalResponse(responseKind, selected, approved, requestedPermissions) {
|
|
922
1052
|
switch (responseKind) {
|
|
923
1053
|
case 'execCommand':
|
|
924
|
-
|
|
1054
|
+
// The older requests (`execCommandApproval`, `applyPatchApproval`)
|
|
1055
|
+
// take a ReviewDecision. `denied` continues the turn; `abort` stops it.
|
|
1056
|
+
return { decision: approved ? 'approved' : (selected === 'abort' ? 'abort' : 'denied') };
|
|
925
1057
|
case 'commandExecution':
|
|
926
|
-
return { decision: selected };
|
|
927
1058
|
case 'fileChange':
|
|
1059
|
+
return { decision: approved ? 'accept' : (selected === 'cancel' ? 'cancel' : 'decline') };
|
|
928
1060
|
case 'permissions':
|
|
929
|
-
|
|
1061
|
+
// CHANGED from 20x, which answered `{ decision }`. This request has no
|
|
1062
|
+
// decision: the answer is the permissions granted, for this turn only.
|
|
1063
|
+
// Granting nothing is the denial.
|
|
1064
|
+
return {
|
|
1065
|
+
permissions: approved && isObject(requestedPermissions) ? requestedPermissions : {},
|
|
1066
|
+
scope: 'turn'
|
|
1067
|
+
};
|
|
930
1068
|
case 'elicitation':
|
|
931
1069
|
return approved
|
|
932
1070
|
? { action: 'accept', content: {} }
|
|
@@ -936,11 +1074,11 @@ class CodexAppServerAdapter {
|
|
|
936
1074
|
? { response: selected }
|
|
937
1075
|
: { response: null };
|
|
938
1076
|
default:
|
|
939
|
-
return { decision: approved ?
|
|
1077
|
+
return { decision: approved ? 'accept' : 'decline' };
|
|
940
1078
|
}
|
|
941
1079
|
}
|
|
942
1080
|
getApprovalResponseKind(method) {
|
|
943
|
-
if (method === 'execCommandApproval')
|
|
1081
|
+
if (method === 'execCommandApproval' || method === 'applyPatchApproval')
|
|
944
1082
|
return 'execCommand';
|
|
945
1083
|
if (method.includes('commandExecution'))
|
|
946
1084
|
return 'commandExecution';
|
|
@@ -1178,6 +1316,29 @@ class CodexAppServerAdapter {
|
|
|
1178
1316
|
if ((method === 'item/started' || method === 'item/completed') && item) {
|
|
1179
1317
|
return this.convertThreadItem(method, item, params, seenMessageIds, seenPartIds, partContentLengths, session);
|
|
1180
1318
|
}
|
|
1319
|
+
if (method === 'pf/question') {
|
|
1320
|
+
const partId = asString(params.partId) || 'codex-question';
|
|
1321
|
+
if (seenPartIds.has(partId))
|
|
1322
|
+
return [];
|
|
1323
|
+
seenPartIds.add(partId);
|
|
1324
|
+
const questions = Array.isArray(params.questions) ? params.questions.filter(isObject) : [];
|
|
1325
|
+
return [{
|
|
1326
|
+
id: partId,
|
|
1327
|
+
type: coding_agent_adapter_1.MessagePartType.QUESTION,
|
|
1328
|
+
role: coding_agent_adapter_1.MessageRole.ASSISTANT,
|
|
1329
|
+
text: questions.map((question) => asString(question.question) || '').join('\n'),
|
|
1330
|
+
tool: {
|
|
1331
|
+
name: 'question',
|
|
1332
|
+
status: 'running',
|
|
1333
|
+
title: asString(questions[0]?.header) || 'Question',
|
|
1334
|
+
questions: questions.map((question) => ({
|
|
1335
|
+
header: question.header,
|
|
1336
|
+
question: question.question,
|
|
1337
|
+
options: question.options
|
|
1338
|
+
}))
|
|
1339
|
+
}
|
|
1340
|
+
}];
|
|
1341
|
+
}
|
|
1181
1342
|
if (method === 'error') {
|
|
1182
1343
|
const partId = `error-${Date.now()}`;
|
|
1183
1344
|
return [{
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a person is shown when `codex app-server` asks for an approval.
|
|
3
|
+
*
|
|
4
|
+
* The card used to show the protocol itself: `item/fileChange/requestApproval`
|
|
5
|
+
* and `id: exec-…`, with the file name only in the item above it. This module
|
|
6
|
+
* turns each kind of request the app-server can send into a first line that
|
|
7
|
+
* says what will happen ("Write: src/a.ts", "Run: npm test") and further lines
|
|
8
|
+
* with what helps to decide (where, why, which files).
|
|
9
|
+
*
|
|
10
|
+
* The first line is the title; the control plane's card shows it on its own.
|
|
11
|
+
* No ids: nobody can decide anything from one.
|
|
12
|
+
*
|
|
13
|
+
* Request kinds, from `codex app-server generate-json-schema` (ServerRequest):
|
|
14
|
+
* item/commandExecution/requestApproval a command, or input to a terminal
|
|
15
|
+
* item/fileChange/requestApproval a patch; the files are on the item
|
|
16
|
+
* item/permissions/requestApproval wider sandbox access for the turn
|
|
17
|
+
* mcpServer/elicitation/request an MCP server asks (incl. to run
|
|
18
|
+
* one of its tools)
|
|
19
|
+
* item/tool/requestUserInput the model asks a question
|
|
20
|
+
* execCommandApproval, applyPatchApproval the older forms of the first two
|
|
21
|
+
* Kept out of the adapter, which is a copy of 20x's, so it can be tested alone.
|
|
22
|
+
*/
|
|
23
|
+
type Json = Record<string, unknown>;
|
|
24
|
+
/** The longest command shown whole. Longer ones keep both ends. */
|
|
25
|
+
export declare const MAX_SHOWN_COMMAND = 2000;
|
|
26
|
+
/** The longest reason, message or question shown whole. */
|
|
27
|
+
export declare const MAX_SHOWN_NOTE = 500;
|
|
28
|
+
interface FileChange {
|
|
29
|
+
path: string;
|
|
30
|
+
kind: string;
|
|
31
|
+
added: number;
|
|
32
|
+
removed: number;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The files of a change, from either shape Codex uses:
|
|
36
|
+
* v2 item `changes: [{ path, kind: { type }, diff }]`
|
|
37
|
+
* older request `fileChanges: { [path]: { add | delete | update: {…} } }`
|
|
38
|
+
*/
|
|
39
|
+
export declare function readFileChanges(source: unknown): FileChange[];
|
|
40
|
+
/**
|
|
41
|
+
* The text of one approval request.
|
|
42
|
+
*
|
|
43
|
+
* @param item the thread item the request is about, when the adapter has seen
|
|
44
|
+
* it (`item/started` arrives before the request). A file change's paths are
|
|
45
|
+
* only there.
|
|
46
|
+
*/
|
|
47
|
+
export declare function describeCodexApproval(method: string, params: Json, item?: Json,
|
|
48
|
+
/** The session's workspace; paths inside it are shown relative to it. */
|
|
49
|
+
workspaceDir?: string): string;
|
|
50
|
+
export {};
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* What a person is shown when `codex app-server` asks for an approval.
|
|
4
|
+
*
|
|
5
|
+
* The card used to show the protocol itself: `item/fileChange/requestApproval`
|
|
6
|
+
* and `id: exec-…`, with the file name only in the item above it. This module
|
|
7
|
+
* turns each kind of request the app-server can send into a first line that
|
|
8
|
+
* says what will happen ("Write: src/a.ts", "Run: npm test") and further lines
|
|
9
|
+
* with what helps to decide (where, why, which files).
|
|
10
|
+
*
|
|
11
|
+
* The first line is the title; the control plane's card shows it on its own.
|
|
12
|
+
* No ids: nobody can decide anything from one.
|
|
13
|
+
*
|
|
14
|
+
* Request kinds, from `codex app-server generate-json-schema` (ServerRequest):
|
|
15
|
+
* item/commandExecution/requestApproval a command, or input to a terminal
|
|
16
|
+
* item/fileChange/requestApproval a patch; the files are on the item
|
|
17
|
+
* item/permissions/requestApproval wider sandbox access for the turn
|
|
18
|
+
* mcpServer/elicitation/request an MCP server asks (incl. to run
|
|
19
|
+
* one of its tools)
|
|
20
|
+
* item/tool/requestUserInput the model asks a question
|
|
21
|
+
* execCommandApproval, applyPatchApproval the older forms of the first two
|
|
22
|
+
* Kept out of the adapter, which is a copy of 20x's, so it can be tested alone.
|
|
23
|
+
*/
|
|
24
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
25
|
+
exports.MAX_SHOWN_NOTE = exports.MAX_SHOWN_COMMAND = void 0;
|
|
26
|
+
exports.readFileChanges = readFileChanges;
|
|
27
|
+
exports.describeCodexApproval = describeCodexApproval;
|
|
28
|
+
function isRecord(value) {
|
|
29
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
30
|
+
}
|
|
31
|
+
function text(value) {
|
|
32
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
33
|
+
}
|
|
34
|
+
/** One line, however the value was written. */
|
|
35
|
+
function oneLine(value) {
|
|
36
|
+
return value.replace(/\s*\n\s*/g, ' ⏎ ').trim();
|
|
37
|
+
}
|
|
38
|
+
const MAX_LISTED_PATHS = 12;
|
|
39
|
+
/** The longest command shown whole. Longer ones keep both ends. */
|
|
40
|
+
exports.MAX_SHOWN_COMMAND = 2000;
|
|
41
|
+
const COMMAND_HEAD = 1500;
|
|
42
|
+
const COMMAND_TAIL = 300;
|
|
43
|
+
/** The longest reason, message or question shown whole. */
|
|
44
|
+
exports.MAX_SHOWN_NOTE = 500;
|
|
45
|
+
/**
|
|
46
|
+
* A command that is too long for a card keeps its start, where the program
|
|
47
|
+
* and its first arguments are, and its end, where a redirect or a second
|
|
48
|
+
* command would be, and says how much is missing. The card then also says, on
|
|
49
|
+
* a line of its own, that this is not the whole command: a person must never
|
|
50
|
+
* approve a shortened command believing they have read it.
|
|
51
|
+
*/
|
|
52
|
+
function shownCommand(command) {
|
|
53
|
+
const line = oneLine(command);
|
|
54
|
+
if (line.length <= exports.MAX_SHOWN_COMMAND)
|
|
55
|
+
return { text: line, hidden: 0 };
|
|
56
|
+
const hidden = line.length - COMMAND_HEAD - COMMAND_TAIL;
|
|
57
|
+
return {
|
|
58
|
+
text: `${line.slice(0, COMMAND_HEAD)} … [${hidden} characters not shown] … ${line.slice(-COMMAND_TAIL)}`,
|
|
59
|
+
hidden
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function notShownInFull(command) {
|
|
63
|
+
return (`Not shown in full: this command is ${oneLine(command).length} characters ` +
|
|
64
|
+
'long. Deny it unless you know what it runs.');
|
|
65
|
+
}
|
|
66
|
+
/** Words about the request (why, what a server says), not what will run. */
|
|
67
|
+
function note(value) {
|
|
68
|
+
const line = oneLine(value);
|
|
69
|
+
return line.length <= exports.MAX_SHOWN_NOTE
|
|
70
|
+
? line
|
|
71
|
+
: `${line.slice(0, exports.MAX_SHOWN_NOTE)} … (shortened, ${line.length - exports.MAX_SHOWN_NOTE} more characters)`;
|
|
72
|
+
}
|
|
73
|
+
/** `/work/space/src/a.ts` in `/work/space` → `src/a.ts`. Others unchanged. */
|
|
74
|
+
function relativeTo(workspaceDir, path) {
|
|
75
|
+
const root = (workspaceDir ?? '').replace(/\/+$/, '');
|
|
76
|
+
if (!root)
|
|
77
|
+
return path;
|
|
78
|
+
// A path that climbs (`/work/space/../../etc/passwd`) is not inside the
|
|
79
|
+
// workspace whatever it starts with. Shown as it is.
|
|
80
|
+
if (/(^|\/)\.\.?(\/|$)/.test(path.replace(/\/\.$/, '')))
|
|
81
|
+
return path;
|
|
82
|
+
// macOS reports the same directory as /tmp/… and /private/tmp/….
|
|
83
|
+
for (const prefix of [
|
|
84
|
+
root,
|
|
85
|
+
`/private${root}`,
|
|
86
|
+
root.replace(/^\/private/, '')
|
|
87
|
+
]) {
|
|
88
|
+
if (prefix && path.startsWith(`${prefix}/`)) {
|
|
89
|
+
return path.slice(prefix.length + 1);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return path;
|
|
93
|
+
}
|
|
94
|
+
function countDiff(diff) {
|
|
95
|
+
let added = 0;
|
|
96
|
+
let removed = 0;
|
|
97
|
+
for (const line of diff.split('\n')) {
|
|
98
|
+
if (line.startsWith('+++') || line.startsWith('---'))
|
|
99
|
+
continue;
|
|
100
|
+
if (line.startsWith('+'))
|
|
101
|
+
added += 1;
|
|
102
|
+
else if (line.startsWith('-'))
|
|
103
|
+
removed += 1;
|
|
104
|
+
}
|
|
105
|
+
return { added, removed };
|
|
106
|
+
}
|
|
107
|
+
function changeKind(value) {
|
|
108
|
+
if (typeof value === 'string')
|
|
109
|
+
return value;
|
|
110
|
+
if (isRecord(value)) {
|
|
111
|
+
return text(value.type) || Object.keys(value)[0] || 'update';
|
|
112
|
+
}
|
|
113
|
+
return 'update';
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* The files of a change, from either shape Codex uses:
|
|
117
|
+
* v2 item `changes: [{ path, kind: { type }, diff }]`
|
|
118
|
+
* older request `fileChanges: { [path]: { add | delete | update: {…} } }`
|
|
119
|
+
*/
|
|
120
|
+
function readFileChanges(source) {
|
|
121
|
+
if (Array.isArray(source)) {
|
|
122
|
+
const changes = [];
|
|
123
|
+
for (const entry of source) {
|
|
124
|
+
if (!isRecord(entry) || !text(entry.path))
|
|
125
|
+
continue;
|
|
126
|
+
const diff = text(entry.diff);
|
|
127
|
+
const kind = changeKind(entry.kind);
|
|
128
|
+
// A new file's "diff" is its whole content, without `+` marks.
|
|
129
|
+
const counted = kind === 'add' && diff && !/^[+-]/m.test(diff)
|
|
130
|
+
? { added: diff.split('\n').length, removed: 0 }
|
|
131
|
+
: countDiff(diff);
|
|
132
|
+
changes.push({ path: text(entry.path), kind, ...counted });
|
|
133
|
+
}
|
|
134
|
+
return changes;
|
|
135
|
+
}
|
|
136
|
+
if (isRecord(source)) {
|
|
137
|
+
return Object.entries(source).map(([path, change]) => {
|
|
138
|
+
const kind = changeKind(change);
|
|
139
|
+
const body = isRecord(change) && isRecord(change[kind]) ? change[kind] : {};
|
|
140
|
+
const diff = text(body.unified_diff) || text(body.diff);
|
|
141
|
+
const content = text(body.content);
|
|
142
|
+
return {
|
|
143
|
+
path,
|
|
144
|
+
kind,
|
|
145
|
+
...(diff
|
|
146
|
+
? countDiff(diff)
|
|
147
|
+
: { added: content ? content.split('\n').length : 0, removed: 0 })
|
|
148
|
+
};
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
return [];
|
|
152
|
+
}
|
|
153
|
+
function verbFor(kind) {
|
|
154
|
+
if (kind === 'add')
|
|
155
|
+
return 'Create';
|
|
156
|
+
if (kind === 'delete')
|
|
157
|
+
return 'Delete';
|
|
158
|
+
return 'Write';
|
|
159
|
+
}
|
|
160
|
+
function changeSummary(change) {
|
|
161
|
+
if (change.kind === 'delete')
|
|
162
|
+
return '';
|
|
163
|
+
const parts = [];
|
|
164
|
+
if (change.added)
|
|
165
|
+
parts.push(`+${change.added}`);
|
|
166
|
+
if (change.removed)
|
|
167
|
+
parts.push(`−${change.removed}`);
|
|
168
|
+
return parts.length ? ` (${parts.join(' ')})` : '';
|
|
169
|
+
}
|
|
170
|
+
function describeFileChanges(changes) {
|
|
171
|
+
if (changes.length === 0)
|
|
172
|
+
return ['Write: files in the workspace'];
|
|
173
|
+
if (changes.length === 1) {
|
|
174
|
+
const [only] = changes;
|
|
175
|
+
return [`${verbFor(only.kind)}: ${only.path}${changeSummary(only)}`];
|
|
176
|
+
}
|
|
177
|
+
const kinds = new Set(changes.map((change) => change.kind));
|
|
178
|
+
const verb = kinds.size === 1 ? verbFor(changes[0].kind) : 'Change';
|
|
179
|
+
const lines = [`${verb}: ${changes.length} files`];
|
|
180
|
+
for (const change of changes.slice(0, MAX_LISTED_PATHS)) {
|
|
181
|
+
// Said once in the first line when every file is the same kind of change.
|
|
182
|
+
const mark = kinds.size === 1
|
|
183
|
+
? ''
|
|
184
|
+
: change.kind === 'add'
|
|
185
|
+
? 'new'
|
|
186
|
+
: change.kind === 'delete'
|
|
187
|
+
? 'deleted'
|
|
188
|
+
: '';
|
|
189
|
+
lines.push(`• ${change.path}${changeSummary(change)}${mark ? ` — ${mark}` : ''}`);
|
|
190
|
+
}
|
|
191
|
+
if (changes.length > MAX_LISTED_PATHS) {
|
|
192
|
+
lines.push(`• and ${changes.length - MAX_LISTED_PATHS} more`);
|
|
193
|
+
}
|
|
194
|
+
return lines;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* `/bin/zsh -lc 'npm test'` → `npm test`. Codex runs every command through a
|
|
198
|
+
* login shell; the wrapper is the same every time and hides the command. Only
|
|
199
|
+
* the plain one-quoted form is unwrapped, so nothing is ever shown that is not
|
|
200
|
+
* literally what the shell will be given.
|
|
201
|
+
*/
|
|
202
|
+
function unwrapShell(command) {
|
|
203
|
+
// SINGLE quotes only: inside them the shell changes nothing, so the inner
|
|
204
|
+
// text is exactly the command. Inside double quotes it would still expand
|
|
205
|
+
// `$(…)`, backticks and variables, and the card would not show what runs.
|
|
206
|
+
const match = /^(?:\/(?:usr\/)?bin\/)?(?:zsh|bash|sh) -l?c '([\s\S]*)'$/.exec(command.trim());
|
|
207
|
+
if (!match)
|
|
208
|
+
return command;
|
|
209
|
+
const inner = match[1];
|
|
210
|
+
return inner.includes("'") ? command : inner;
|
|
211
|
+
}
|
|
212
|
+
function commandText(value) {
|
|
213
|
+
if (Array.isArray(value)) {
|
|
214
|
+
return value
|
|
215
|
+
.filter((part) => typeof part === 'string')
|
|
216
|
+
.join(' ');
|
|
217
|
+
}
|
|
218
|
+
return text(value);
|
|
219
|
+
}
|
|
220
|
+
function describePermissions(permissions) {
|
|
221
|
+
const lines = [];
|
|
222
|
+
if (!isRecord(permissions))
|
|
223
|
+
return lines;
|
|
224
|
+
if (isRecord(permissions.network) && permissions.network.enabled === true) {
|
|
225
|
+
lines.push('• network access');
|
|
226
|
+
}
|
|
227
|
+
if (isRecord(permissions.fileSystem)) {
|
|
228
|
+
const fileSystem = permissions.fileSystem;
|
|
229
|
+
const list = (key, label) => {
|
|
230
|
+
const paths = Array.isArray(fileSystem[key])
|
|
231
|
+
? fileSystem[key].filter((path) => typeof path === 'string')
|
|
232
|
+
: [];
|
|
233
|
+
for (const path of paths)
|
|
234
|
+
lines.push(`• ${label} ${path}`);
|
|
235
|
+
};
|
|
236
|
+
list('write', 'write to');
|
|
237
|
+
list('read', 'read');
|
|
238
|
+
if (Array.isArray(fileSystem.entries)) {
|
|
239
|
+
for (const entry of fileSystem.entries) {
|
|
240
|
+
if (!isRecord(entry))
|
|
241
|
+
continue;
|
|
242
|
+
const path = isRecord(entry.path)
|
|
243
|
+
? text(entry.path.path) || JSON.stringify(entry.path)
|
|
244
|
+
: text(entry.path);
|
|
245
|
+
const access = text(entry.access) || 'access';
|
|
246
|
+
if (path)
|
|
247
|
+
lines.push(`• ${access} ${path}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return lines;
|
|
252
|
+
}
|
|
253
|
+
/** `item/somethingNew/requestApproval` → "something new". */
|
|
254
|
+
function humanise(method) {
|
|
255
|
+
const words = method
|
|
256
|
+
.replace(/^item\//, '')
|
|
257
|
+
.replace(/\/?(requestApproval|request)$/i, '')
|
|
258
|
+
.replace(/Approval$/i, '')
|
|
259
|
+
.replace(/[/_]/g, ' ')
|
|
260
|
+
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
261
|
+
.trim()
|
|
262
|
+
.toLowerCase();
|
|
263
|
+
return words || 'an action';
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* The text of one approval request.
|
|
267
|
+
*
|
|
268
|
+
* @param item the thread item the request is about, when the adapter has seen
|
|
269
|
+
* it (`item/started` arrives before the request). A file change's paths are
|
|
270
|
+
* only there.
|
|
271
|
+
*/
|
|
272
|
+
function describeCodexApproval(method, params, item,
|
|
273
|
+
/** The session's workspace; paths inside it are shown relative to it. */
|
|
274
|
+
workspaceDir) {
|
|
275
|
+
const reason = text(params.reason);
|
|
276
|
+
const lines = [];
|
|
277
|
+
const shown = (path) => relativeTo(workspaceDir, path);
|
|
278
|
+
if (method === 'item/commandExecution/requestApproval' ||
|
|
279
|
+
method === 'execCommandApproval') {
|
|
280
|
+
const command = unwrapShell(commandText(params.command) || commandText(item?.command) || '');
|
|
281
|
+
const network = isRecord(params.networkApprovalContext)
|
|
282
|
+
? params.networkApprovalContext
|
|
283
|
+
: null;
|
|
284
|
+
const capped = shownCommand(command);
|
|
285
|
+
if (params.kind === 'writeStdin') {
|
|
286
|
+
lines.push(`Send input to a running command: ${capped.text}`);
|
|
287
|
+
if (capped.hidden)
|
|
288
|
+
lines.push(notShownInFull(command));
|
|
289
|
+
}
|
|
290
|
+
else if (command) {
|
|
291
|
+
lines.push(`Run: ${capped.text}`);
|
|
292
|
+
// Straight under the command, before anything else that could push it
|
|
293
|
+
// out of sight.
|
|
294
|
+
if (capped.hidden)
|
|
295
|
+
lines.push(notShownInFull(command));
|
|
296
|
+
}
|
|
297
|
+
else if (network) {
|
|
298
|
+
lines.push(`Network access: ${text(network.host)}`);
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
lines.push('Run: a command');
|
|
302
|
+
}
|
|
303
|
+
const cwd = text(params.cwd) || text(item?.cwd);
|
|
304
|
+
// The workspace itself goes without saying; anywhere else is worth a line.
|
|
305
|
+
const where = cwd
|
|
306
|
+
? shown(`${cwd.replace(/\/+$/, '')}/.`).replace(/\/?\.$/, '')
|
|
307
|
+
: '';
|
|
308
|
+
if (where)
|
|
309
|
+
lines.push(`In: ${where}`);
|
|
310
|
+
if (network && command) {
|
|
311
|
+
lines.push(`Network access: ${text(network.host)}${text(network.protocol) ? ` (${text(network.protocol)})` : ''}`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
else if (method === 'item/fileChange/requestApproval' ||
|
|
315
|
+
method === 'applyPatchApproval') {
|
|
316
|
+
lines.push(...describeFileChanges(readFileChanges(params.fileChanges ?? item?.changes).map((change) => ({
|
|
317
|
+
...change,
|
|
318
|
+
path: shown(change.path)
|
|
319
|
+
}))));
|
|
320
|
+
const root = text(params.grantRoot);
|
|
321
|
+
if (root)
|
|
322
|
+
lines.push(`Also asks to write under: ${root}`);
|
|
323
|
+
}
|
|
324
|
+
else if (method === 'item/permissions/requestApproval') {
|
|
325
|
+
const wanted = describePermissions(params.permissions);
|
|
326
|
+
lines.push('Grant more access for this turn');
|
|
327
|
+
lines.push(...(wanted.length ? wanted : ['• wider sandbox access']));
|
|
328
|
+
const cwd = text(params.cwd);
|
|
329
|
+
if (cwd)
|
|
330
|
+
lines.push(`In: ${cwd}`);
|
|
331
|
+
}
|
|
332
|
+
else if (method === 'mcpServer/elicitation/request') {
|
|
333
|
+
const server = text(params.serverName) || 'An MCP server';
|
|
334
|
+
const message = text(params.message);
|
|
335
|
+
lines.push(message
|
|
336
|
+
? `${server} asks: ${note(message)}`
|
|
337
|
+
: `${server} asks to continue`);
|
|
338
|
+
const url = text(params.url);
|
|
339
|
+
if (url)
|
|
340
|
+
lines.push(`Opens: ${url}`);
|
|
341
|
+
}
|
|
342
|
+
else if (method === 'item/tool/requestUserInput') {
|
|
343
|
+
const first = Array.isArray(params.questions) ? params.questions[0] : null;
|
|
344
|
+
const question = isRecord(first)
|
|
345
|
+
? text(first.question) || text(first.header)
|
|
346
|
+
: '';
|
|
347
|
+
lines.push(question ? `Codex asks: ${note(question)}` : 'Codex asks a question');
|
|
348
|
+
}
|
|
349
|
+
else {
|
|
350
|
+
// A kind this build does not know. Say what is asked in plain words and
|
|
351
|
+
// show what the request itself says about it, never the method name alone.
|
|
352
|
+
const command = commandText(params.command);
|
|
353
|
+
lines.push(`Codex asks to approve: ${humanise(method)}`);
|
|
354
|
+
if (command) {
|
|
355
|
+
lines.push(`Command: ${shownCommand(command).text}`);
|
|
356
|
+
if (shownCommand(command).hidden)
|
|
357
|
+
lines.push(notShownInFull(command));
|
|
358
|
+
}
|
|
359
|
+
const cwd = text(params.cwd);
|
|
360
|
+
if (cwd)
|
|
361
|
+
lines.push(`In: ${cwd}`);
|
|
362
|
+
}
|
|
363
|
+
if (reason)
|
|
364
|
+
lines.push(`Reason: ${note(reason)}`);
|
|
365
|
+
return lines.join('\n');
|
|
366
|
+
}
|
|
@@ -277,6 +277,35 @@ export declare function decodeQuestionAnswers(answer: string | undefined): Recor
|
|
|
277
277
|
* Codex's "Allow" read as a denial.
|
|
278
278
|
*/
|
|
279
279
|
export declare function isApprovingOptionId(optionId: string): boolean;
|
|
280
|
+
/**
|
|
281
|
+
* Decide what a user's answer actually means, and fail closed when it is not
|
|
282
|
+
* clear.
|
|
283
|
+
*
|
|
284
|
+
* THREE PATHS REACH HERE AND THEY DO NOT CARRY THE SAME INFORMATION:
|
|
285
|
+
*
|
|
286
|
+
* 1. An option BUTTON in the question card. Sends `optionId`. Unambiguous,
|
|
287
|
+
* and this is the ordinary case.
|
|
288
|
+
* 2. The card's Reject button. Sends no `optionId` and the fixed text
|
|
289
|
+
* "Rejected by the user."
|
|
290
|
+
* 3. The COMPOSER. When a question is open, typing a message posts it as an
|
|
291
|
+
* answer instead (composer.tsx:76-106) — with no `optionId` at all.
|
|
292
|
+
*
|
|
293
|
+
* So free text has to be interpreted, and the rule is: interpret it only when
|
|
294
|
+
* it plainly matches something the backend offered. Anything else DENIES, and
|
|
295
|
+
* says so in the transcript rather than silently doing the safer thing without
|
|
296
|
+
* telling the user why they are being asked again.
|
|
297
|
+
*/
|
|
298
|
+
export declare function resolveApproval(command: {
|
|
299
|
+
answer?: string;
|
|
300
|
+
optionId?: string;
|
|
301
|
+
}, open: {
|
|
302
|
+
knownOptionIds: Set<string>;
|
|
303
|
+
optionIdByLabel: Map<string, string>;
|
|
304
|
+
}): {
|
|
305
|
+
approved: boolean;
|
|
306
|
+
optionId?: string;
|
|
307
|
+
transcriptText: string;
|
|
308
|
+
};
|
|
280
309
|
export interface McpCheckState {
|
|
281
310
|
checked: Map<string, number>;
|
|
282
311
|
failed: Map<string, FailedMcpCheck>;
|
|
@@ -29,6 +29,7 @@ exports.modelGatewayRefusal = modelGatewayRefusal;
|
|
|
29
29
|
exports.permissionFingerprint = permissionFingerprint;
|
|
30
30
|
exports.decodeQuestionAnswers = decodeQuestionAnswers;
|
|
31
31
|
exports.isApprovingOptionId = isApprovingOptionId;
|
|
32
|
+
exports.resolveApproval = resolveApproval;
|
|
32
33
|
const node_crypto_1 = require("node:crypto");
|
|
33
34
|
const model_gateway_1 = require("../adapters/model-gateway");
|
|
34
35
|
const contract_1 = require("../contract");
|
|
@@ -1869,7 +1870,11 @@ class SessionRunner {
|
|
|
1869
1870
|
question: approval.question,
|
|
1870
1871
|
options: approval.options.map((option) => ({
|
|
1871
1872
|
id: option.optionId,
|
|
1872
|
-
label: option.name
|
|
1873
|
+
label: option.name,
|
|
1874
|
+
// Whether this answer says yes, decided HERE by the same rule
|
|
1875
|
+
// that will read the answer back. The card colours its buttons
|
|
1876
|
+
// from this and never from the words on them.
|
|
1877
|
+
kind: isApprovingOptionId(option.optionId) ? 'allow' : 'reject'
|
|
1873
1878
|
}))
|
|
1874
1879
|
}
|
|
1875
1880
|
]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@peakflo/agent-harness",
|
|
3
|
-
"version": "0.2.9-rc.
|
|
3
|
+
"version": "0.2.9-rc.7",
|
|
4
4
|
"description": "Headless Peakflo coding-agent harness. One codebase, three hosting modes: a Daytona cloud sandbox, a customer VM, and the 20x desktop.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "commonjs",
|