nofax 0.2.0-oidc-test.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/LICENSE +21 -0
- package/README.md +351 -0
- package/SECURITY.md +144 -0
- package/bin/nofax.mjs +4 -0
- package/package.json +56 -0
- package/src/adapters/claude.mjs +47 -0
- package/src/adapters/codex.mjs +47 -0
- package/src/adapters/gemini.mjs +57 -0
- package/src/cli.mjs +191 -0
- package/src/config.mjs +91 -0
- package/src/index.mjs +8 -0
- package/src/mcp-server.mjs +141 -0
- package/src/mcp-tools.mjs +240 -0
- package/src/ntfy.mjs +259 -0
- package/src/protocol.mjs +108 -0
- package/src/requests.mjs +120 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { loadConfig } from './config.mjs';
|
|
2
|
+
import {
|
|
3
|
+
createRemoteRequest,
|
|
4
|
+
pollRemoteResponse,
|
|
5
|
+
sendNotification,
|
|
6
|
+
sendResponseConfirmation
|
|
7
|
+
} from './ntfy.mjs';
|
|
8
|
+
import {
|
|
9
|
+
listPendingRequests,
|
|
10
|
+
loadRequest,
|
|
11
|
+
resolveRequest,
|
|
12
|
+
savePendingRequest
|
|
13
|
+
} from './requests.mjs';
|
|
14
|
+
|
|
15
|
+
const MAX_WAIT_SECONDS = 240;
|
|
16
|
+
const DEFAULT_WAIT_SECONDS = 240;
|
|
17
|
+
|
|
18
|
+
export const WAIT_REQUIRED = (requestId) =>
|
|
19
|
+
`WAIT REQUIRED: human response for ${requestId} is still pending. Do not continue, infer approval, or complete the guarded action. Call nofax_wait_for_response with requestId "${requestId}" and repeat whenever it returns pending until a terminal response is returned.`;
|
|
20
|
+
|
|
21
|
+
function pendingResult(requestId) {
|
|
22
|
+
return {
|
|
23
|
+
status: 'pending',
|
|
24
|
+
requestId,
|
|
25
|
+
mustWait: true,
|
|
26
|
+
instruction: WAIT_REQUIRED(requestId)
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function terminalResult(request) {
|
|
31
|
+
if (request.decision === 'allow') {
|
|
32
|
+
return {
|
|
33
|
+
status: 'resolved',
|
|
34
|
+
requestId: request.requestId,
|
|
35
|
+
decision: 'allow',
|
|
36
|
+
instruction: 'Human approved this request. The caller may continue only within its existing authority.'
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
if (request.decision === 'deny') {
|
|
40
|
+
return {
|
|
41
|
+
status: 'resolved',
|
|
42
|
+
requestId: request.requestId,
|
|
43
|
+
decision: 'deny',
|
|
44
|
+
instruction: 'Human denied this request. Do not perform the guarded action.'
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
if (request.decision === 'refine') {
|
|
48
|
+
return {
|
|
49
|
+
status: 'resolved',
|
|
50
|
+
requestId: request.requestId,
|
|
51
|
+
decision: 'refine',
|
|
52
|
+
text: request.text,
|
|
53
|
+
instruction: 'Apply the human refinement. If the resulting action still requires approval, create a new approval request and wait for that new terminal response before acting.'
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
status: 'resolved',
|
|
58
|
+
requestId: request.requestId,
|
|
59
|
+
decision: request.decision,
|
|
60
|
+
instruction: 'Human choice received. Apply only that explicit choice within the caller\'s existing authority.'
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function publicRequest(request) {
|
|
65
|
+
const result = {
|
|
66
|
+
requestId: request.requestId,
|
|
67
|
+
kind: request.kind,
|
|
68
|
+
status: request.status,
|
|
69
|
+
createdAt: request.createdAt
|
|
70
|
+
};
|
|
71
|
+
if (request.status === 'resolved') {
|
|
72
|
+
result.resolvedAt = request.resolvedAt;
|
|
73
|
+
result.decision = request.decision;
|
|
74
|
+
if (request.text !== undefined) result.text = request.text;
|
|
75
|
+
}
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function validateText(value, name, max = 2200) {
|
|
80
|
+
if (typeof value !== 'string' || !value.trim()) throw new Error(`NOFAX_${name}_REQUIRED`);
|
|
81
|
+
return value.trim().slice(0, max);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function validateWaitSeconds(value) {
|
|
85
|
+
const seconds = value ?? DEFAULT_WAIT_SECONDS;
|
|
86
|
+
if (!Number.isInteger(seconds) || seconds < 1 || seconds > MAX_WAIT_SECONDS) {
|
|
87
|
+
throw new Error('NOFAX_MCP_WAIT_SECONDS_INVALID');
|
|
88
|
+
}
|
|
89
|
+
return seconds;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function validateChoiceOptions(options) {
|
|
93
|
+
if (!Array.isArray(options) || options.length < 1 || options.length > 3) throw new Error('NOFAX_CHOICE_LIMIT');
|
|
94
|
+
return options.map((option) => {
|
|
95
|
+
if (typeof option === 'string') {
|
|
96
|
+
const value = option.trim();
|
|
97
|
+
if (!value) throw new Error('NOFAX_CHOICE_INVALID');
|
|
98
|
+
return { value: value.slice(0, 80), label: value.slice(0, 32) };
|
|
99
|
+
}
|
|
100
|
+
if (!option || typeof option.value !== 'string' || typeof option.label !== 'string') throw new Error('NOFAX_CHOICE_INVALID');
|
|
101
|
+
const value = option.value.trim();
|
|
102
|
+
const label = option.label.trim();
|
|
103
|
+
if (!value || !label) throw new Error('NOFAX_CHOICE_INVALID');
|
|
104
|
+
return { value: value.slice(0, 80), label: label.slice(0, 32) };
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function createMcpToolHandlers(overrides = {}) {
|
|
109
|
+
const deps = {
|
|
110
|
+
home: overrides.home,
|
|
111
|
+
env: overrides.env ?? process.env,
|
|
112
|
+
loadConfigImpl: overrides.loadConfigImpl ?? loadConfig,
|
|
113
|
+
sendNotificationImpl: overrides.sendNotificationImpl ?? sendNotification,
|
|
114
|
+
createRemoteRequestImpl: overrides.createRemoteRequestImpl ?? createRemoteRequest,
|
|
115
|
+
pollRemoteResponseImpl: overrides.pollRemoteResponseImpl ?? pollRemoteResponse,
|
|
116
|
+
sendResponseConfirmationImpl: overrides.sendResponseConfirmationImpl ?? sendResponseConfirmation,
|
|
117
|
+
savePendingRequestImpl: overrides.savePendingRequestImpl ?? savePendingRequest,
|
|
118
|
+
loadRequestImpl: overrides.loadRequestImpl ?? loadRequest,
|
|
119
|
+
resolveRequestImpl: overrides.resolveRequestImpl ?? resolveRequest,
|
|
120
|
+
listPendingRequestsImpl: overrides.listPendingRequestsImpl ?? listPendingRequests,
|
|
121
|
+
nowImpl: overrides.nowImpl ?? Date.now,
|
|
122
|
+
sleepImpl: overrides.sleepImpl ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)))
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
async function config() {
|
|
126
|
+
return deps.loadConfigImpl({ home: deps.home, env: deps.env });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function persistRemote({ kind, remote }) {
|
|
130
|
+
const request = {
|
|
131
|
+
version: 1,
|
|
132
|
+
requestId: remote.requestId,
|
|
133
|
+
kind,
|
|
134
|
+
responseTopic: remote.responseTopic,
|
|
135
|
+
allowed: remote.allowed,
|
|
136
|
+
status: 'pending',
|
|
137
|
+
createdAt: new Date(deps.nowImpl()).toISOString()
|
|
138
|
+
};
|
|
139
|
+
await deps.savePendingRequestImpl({ home: deps.home, env: deps.env, request });
|
|
140
|
+
return pendingResult(remote.requestId);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
async notify({ title = 'Nofax', message }) {
|
|
145
|
+
const current = await config();
|
|
146
|
+
await deps.sendNotificationImpl({
|
|
147
|
+
config: current,
|
|
148
|
+
title: validateText(title, 'TITLE', 120),
|
|
149
|
+
message: validateText(message, 'MESSAGE')
|
|
150
|
+
});
|
|
151
|
+
return { status: 'sent' };
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
async requestApproval({ title = 'Nofax approval', message, allowRefine = false }) {
|
|
155
|
+
const current = await config();
|
|
156
|
+
const remote = await deps.createRemoteRequestImpl({
|
|
157
|
+
config: current,
|
|
158
|
+
title: validateText(title, 'TITLE', 120),
|
|
159
|
+
message: validateText(message, 'MESSAGE'),
|
|
160
|
+
options: [
|
|
161
|
+
{ value: 'allow', label: 'Allow' },
|
|
162
|
+
{ value: 'deny', label: 'Deny' }
|
|
163
|
+
],
|
|
164
|
+
includeRefine: allowRefine === true
|
|
165
|
+
});
|
|
166
|
+
return persistRemote({ kind: 'approval', remote });
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
async requestChoice({ title = 'Nofax choice', message, options }) {
|
|
170
|
+
const current = await config();
|
|
171
|
+
const normalized = validateChoiceOptions(options);
|
|
172
|
+
const remote = await deps.createRemoteRequestImpl({
|
|
173
|
+
config: current,
|
|
174
|
+
title: validateText(title, 'TITLE', 120),
|
|
175
|
+
message: validateText(message, 'MESSAGE'),
|
|
176
|
+
options: normalized,
|
|
177
|
+
includeRefine: false
|
|
178
|
+
});
|
|
179
|
+
return persistRemote({ kind: 'choice', remote });
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
async requestRefinement({ title = 'Nofax refinement', message }) {
|
|
183
|
+
const current = await config();
|
|
184
|
+
const remote = await deps.createRemoteRequestImpl({
|
|
185
|
+
config: current,
|
|
186
|
+
title: validateText(title, 'TITLE', 120),
|
|
187
|
+
message: validateText(message, 'MESSAGE'),
|
|
188
|
+
options: [],
|
|
189
|
+
includeRefine: true
|
|
190
|
+
});
|
|
191
|
+
return persistRemote({ kind: 'refinement', remote });
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
async waitForResponse({ requestId, waitSeconds }) {
|
|
195
|
+
const seconds = validateWaitSeconds(waitSeconds);
|
|
196
|
+
let request = await deps.loadRequestImpl({ home: deps.home, env: deps.env, requestId });
|
|
197
|
+
if (request.status === 'resolved') return terminalResult(request);
|
|
198
|
+
|
|
199
|
+
const current = await config();
|
|
200
|
+
const deadline = deps.nowImpl() + seconds * 1000;
|
|
201
|
+
while (deps.nowImpl() < deadline) {
|
|
202
|
+
const response = await deps.pollRemoteResponseImpl({
|
|
203
|
+
config: current,
|
|
204
|
+
responseTopic: request.responseTopic,
|
|
205
|
+
requestId: request.requestId,
|
|
206
|
+
allowed: request.allowed
|
|
207
|
+
});
|
|
208
|
+
if (response !== null) {
|
|
209
|
+
request = await deps.resolveRequestImpl({
|
|
210
|
+
home: deps.home,
|
|
211
|
+
env: deps.env,
|
|
212
|
+
requestId: request.requestId,
|
|
213
|
+
response,
|
|
214
|
+
resolvedAt: new Date(deps.nowImpl()).toISOString()
|
|
215
|
+
});
|
|
216
|
+
await deps.sendResponseConfirmationImpl({
|
|
217
|
+
config: current,
|
|
218
|
+
response,
|
|
219
|
+
title: request.kind === 'approval' ? 'Approval' : request.kind === 'refinement' ? 'Refinement' : 'Choice'
|
|
220
|
+
});
|
|
221
|
+
return terminalResult(request);
|
|
222
|
+
}
|
|
223
|
+
const remaining = deadline - deps.nowImpl();
|
|
224
|
+
if (remaining <= 0) break;
|
|
225
|
+
await deps.sleepImpl(Math.min(1000, remaining));
|
|
226
|
+
}
|
|
227
|
+
return pendingResult(request.requestId);
|
|
228
|
+
},
|
|
229
|
+
|
|
230
|
+
async getRequest({ requestId }) {
|
|
231
|
+
const request = await deps.loadRequestImpl({ home: deps.home, env: deps.env, requestId });
|
|
232
|
+
return { status: 'ok', request: publicRequest(request) };
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
async listPending({ limit = 20 } = {}) {
|
|
236
|
+
const requests = await deps.listPendingRequestsImpl({ home: deps.home, env: deps.env, limit });
|
|
237
|
+
return { status: 'ok', requests: requests.map(publicRequest) };
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
}
|
package/src/ntfy.mjs
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { createRequestId, createResponseTopic, parseResponseMessage } from './protocol.mjs';
|
|
2
|
+
|
|
3
|
+
const MAX_TITLE = 120;
|
|
4
|
+
const MAX_MESSAGE = 2200;
|
|
5
|
+
const DEFAULT_REFINE_SHORTCUT = 'Nofax Refine';
|
|
6
|
+
|
|
7
|
+
function boundText(value, max, name) {
|
|
8
|
+
if (typeof value !== 'string' || value.trim().length === 0) throw new Error(`NOFAX_${name}_REQUIRED`);
|
|
9
|
+
const text = value.trim();
|
|
10
|
+
return text.length <= max ? text : `${text.slice(0, max - 14)}…[truncated]`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function sleep(ms) {
|
|
14
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function httpAction({ label, server, responseTopic, requestId, decision }) {
|
|
18
|
+
return {
|
|
19
|
+
action: 'http',
|
|
20
|
+
label,
|
|
21
|
+
url: `${server}/${responseTopic}`,
|
|
22
|
+
method: 'POST',
|
|
23
|
+
body: JSON.stringify({ v: 1, requestId, decision }),
|
|
24
|
+
clear: true
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function buildRefineShortcutUrl({ shortcutName = DEFAULT_REFINE_SHORTCUT, server, responseTopic, requestId }) {
|
|
29
|
+
const payload = JSON.stringify({
|
|
30
|
+
v: 1,
|
|
31
|
+
requestId,
|
|
32
|
+
callbackUrl: `${server}/${responseTopic}`
|
|
33
|
+
});
|
|
34
|
+
const url = new URL('shortcuts://run-shortcut');
|
|
35
|
+
url.searchParams.set('name', shortcutName);
|
|
36
|
+
url.searchParams.set('input', 'text');
|
|
37
|
+
url.searchParams.set('text', payload);
|
|
38
|
+
return url.toString();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function refineAction({ shortcutName, server, responseTopic, requestId }) {
|
|
42
|
+
return {
|
|
43
|
+
action: 'view',
|
|
44
|
+
label: 'Refine',
|
|
45
|
+
url: buildRefineShortcutUrl({ shortcutName, server, responseTopic, requestId }),
|
|
46
|
+
clear: true
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function ensureOk(response, code) {
|
|
51
|
+
if (!response?.ok) throw new Error(`${code}_${response?.status ?? 'NETWORK'}`);
|
|
52
|
+
return response;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function sendNotification({ config, title, message, actions, priority = 4, tags = ['bell'], fetchImpl = fetch }) {
|
|
56
|
+
const payload = {
|
|
57
|
+
topic: config.topic,
|
|
58
|
+
title: boundText(title, MAX_TITLE, 'TITLE'),
|
|
59
|
+
message: boundText(message, MAX_MESSAGE, 'MESSAGE'),
|
|
60
|
+
priority,
|
|
61
|
+
tags
|
|
62
|
+
};
|
|
63
|
+
if (actions?.length) payload.actions = actions;
|
|
64
|
+
|
|
65
|
+
let response;
|
|
66
|
+
try {
|
|
67
|
+
response = await fetchImpl(`${config.server}/`, {
|
|
68
|
+
method: 'POST',
|
|
69
|
+
headers: { 'content-type': 'application/json' },
|
|
70
|
+
body: JSON.stringify(payload)
|
|
71
|
+
});
|
|
72
|
+
} catch (error) {
|
|
73
|
+
throw new Error(`NOFAX_NTFY_PUBLISH_NETWORK: ${error?.message ?? String(error)}`);
|
|
74
|
+
}
|
|
75
|
+
await ensureOk(response, 'NOFAX_NTFY_PUBLISH');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function normalizeOptions(options) {
|
|
79
|
+
if (!Array.isArray(options) || options.length > 3) throw new Error('NOFAX_CHOICE_LIMIT');
|
|
80
|
+
const normalized = options.map((option) => {
|
|
81
|
+
if (typeof option === 'string') return { value: option, label: option };
|
|
82
|
+
if (!option || typeof option.value !== 'string' || typeof option.label !== 'string') throw new Error('NOFAX_CHOICE_INVALID');
|
|
83
|
+
const value = option.value.trim();
|
|
84
|
+
const label = option.label.trim();
|
|
85
|
+
if (!value || !label || value.length > 80) throw new Error('NOFAX_CHOICE_INVALID');
|
|
86
|
+
return { value, label: label.slice(0, 32) };
|
|
87
|
+
});
|
|
88
|
+
if (new Set(normalized.map((option) => option.value)).size !== normalized.length) throw new Error('NOFAX_CHOICE_DUPLICATE');
|
|
89
|
+
return normalized;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function createRemoteRequest({
|
|
93
|
+
config,
|
|
94
|
+
title,
|
|
95
|
+
message,
|
|
96
|
+
options = [],
|
|
97
|
+
includeRefine = false,
|
|
98
|
+
shortcutName = config.refineShortcutName ?? DEFAULT_REFINE_SHORTCUT,
|
|
99
|
+
fetchImpl = fetch
|
|
100
|
+
}) {
|
|
101
|
+
const normalized = normalizeOptions(options);
|
|
102
|
+
const totalActions = normalized.length + (includeRefine ? 1 : 0);
|
|
103
|
+
if (totalActions < 1 || totalActions > 3) throw new Error('NOFAX_CHOICE_LIMIT');
|
|
104
|
+
|
|
105
|
+
const requestId = createRequestId();
|
|
106
|
+
const responseTopic = createResponseTopic();
|
|
107
|
+
const actions = [];
|
|
108
|
+
const allowed = [];
|
|
109
|
+
|
|
110
|
+
if (includeRefine && normalized.length === 2) {
|
|
111
|
+
const [first, second] = normalized;
|
|
112
|
+
actions.push(httpAction({ label: first.label, server: config.server, responseTopic, requestId, decision: first.value }));
|
|
113
|
+
allowed.push(first.value);
|
|
114
|
+
actions.push(refineAction({ shortcutName, server: config.server, responseTopic, requestId }));
|
|
115
|
+
allowed.push('refine');
|
|
116
|
+
actions.push(httpAction({ label: second.label, server: config.server, responseTopic, requestId, decision: second.value }));
|
|
117
|
+
allowed.push(second.value);
|
|
118
|
+
} else {
|
|
119
|
+
for (const option of normalized) {
|
|
120
|
+
actions.push(httpAction({ label: option.label, server: config.server, responseTopic, requestId, decision: option.value }));
|
|
121
|
+
allowed.push(option.value);
|
|
122
|
+
}
|
|
123
|
+
if (includeRefine) {
|
|
124
|
+
actions.push(refineAction({ shortcutName, server: config.server, responseTopic, requestId }));
|
|
125
|
+
allowed.push('refine');
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
await sendNotification({ config, title, message, actions, fetchImpl });
|
|
130
|
+
return { requestId, responseTopic, allowed };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function parseNtfyPoll(text, requestId, allowed) {
|
|
134
|
+
for (const line of text.split(/\r?\n/)) {
|
|
135
|
+
if (!line.trim()) continue;
|
|
136
|
+
let event;
|
|
137
|
+
try {
|
|
138
|
+
event = JSON.parse(line);
|
|
139
|
+
} catch {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (event?.event !== 'message') continue;
|
|
143
|
+
const response = parseResponseMessage(event.message, { requestId, allowed });
|
|
144
|
+
if (response !== null) return response;
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function pollRemoteResponse({ config, responseTopic, requestId, allowed, fetchImpl = fetch }) {
|
|
150
|
+
let response;
|
|
151
|
+
try {
|
|
152
|
+
response = await fetchImpl(`${config.server}/${responseTopic}/json?poll=1&since=10m`, {
|
|
153
|
+
method: 'GET',
|
|
154
|
+
headers: { accept: 'application/x-ndjson' }
|
|
155
|
+
});
|
|
156
|
+
} catch (error) {
|
|
157
|
+
throw new Error(`NOFAX_NTFY_POLL_NETWORK: ${error?.message ?? String(error)}`);
|
|
158
|
+
}
|
|
159
|
+
await ensureOk(response, 'NOFAX_NTFY_POLL');
|
|
160
|
+
return parseNtfyPoll(await response.text(), requestId, allowed);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function sendResponseConfirmation({ config, response, title = 'Nofax', fetchImpl = fetch }) {
|
|
164
|
+
let confirmationTitle = 'Response received';
|
|
165
|
+
let tag = 'white_check_mark';
|
|
166
|
+
if (response.decision === 'allow') confirmationTitle = 'Approved';
|
|
167
|
+
else if (response.decision === 'deny') {
|
|
168
|
+
confirmationTitle = 'Denied';
|
|
169
|
+
tag = 'no_entry';
|
|
170
|
+
} else if (response.decision === 'refine') confirmationTitle = 'Refinement received';
|
|
171
|
+
else confirmationTitle = 'Choice received';
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
await sendNotification({
|
|
175
|
+
config,
|
|
176
|
+
title: `${confirmationTitle} - ${title}`,
|
|
177
|
+
message: response.decision === 'refine'
|
|
178
|
+
? 'Your refinement was sent back to the agent.'
|
|
179
|
+
: `Nofax recorded: ${response.decision}`,
|
|
180
|
+
priority: 2,
|
|
181
|
+
tags: [tag],
|
|
182
|
+
fetchImpl
|
|
183
|
+
});
|
|
184
|
+
return true;
|
|
185
|
+
} catch {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export async function waitRemoteResponse({
|
|
191
|
+
config,
|
|
192
|
+
responseTopic,
|
|
193
|
+
requestId,
|
|
194
|
+
allowed,
|
|
195
|
+
timeoutMs,
|
|
196
|
+
pollIntervalMs = 1000,
|
|
197
|
+
fetchImpl = fetch,
|
|
198
|
+
nowImpl = Date.now,
|
|
199
|
+
sleepImpl = sleep
|
|
200
|
+
}) {
|
|
201
|
+
const deadline = nowImpl() + timeoutMs;
|
|
202
|
+
while (nowImpl() < deadline) {
|
|
203
|
+
const response = await pollRemoteResponse({ config, responseTopic, requestId, allowed, fetchImpl });
|
|
204
|
+
if (response !== null) return response;
|
|
205
|
+
const remaining = deadline - nowImpl();
|
|
206
|
+
if (remaining <= 0) break;
|
|
207
|
+
await sleepImpl(Math.min(pollIntervalMs, remaining));
|
|
208
|
+
}
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function requestDecision({
|
|
213
|
+
config,
|
|
214
|
+
title,
|
|
215
|
+
message,
|
|
216
|
+
options,
|
|
217
|
+
includeRefine = false,
|
|
218
|
+
fetchImpl = fetch,
|
|
219
|
+
timeoutMs = config.timeoutSeconds * 1000,
|
|
220
|
+
pollIntervalMs = 1000,
|
|
221
|
+
nowImpl = Date.now,
|
|
222
|
+
sleepImpl = sleep
|
|
223
|
+
}) {
|
|
224
|
+
const remote = await createRemoteRequest({ config, title, message, options, includeRefine, fetchImpl });
|
|
225
|
+
const response = await waitRemoteResponse({
|
|
226
|
+
config,
|
|
227
|
+
...remote,
|
|
228
|
+
timeoutMs,
|
|
229
|
+
pollIntervalMs,
|
|
230
|
+
fetchImpl,
|
|
231
|
+
nowImpl,
|
|
232
|
+
sleepImpl
|
|
233
|
+
});
|
|
234
|
+
if (response === null) {
|
|
235
|
+
return { decision: 'timeout', requestId: remote.requestId, responseTopic: remote.responseTopic };
|
|
236
|
+
}
|
|
237
|
+
await sendResponseConfirmation({ config, response, title, fetchImpl });
|
|
238
|
+
return { ...response, requestId: remote.requestId, responseTopic: remote.responseTopic };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export async function requestApproval(options) {
|
|
242
|
+
return requestDecision({
|
|
243
|
+
...options,
|
|
244
|
+
options: [
|
|
245
|
+
{ value: 'allow', label: 'Allow' },
|
|
246
|
+
{ value: 'deny', label: 'Deny' }
|
|
247
|
+
]
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export async function requestRefinement(options) {
|
|
252
|
+
return requestDecision({ ...options, options: [], includeRefine: true });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export async function requestChoice({ options, ...rest }) {
|
|
256
|
+
const normalized = normalizeOptions(options);
|
|
257
|
+
if (normalized.length < 1 || normalized.length > 3) throw new Error('NOFAX_CHOICE_LIMIT');
|
|
258
|
+
return requestDecision({ ...rest, options: normalized });
|
|
259
|
+
}
|
package/src/protocol.mjs
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
const SECRET_KEY = /(?:^|[_-])(token|password|passwd|secret|api[_-]?key|authorization|cookie|credential|private[_-]?key)(?:$|[_-])/i;
|
|
4
|
+
const MAX_STRING = 500;
|
|
5
|
+
const MAX_DEPTH = 5;
|
|
6
|
+
const MAX_ARRAY = 20;
|
|
7
|
+
const MAX_OBJECT_KEYS = 30;
|
|
8
|
+
const MAX_SUMMARY = 2200;
|
|
9
|
+
const MAX_RESPONSE_TEXT = 2000;
|
|
10
|
+
|
|
11
|
+
function randomBase64Url(bytes) {
|
|
12
|
+
return randomBytes(bytes).toString('base64url');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function createRequestId() {
|
|
16
|
+
return `nfx_${randomBase64Url(18)}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function createResponseTopic() {
|
|
20
|
+
return `nofax_r_${randomBase64Url(24)}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createPhoneTopic() {
|
|
24
|
+
return `nofax_${randomBase64Url(24)}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function boundString(value) {
|
|
28
|
+
if (value.length <= MAX_STRING) return value;
|
|
29
|
+
return `${value.slice(0, MAX_STRING)}…[truncated]`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function redactAndBound(value, options = {}) {
|
|
33
|
+
const seen = options.seen ?? new WeakSet();
|
|
34
|
+
const depth = options.depth ?? 0;
|
|
35
|
+
|
|
36
|
+
if (value === null || value === undefined) return value;
|
|
37
|
+
if (typeof value === 'string') return boundString(value);
|
|
38
|
+
if (typeof value === 'number' || typeof value === 'boolean') return value;
|
|
39
|
+
if (typeof value === 'bigint') return value.toString();
|
|
40
|
+
if (typeof value === 'function' || typeof value === 'symbol') return `[${typeof value}]`;
|
|
41
|
+
if (depth >= MAX_DEPTH) return '[MAX_DEPTH]';
|
|
42
|
+
if (typeof value !== 'object') return boundString(String(value));
|
|
43
|
+
|
|
44
|
+
if (seen.has(value)) return '[CIRCULAR]';
|
|
45
|
+
seen.add(value);
|
|
46
|
+
|
|
47
|
+
if (Array.isArray(value)) {
|
|
48
|
+
return value.slice(0, MAX_ARRAY).map((item) => redactAndBound(item, { seen, depth: depth + 1 }));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const out = {};
|
|
52
|
+
for (const [key, child] of Object.entries(value).slice(0, MAX_OBJECT_KEYS)) {
|
|
53
|
+
out[key] = SECRET_KEY.test(key)
|
|
54
|
+
? '[REDACTED]'
|
|
55
|
+
: redactAndBound(child, { seen, depth: depth + 1 });
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function serializeBounded(value) {
|
|
61
|
+
let serialized;
|
|
62
|
+
try {
|
|
63
|
+
serialized = JSON.stringify(redactAndBound(value), null, 2);
|
|
64
|
+
} catch {
|
|
65
|
+
serialized = '[unserializable]';
|
|
66
|
+
}
|
|
67
|
+
if (serialized.length <= 1400) return serialized;
|
|
68
|
+
return `${serialized.slice(0, 1400)}\n…[truncated]`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function buildAgentSummary({ source, toolName, cwd, toolInput, message }) {
|
|
72
|
+
const lines = [];
|
|
73
|
+
if (source) lines.push(`Source: ${boundString(String(source))}`);
|
|
74
|
+
if (toolName) lines.push(`Tool: ${boundString(String(toolName))}`);
|
|
75
|
+
if (cwd) lines.push(`Working directory: ${boundString(String(cwd))}`);
|
|
76
|
+
if (message) lines.push('', boundString(String(message)));
|
|
77
|
+
if (toolInput !== undefined) lines.push('', 'Request:', serializeBounded(toolInput));
|
|
78
|
+
const result = lines.join('\n').trim();
|
|
79
|
+
if (result.length <= MAX_SUMMARY) return result;
|
|
80
|
+
return `${result.slice(0, MAX_SUMMARY - 14)}\n…[truncated]`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function parseResponseMessage(message, { requestId, allowed }) {
|
|
84
|
+
if (typeof message !== 'string') return null;
|
|
85
|
+
let parsed;
|
|
86
|
+
try {
|
|
87
|
+
parsed = JSON.parse(message);
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
if (!parsed || parsed.v !== 1 || parsed.requestId !== requestId || typeof parsed.decision !== 'string') {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
if (!allowed.includes(parsed.decision)) return null;
|
|
95
|
+
|
|
96
|
+
if (parsed.decision === 'refine') {
|
|
97
|
+
if (typeof parsed.text !== 'string') return null;
|
|
98
|
+
const text = parsed.text.trim();
|
|
99
|
+
if (!text) return null;
|
|
100
|
+
return { decision: 'refine', text: boundString(text).slice(0, MAX_RESPONSE_TEXT) };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { decision: parsed.decision };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function parseDecisionMessage(message, { requestId, allowed }) {
|
|
107
|
+
return parseResponseMessage(message, { requestId, allowed })?.decision ?? null;
|
|
108
|
+
}
|