nofax 0.2.0 → 0.2.2

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/src/protocol.mjs CHANGED
@@ -1,108 +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
- }
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
+ }
package/src/requests.mjs CHANGED
@@ -1,120 +1,160 @@
1
- import { chmod, mkdir, readFile, readdir, rename, writeFile } from 'node:fs/promises';
2
- import { join } from 'node:path';
3
- import { resolveNofaxHome } from './config.mjs';
4
-
5
- const REQUEST_ID = /^nfx_[A-Za-z0-9_-]{20,80}$/;
6
- const RESPONSE_TOPIC = /^nofax_r_[A-Za-z0-9_-]{20,120}$/;
7
- const KINDS = new Set(['approval', 'choice', 'refinement']);
8
- const STATUSES = new Set(['pending', 'resolved']);
9
-
10
- function validateRequestId(value) {
11
- if (typeof value !== 'string' || !REQUEST_ID.test(value)) throw new Error('NOFAX_REQUEST_ID_INVALID');
12
- return value;
13
- }
14
-
15
- function validateRequest(input) {
16
- if (!input || input.version !== 1) throw new Error('NOFAX_REQUEST_VERSION');
17
- const requestId = validateRequestId(input.requestId);
18
- if (!KINDS.has(input.kind)) throw new Error('NOFAX_REQUEST_KIND_INVALID');
19
- if (typeof input.responseTopic !== 'string' || !RESPONSE_TOPIC.test(input.responseTopic)) throw new Error('NOFAX_RESPONSE_TOPIC_INVALID');
20
- if (!Array.isArray(input.allowed) || input.allowed.length < 1 || input.allowed.length > 3 || input.allowed.some((value) => typeof value !== 'string' || !value || value.length > 80)) {
21
- throw new Error('NOFAX_REQUEST_ALLOWED_INVALID');
22
- }
23
- if (!STATUSES.has(input.status)) throw new Error('NOFAX_REQUEST_STATUS_INVALID');
24
- if (typeof input.createdAt !== 'string' || Number.isNaN(Date.parse(input.createdAt))) throw new Error('NOFAX_REQUEST_CREATED_AT_INVALID');
25
- const base = {
26
- version: 1,
27
- requestId,
28
- kind: input.kind,
29
- responseTopic: input.responseTopic,
30
- allowed: [...input.allowed],
31
- status: input.status,
32
- createdAt: input.createdAt
33
- };
34
- if (input.status === 'resolved') {
35
- if (typeof input.resolvedAt !== 'string' || Number.isNaN(Date.parse(input.resolvedAt))) throw new Error('NOFAX_REQUEST_RESOLVED_AT_INVALID');
36
- if (typeof input.decision !== 'string' || !input.allowed.includes(input.decision)) throw new Error('NOFAX_REQUEST_DECISION_INVALID');
37
- base.resolvedAt = input.resolvedAt;
38
- base.decision = input.decision;
39
- if (input.text !== undefined) {
40
- if (typeof input.text !== 'string' || !input.text.trim()) throw new Error('NOFAX_REQUEST_TEXT_INVALID');
41
- base.text = input.text.trim().slice(0, 2000);
42
- }
43
- }
44
- return base;
45
- }
46
-
47
- function paths({ home, env, requestId }) {
48
- const root = resolveNofaxHome({ home, env });
49
- const requestsRoot = join(root, 'requests');
50
- return { requestsRoot, file: join(requestsRoot, `${validateRequestId(requestId)}.json`) };
51
- }
52
-
53
- async function atomicWrite(path, value) {
54
- const temp = `${path}.tmp-${process.pid}-${Math.random().toString(16).slice(2)}`;
55
- await writeFile(temp, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
56
- try { await chmod(temp, 0o600); } catch {}
57
- await rename(temp, path);
58
- try { await chmod(path, 0o600); } catch {}
59
- }
60
-
61
- export async function savePendingRequest({ home, env, request }) {
62
- const normalized = validateRequest(request);
63
- if (normalized.status !== 'pending') throw new Error('NOFAX_REQUEST_NOT_PENDING');
64
- const { requestsRoot, file } = paths({ home, env, requestId: normalized.requestId });
65
- await mkdir(requestsRoot, { recursive: true, mode: 0o700 });
66
- await atomicWrite(file, normalized);
67
- return normalized;
68
- }
69
-
70
- export async function loadRequest({ home, env, requestId }) {
71
- const { file } = paths({ home, env, requestId });
72
- try {
73
- return validateRequest(JSON.parse(await readFile(file, 'utf8')));
74
- } catch (error) {
75
- if (error?.code === 'ENOENT') throw new Error('NOFAX_REQUEST_NOT_FOUND');
76
- if (error instanceof SyntaxError) throw new Error('NOFAX_REQUEST_INVALID_JSON');
77
- throw error;
78
- }
79
- }
80
-
81
- export async function resolveRequest({ home, env, requestId, response, resolvedAt = new Date().toISOString() }) {
82
- const current = await loadRequest({ home, env, requestId });
83
- if (current.status === 'resolved') return current;
84
- if (!response || typeof response.decision !== 'string' || !current.allowed.includes(response.decision)) throw new Error('NOFAX_REQUEST_DECISION_INVALID');
85
- const resolved = validateRequest({
86
- ...current,
87
- status: 'resolved',
88
- resolvedAt,
89
- decision: response.decision,
90
- ...(response.text === undefined ? {} : { text: response.text })
91
- });
92
- const { file } = paths({ home, env, requestId });
93
- await atomicWrite(file, resolved);
94
- return resolved;
95
- }
96
-
97
- export async function listPendingRequests({ home, env, limit = 20 } = {}) {
98
- if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw new Error('NOFAX_REQUEST_LIMIT_INVALID');
99
- const root = resolveNofaxHome({ home, env });
100
- const requestsRoot = join(root, 'requests');
101
- let names;
102
- try {
103
- names = await readdir(requestsRoot);
104
- } catch (error) {
105
- if (error?.code === 'ENOENT') return [];
106
- throw error;
107
- }
108
- const results = [];
109
- for (const name of names.sort().reverse()) {
110
- if (!name.endsWith('.json')) continue;
111
- const requestId = name.slice(0, -5);
112
- if (!REQUEST_ID.test(requestId)) continue;
113
- try {
114
- const request = await loadRequest({ home: root, requestId });
115
- if (request.status === 'pending') results.push(request);
116
- } catch {}
117
- if (results.length >= limit) break;
118
- }
119
- return results;
120
- }
1
+ import { chmod, link, mkdir, readFile, readdir, rename, unlink, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { resolveNofaxHome } from './config.mjs';
4
+
5
+ const REQUEST_ID = /^nfx_[A-Za-z0-9_-]{20,80}$/;
6
+ const RESPONSE_TOPIC = /^nofax_r_[A-Za-z0-9_-]{20,120}$/;
7
+ const KINDS = new Set(['approval', 'choice', 'refinement']);
8
+ const STATUSES = new Set(['pending', 'resolved']);
9
+
10
+ function validateRequestId(value) {
11
+ if (typeof value !== 'string' || !REQUEST_ID.test(value)) throw new Error('NOFAX_REQUEST_ID_INVALID');
12
+ return value;
13
+ }
14
+
15
+ function validateRequest(input) {
16
+ if (!input || input.version !== 1) throw new Error('NOFAX_REQUEST_VERSION');
17
+ const requestId = validateRequestId(input.requestId);
18
+ if (!KINDS.has(input.kind)) throw new Error('NOFAX_REQUEST_KIND_INVALID');
19
+ if (typeof input.responseTopic !== 'string' || !RESPONSE_TOPIC.test(input.responseTopic)) throw new Error('NOFAX_RESPONSE_TOPIC_INVALID');
20
+ if (!Array.isArray(input.allowed) || input.allowed.length < 1 || input.allowed.length > 3 || input.allowed.some((value) => typeof value !== 'string' || !value || value.length > 80)) {
21
+ throw new Error('NOFAX_REQUEST_ALLOWED_INVALID');
22
+ }
23
+ if (!STATUSES.has(input.status)) throw new Error('NOFAX_REQUEST_STATUS_INVALID');
24
+ if (typeof input.createdAt !== 'string' || Number.isNaN(Date.parse(input.createdAt))) throw new Error('NOFAX_REQUEST_CREATED_AT_INVALID');
25
+ const base = {
26
+ version: 1,
27
+ requestId,
28
+ kind: input.kind,
29
+ responseTopic: input.responseTopic,
30
+ allowed: [...input.allowed],
31
+ status: input.status,
32
+ createdAt: input.createdAt
33
+ };
34
+ if (input.status === 'resolved') {
35
+ if (typeof input.resolvedAt !== 'string' || Number.isNaN(Date.parse(input.resolvedAt))) throw new Error('NOFAX_REQUEST_RESOLVED_AT_INVALID');
36
+ if (typeof input.decision !== 'string' || !input.allowed.includes(input.decision)) throw new Error('NOFAX_REQUEST_DECISION_INVALID');
37
+ base.resolvedAt = input.resolvedAt;
38
+ base.decision = input.decision;
39
+ if (input.text !== undefined) {
40
+ if (typeof input.text !== 'string' || !input.text.trim()) throw new Error('NOFAX_REQUEST_TEXT_INVALID');
41
+ base.text = input.text.trim().slice(0, 2000);
42
+ }
43
+ }
44
+ return base;
45
+ }
46
+
47
+ function paths({ home, env, requestId }) {
48
+ const root = resolveNofaxHome({ home, env });
49
+ const requestsRoot = join(root, 'requests');
50
+ const normalizedRequestId = validateRequestId(requestId);
51
+ return {
52
+ requestsRoot,
53
+ file: join(requestsRoot, `${normalizedRequestId}.json`),
54
+ terminal: join(requestsRoot, `${normalizedRequestId}.terminal.json`)
55
+ };
56
+ }
57
+
58
+ async function atomicWrite(path, value) {
59
+ const temp = `${path}.tmp-${process.pid}-${Math.random().toString(16).slice(2)}`;
60
+ await writeFile(temp, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
61
+ try { await chmod(temp, 0o600); } catch {}
62
+ await rename(temp, path);
63
+ try { await chmod(path, 0o600); } catch {}
64
+ }
65
+
66
+ async function loadTerminalClaim(path) {
67
+ try {
68
+ const terminal = validateRequest(JSON.parse(await readFile(path, 'utf8')));
69
+ if (terminal.status !== 'resolved') throw new Error('NOFAX_REQUEST_TERMINAL_INVALID');
70
+ return terminal;
71
+ } catch (error) {
72
+ if (error?.code === 'ENOENT') return null;
73
+ if (error instanceof SyntaxError) throw new Error('NOFAX_REQUEST_TERMINAL_INVALID_JSON');
74
+ throw error;
75
+ }
76
+ }
77
+
78
+ async function claimTerminal(path, value) {
79
+ const temp = `${path}.tmp-${process.pid}-${Math.random().toString(16).slice(2)}`;
80
+ await writeFile(temp, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
81
+ try { await chmod(temp, 0o600); } catch {}
82
+
83
+ try {
84
+ await link(temp, path);
85
+ try { await chmod(path, 0o600); } catch {}
86
+ return true;
87
+ } catch (error) {
88
+ if (error?.code === 'EEXIST') return false;
89
+ throw error;
90
+ } finally {
91
+ try { await unlink(temp); } catch {}
92
+ }
93
+ }
94
+
95
+ export async function savePendingRequest({ home, env, request }) {
96
+ const normalized = validateRequest(request);
97
+ if (normalized.status !== 'pending') throw new Error('NOFAX_REQUEST_NOT_PENDING');
98
+ const { requestsRoot, file } = paths({ home, env, requestId: normalized.requestId });
99
+ await mkdir(requestsRoot, { recursive: true, mode: 0o700 });
100
+ await atomicWrite(file, normalized);
101
+ return normalized;
102
+ }
103
+
104
+ export async function loadRequest({ home, env, requestId }) {
105
+ const { file, terminal } = paths({ home, env, requestId });
106
+ try {
107
+ const current = validateRequest(JSON.parse(await readFile(file, 'utf8')));
108
+ if (current.status === 'resolved') return current;
109
+ const claimed = await loadTerminalClaim(terminal);
110
+ return claimed ?? current;
111
+ } catch (error) {
112
+ if (error?.code === 'ENOENT') throw new Error('NOFAX_REQUEST_NOT_FOUND');
113
+ if (error instanceof SyntaxError) throw new Error('NOFAX_REQUEST_INVALID_JSON');
114
+ throw error;
115
+ }
116
+ }
117
+
118
+ export async function resolveRequest({ home, env, requestId, response, resolvedAt = new Date().toISOString() }) {
119
+ const current = await loadRequest({ home, env, requestId });
120
+ if (current.status === 'resolved') return current;
121
+ if (!response || typeof response.decision !== 'string' || !current.allowed.includes(response.decision)) throw new Error('NOFAX_REQUEST_DECISION_INVALID');
122
+ const resolved = validateRequest({
123
+ ...current,
124
+ status: 'resolved',
125
+ resolvedAt,
126
+ decision: response.decision,
127
+ ...(response.text === undefined ? {} : { text: response.text })
128
+ });
129
+ const { file, terminal } = paths({ home, env, requestId });
130
+ if (!await claimTerminal(terminal, resolved)) {
131
+ return loadRequest({ home, env, requestId });
132
+ }
133
+ await atomicWrite(file, resolved);
134
+ return resolved;
135
+ }
136
+
137
+ export async function listPendingRequests({ home, env, limit = 20 } = {}) {
138
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw new Error('NOFAX_REQUEST_LIMIT_INVALID');
139
+ const root = resolveNofaxHome({ home, env });
140
+ const requestsRoot = join(root, 'requests');
141
+ let names;
142
+ try {
143
+ names = await readdir(requestsRoot);
144
+ } catch (error) {
145
+ if (error?.code === 'ENOENT') return [];
146
+ throw error;
147
+ }
148
+ const results = [];
149
+ for (const name of names.sort().reverse()) {
150
+ if (!name.endsWith('.json')) continue;
151
+ const requestId = name.slice(0, -5);
152
+ if (!REQUEST_ID.test(requestId)) continue;
153
+ try {
154
+ const request = await loadRequest({ home: root, requestId });
155
+ if (request.status === 'pending') results.push(request);
156
+ } catch {}
157
+ if (results.length >= limit) break;
158
+ }
159
+ return results;
160
+ }