@moxxy/sdk 0.32.0 → 0.34.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/README.md +1 -1
- package/dist/audit-export.d.ts +51 -0
- package/dist/audit-export.d.ts.map +1 -0
- package/dist/audit-export.js +4 -0
- package/dist/audit-export.js.map +1 -0
- package/dist/audit.d.ts +86 -0
- package/dist/audit.d.ts.map +1 -0
- package/dist/audit.js +35 -0
- package/dist/audit.js.map +1 -0
- package/dist/define.d.ts +2 -0
- package/dist/define.d.ts.map +1 -1
- package/dist/define.js +1 -0
- package/dist/define.js.map +1 -1
- package/dist/egress.d.ts +109 -0
- package/dist/egress.d.ts.map +1 -0
- package/dist/egress.js +228 -0
- package/dist/egress.js.map +1 -0
- package/dist/events.d.ts +18 -0
- package/dist/events.d.ts.map +1 -1
- package/dist/fs-utils.d.ts +48 -0
- package/dist/fs-utils.d.ts.map +1 -1
- package/dist/fs-utils.js +114 -1
- package/dist/fs-utils.js.map +1 -1
- package/dist/hooks.d.ts +8 -0
- package/dist/hooks.d.ts.map +1 -1
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/mode/react-loop.d.ts.map +1 -1
- package/dist/mode/react-loop.js +10 -1
- package/dist/mode/react-loop.js.map +1 -1
- package/dist/plugin.d.ts +10 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/principal-os.d.ts +14 -0
- package/dist/principal-os.d.ts.map +1 -0
- package/dist/principal-os.js +48 -0
- package/dist/principal-os.js.map +1 -0
- package/dist/principal.d.ts +54 -0
- package/dist/principal.d.ts.map +1 -0
- package/dist/principal.js +18 -0
- package/dist/principal.js.map +1 -0
- package/dist/redact.d.ts +36 -0
- package/dist/redact.d.ts.map +1 -0
- package/dist/redact.js +88 -0
- package/dist/redact.js.map +1 -0
- package/dist/schemas.d.ts +18 -18
- package/dist/secret-provider.d.ts +60 -0
- package/dist/secret-provider.d.ts.map +1 -0
- package/dist/secret-provider.js +59 -0
- package/dist/secret-provider.js.map +1 -0
- package/dist/server.d.ts +3 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +6 -1
- package/dist/server.js.map +1 -1
- package/dist/session-like.d.ts +3 -0
- package/dist/session-like.d.ts.map +1 -1
- package/dist/signature.d.ts +21 -0
- package/dist/signature.d.ts.map +1 -0
- package/dist/signature.js +32 -0
- package/dist/signature.js.map +1 -0
- package/dist/tool-icon.d.ts +22 -0
- package/dist/tool-icon.d.ts.map +1 -0
- package/dist/tool-icon.js +44 -0
- package/dist/tool-icon.js.map +1 -0
- package/dist/tool.d.ts +8 -0
- package/dist/tool.d.ts.map +1 -1
- package/package.json +9 -2
- package/src/audit-export.ts +57 -0
- package/src/audit.ts +137 -0
- package/src/cross-process-lock.test.ts +23 -14
- package/src/define.ts +3 -0
- package/src/egress.test.ts +238 -0
- package/src/egress.ts +274 -0
- package/src/events.ts +18 -0
- package/src/fs-utils.test.ts +98 -2
- package/src/fs-utils.ts +120 -1
- package/src/hooks.ts +8 -0
- package/src/host-credential.test.ts +39 -0
- package/src/index.ts +49 -0
- package/src/mode/react-loop.ts +10 -1
- package/src/plugin.ts +10 -0
- package/src/principal-os.ts +48 -0
- package/src/principal.test.ts +47 -0
- package/src/principal.ts +60 -0
- package/src/redact.test.ts +91 -0
- package/src/redact.ts +91 -0
- package/src/secret-provider.ts +84 -0
- package/src/server.ts +17 -0
- package/src/session-like.ts +3 -0
- package/src/signature.ts +34 -0
- package/src/tool-icon.test.ts +46 -0
- package/src/tool-icon.ts +46 -0
- package/src/tool.ts +8 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
hasProxy,
|
|
4
|
+
installEgressProxy,
|
|
5
|
+
parseNoProxy,
|
|
6
|
+
readProxyEnv,
|
|
7
|
+
redactProxyUrl,
|
|
8
|
+
shouldBypassProxy,
|
|
9
|
+
} from './egress.js';
|
|
10
|
+
|
|
11
|
+
describe('readProxyEnv', () => {
|
|
12
|
+
it('reads uppercase and lowercase forms', () => {
|
|
13
|
+
expect(readProxyEnv({ HTTPS_PROXY: 'http://p:3128' })).toEqual({ httpsProxy: 'http://p:3128' });
|
|
14
|
+
expect(readProxyEnv({ https_proxy: 'http://p:3128' })).toEqual({ httpsProxy: 'http://p:3128' });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
// Lowercase wins by convention (curl, requests, Go): a shell exporting both
|
|
18
|
+
// usually set the lowercase one deliberately and more recently.
|
|
19
|
+
it('prefers the lowercase form when both are set', () => {
|
|
20
|
+
const env = { https_proxy: 'http://lower:1', HTTPS_PROXY: 'http://upper:2' };
|
|
21
|
+
expect(readProxyEnv(env).httpsProxy).toBe('http://lower:1');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('treats blank and whitespace values as unset', () => {
|
|
25
|
+
expect(readProxyEnv({ HTTPS_PROXY: '', HTTP_PROXY: ' ' })).toEqual({});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// A plaintext-only proxy declared via http_proxy must not silently swallow
|
|
29
|
+
// TLS traffic; https origins go direct instead of failing confusingly.
|
|
30
|
+
it('does not fall back to http_proxy for https origins', () => {
|
|
31
|
+
expect(readProxyEnv({ HTTP_PROXY: 'http://p:3128' })).toEqual({ httpProxy: 'http://p:3128' });
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('parseNoProxy', () => {
|
|
36
|
+
it('splits on commas and whitespace, lowercases, and drops blanks', () => {
|
|
37
|
+
expect(parseNoProxy(' localhost, .Corp.Example 10.0.0.1 ,, ')).toEqual([
|
|
38
|
+
'localhost',
|
|
39
|
+
'.corp.example',
|
|
40
|
+
'10.0.0.1',
|
|
41
|
+
]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('returns an empty list for undefined', () => {
|
|
45
|
+
expect(parseNoProxy(undefined)).toEqual([]);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('shouldBypassProxy', () => {
|
|
50
|
+
const bypass = (origin: string, rules: string): boolean =>
|
|
51
|
+
shouldBypassProxy(origin, parseNoProxy(rules));
|
|
52
|
+
|
|
53
|
+
it('bypasses everything on *', () => {
|
|
54
|
+
expect(bypass('https://api.anthropic.com', '*')).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('matches an exact host on any port', () => {
|
|
58
|
+
expect(bypass('http://localhost:8080/x', 'localhost')).toBe(true);
|
|
59
|
+
expect(bypass('https://localhost', 'localhost')).toBe(true);
|
|
60
|
+
expect(bypass('https://notlocalhost', 'localhost')).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// A leading dot covers the apex too, matching curl. Getting this wrong sends
|
|
64
|
+
// internal traffic through the proxy, which is exactly what NO_PROXY exists
|
|
65
|
+
// to prevent.
|
|
66
|
+
it('matches subdomains and the apex for a dotted rule', () => {
|
|
67
|
+
expect(bypass('https://api.corp.example', '.corp.example')).toBe(true);
|
|
68
|
+
expect(bypass('https://corp.example', '.corp.example')).toBe(true);
|
|
69
|
+
expect(bypass('https://deep.api.corp.example', '.corp.example')).toBe(true);
|
|
70
|
+
expect(bypass('https://evilcorp.example', '.corp.example')).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('treats *.domain the same as .domain', () => {
|
|
74
|
+
expect(bypass('https://api.corp.example', '*.corp.example')).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('honours a port qualifier', () => {
|
|
78
|
+
expect(bypass('http://registry.internal:8081/x', 'registry.internal:8081')).toBe(true);
|
|
79
|
+
expect(bypass('http://registry.internal:9999/x', 'registry.internal:8081')).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('resolves the implicit port from the scheme', () => {
|
|
83
|
+
expect(bypass('https://svc.internal/x', 'svc.internal:443')).toBe(true);
|
|
84
|
+
expect(bypass('http://svc.internal/x', 'svc.internal:80')).toBe(true);
|
|
85
|
+
expect(bypass('https://svc.internal/x', 'svc.internal:80')).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// `::1` must not be read as host ":" on port "1".
|
|
89
|
+
it('does not mistake an IPv6 literal for a host:port rule', () => {
|
|
90
|
+
expect(bypass('http://[::1]:7000/x', '::1')).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('is case-insensitive on the host', () => {
|
|
94
|
+
expect(bypass('https://API.Corp.Example', '.corp.example')).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('never bypasses with no rules, or on an unparseable origin', () => {
|
|
98
|
+
expect(bypass('https://api.anthropic.com', '')).toBe(false);
|
|
99
|
+
expect(shouldBypassProxy('not a url', ['*.corp.example'])).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('redactProxyUrl', () => {
|
|
104
|
+
it('masks embedded credentials', () => {
|
|
105
|
+
expect(redactProxyUrl('http://alice:hunter2@proxy.corp:3128')).toBe('http://***:***@proxy.corp:3128/');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('leaves a credential-free url intact', () => {
|
|
109
|
+
expect(redactProxyUrl('http://proxy.corp:3128')).toBe('http://proxy.corp:3128/');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Never echo something unparseable: it might be a secret pasted by mistake.
|
|
113
|
+
it('reveals nothing for an unparseable value', () => {
|
|
114
|
+
expect(redactProxyUrl('hunter2')).toBe('<invalid proxy url>');
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('installEgressProxy', () => {
|
|
119
|
+
/**
|
|
120
|
+
* Stand-in for undici. Each agent reports its own label through the dispatch
|
|
121
|
+
* handler, so `routeOf` observes WHICH agent the router picked for an origin,
|
|
122
|
+
* which is the whole contract worth testing here.
|
|
123
|
+
*/
|
|
124
|
+
const fakeUndici = () => {
|
|
125
|
+
let installed: { dispatch: (o: { origin?: string }, h: unknown) => boolean } | null = null;
|
|
126
|
+
const agent = (label: string) => ({
|
|
127
|
+
dispatch: (_o: unknown, h: { onPick?: (l: string) => void }) => {
|
|
128
|
+
h.onPick?.(label);
|
|
129
|
+
return true;
|
|
130
|
+
},
|
|
131
|
+
close: async () => {},
|
|
132
|
+
destroy: async () => {},
|
|
133
|
+
});
|
|
134
|
+
return {
|
|
135
|
+
routeOf: (origin: string): string => {
|
|
136
|
+
let picked = '';
|
|
137
|
+
installed!.dispatch({ origin }, {
|
|
138
|
+
onPick: (l: string) => {
|
|
139
|
+
picked = l;
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
return picked;
|
|
143
|
+
},
|
|
144
|
+
module: {
|
|
145
|
+
Agent: function Agent() {
|
|
146
|
+
return agent('direct');
|
|
147
|
+
} as never,
|
|
148
|
+
ProxyAgent: function ProxyAgent(o: { uri: string }) {
|
|
149
|
+
return agent(`proxy:${o.uri}`);
|
|
150
|
+
} as never,
|
|
151
|
+
setGlobalDispatcher: (d: never) => {
|
|
152
|
+
installed = d;
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
it('no-ops when no proxy is configured', async () => {
|
|
159
|
+
const status = await installEgressProxy({});
|
|
160
|
+
expect(status).toEqual({ enabled: false, reason: 'no proxy configured' });
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('reports and skips an unparseable proxy url instead of throwing', async () => {
|
|
164
|
+
const warn = vi.fn();
|
|
165
|
+
const status = await installEgressProxy({ httpsProxy: 'not a url' }, { warn });
|
|
166
|
+
expect(status.enabled).toBe(false);
|
|
167
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining('https_proxy is not a valid URL'));
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// A proxy is configured but the dependency is missing: degrade to direct with
|
|
171
|
+
// a loud warning rather than a failed boot.
|
|
172
|
+
it('warns and degrades when undici cannot be loaded', async () => {
|
|
173
|
+
const warn = vi.fn();
|
|
174
|
+
const status = await installEgressProxy(
|
|
175
|
+
{ httpsProxy: 'http://p:3128' },
|
|
176
|
+
{ warn, loadUndici: () => Promise.reject(new Error('not installed')) },
|
|
177
|
+
);
|
|
178
|
+
expect(status.enabled).toBe(false);
|
|
179
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining('`undici` package is unavailable'));
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('installs a dispatcher and redacts credentials in the reported status', async () => {
|
|
183
|
+
const u = fakeUndici();
|
|
184
|
+
const status = await installEgressProxy(
|
|
185
|
+
{ httpsProxy: 'http://bob:secret@p:3128', noProxy: 'localhost' },
|
|
186
|
+
{ loadUndici: async () => u.module as never },
|
|
187
|
+
);
|
|
188
|
+
expect(status.enabled).toBe(true);
|
|
189
|
+
expect(status.httpsProxy).toBe('http://***:***@p:3128/');
|
|
190
|
+
expect(status.httpsProxy).not.toContain('secret');
|
|
191
|
+
expect(status.noProxy).toEqual(['localhost']);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('routes https through the proxy and bypassed hosts direct', async () => {
|
|
195
|
+
const u = fakeUndici();
|
|
196
|
+
await installEgressProxy(
|
|
197
|
+
{ httpsProxy: 'http://p:3128', noProxy: '.corp.example' },
|
|
198
|
+
{ loadUndici: async () => u.module as never },
|
|
199
|
+
);
|
|
200
|
+
expect(u.routeOf('https://api.anthropic.com')).toBe('proxy:http://p:3128');
|
|
201
|
+
expect(u.routeOf('https://git.corp.example')).toBe('direct');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// Only http_proxy set: https origins must go direct, not through it.
|
|
205
|
+
it('sends https direct when only an http proxy is configured', async () => {
|
|
206
|
+
const u = fakeUndici();
|
|
207
|
+
await installEgressProxy(
|
|
208
|
+
{ httpProxy: 'http://p:3128' },
|
|
209
|
+
{ loadUndici: async () => u.module as never },
|
|
210
|
+
);
|
|
211
|
+
expect(u.routeOf('http://example.com')).toBe('proxy:http://p:3128');
|
|
212
|
+
expect(u.routeOf('https://example.com')).toBe('direct');
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe('hasProxy', () => {
|
|
217
|
+
it('is true when either proxy is set', () => {
|
|
218
|
+
expect(hasProxy({})).toBe(false);
|
|
219
|
+
expect(hasProxy({ noProxy: 'localhost' })).toBe(false);
|
|
220
|
+
expect(hasProxy({ httpProxy: 'http://p' })).toBe(true);
|
|
221
|
+
expect(hasProxy({ httpsProxy: 'http://p' })).toBe(true);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
describe('shouldBypassProxy IPv6 port qualifiers', () => {
|
|
226
|
+
const bypass = (origin: string, rules: string): boolean =>
|
|
227
|
+
shouldBypassProxy(origin, parseNoProxy(rules));
|
|
228
|
+
|
|
229
|
+
// A port on an IPv6 address requires brackets, exactly as in a URL.
|
|
230
|
+
it('honours a bracketed IPv6 host:port rule', () => {
|
|
231
|
+
expect(bypass('http://[::1]:7000/x', '[::1]:7000')).toBe(true);
|
|
232
|
+
expect(bypass('http://[::1]:9999/x', '[::1]:7000')).toBe(false);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('matches a bracketed IPv6 rule on any port', () => {
|
|
236
|
+
expect(bypass('http://[::1]:7000/x', '[::1]')).toBe(true);
|
|
237
|
+
});
|
|
238
|
+
});
|
package/src/egress.ts
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outbound HTTP egress: corporate proxy support for everything moxxy fetches.
|
|
3
|
+
*
|
|
4
|
+
* Node's global `fetch` ignores `HTTPS_PROXY` entirely, and every provider
|
|
5
|
+
* reaches its API through it (`@anthropic-ai/sdk`, `openai`, and the plain
|
|
6
|
+
* `fetch` calls in tools). On a network that requires an outbound proxy that
|
|
7
|
+
* means the first provider call fails with an opaque network error and there is
|
|
8
|
+
* no setting that fixes it. Installing a global undici dispatcher is the only
|
|
9
|
+
* way to route global `fetch` through a proxy.
|
|
10
|
+
*
|
|
11
|
+
* `undici` is imported DYNAMICALLY and only when a proxy is actually configured,
|
|
12
|
+
* so `@moxxy/sdk` keeps zero hard runtime dependencies and a host with no proxy
|
|
13
|
+
* pays nothing. The package is a real dependency of `@moxxy/cli`.
|
|
14
|
+
*
|
|
15
|
+
* Not handled here, on purpose: a corporate TLS-terminating CA is Node's job,
|
|
16
|
+
* via `NODE_EXTRA_CA_CERTS=/path/to/ca.pem` in the environment before Node
|
|
17
|
+
* starts. There is no in-process equivalent, so the doctor surfaces whether it
|
|
18
|
+
* is set rather than pretending to configure it.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Proxy inputs, already resolved from env and/or config. */
|
|
22
|
+
export interface EgressProxySettings {
|
|
23
|
+
/** Proxy for `http:` origins. */
|
|
24
|
+
readonly httpProxy?: string;
|
|
25
|
+
/** Proxy for `https:` origins. */
|
|
26
|
+
readonly httpsProxy?: string;
|
|
27
|
+
/** Raw `NO_PROXY` value: comma/whitespace separated bypass rules. */
|
|
28
|
+
readonly noProxy?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface EgressStatus {
|
|
32
|
+
/** True when a global dispatcher was installed. */
|
|
33
|
+
readonly enabled: boolean;
|
|
34
|
+
/** Credential-redacted proxy in use for `http:`, when set. */
|
|
35
|
+
readonly httpProxy?: string;
|
|
36
|
+
/** Credential-redacted proxy in use for `https:`, when set. */
|
|
37
|
+
readonly httpsProxy?: string;
|
|
38
|
+
/** Parsed bypass rules. */
|
|
39
|
+
readonly noProxy?: ReadonlyArray<string>;
|
|
40
|
+
/** Why nothing was installed, or why installation failed. */
|
|
41
|
+
readonly reason?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Read proxy settings from an environment. Lowercase wins over uppercase, which
|
|
46
|
+
* is the de-facto convention (curl, requests, Go): a shell that exports both
|
|
47
|
+
* usually means the lowercase one was set deliberately and more recently.
|
|
48
|
+
*
|
|
49
|
+
* `http_proxy` is deliberately NOT used as a fallback for https origins. Some
|
|
50
|
+
* environments set only `http_proxy` for a plaintext-only proxy, and silently
|
|
51
|
+
* tunnelling TLS through it would fail confusingly rather than going direct.
|
|
52
|
+
*/
|
|
53
|
+
export function readProxyEnv(
|
|
54
|
+
env: Readonly<Record<string, string | undefined>> = process.env,
|
|
55
|
+
): EgressProxySettings {
|
|
56
|
+
const pick = (lower: string, upper: string): string | undefined => {
|
|
57
|
+
const value = (env[lower] ?? env[upper] ?? '').trim();
|
|
58
|
+
return value.length > 0 ? value : undefined;
|
|
59
|
+
};
|
|
60
|
+
const httpProxy = pick('http_proxy', 'HTTP_PROXY');
|
|
61
|
+
const httpsProxy = pick('https_proxy', 'HTTPS_PROXY');
|
|
62
|
+
const noProxy = pick('no_proxy', 'NO_PROXY');
|
|
63
|
+
return {
|
|
64
|
+
...(httpProxy ? { httpProxy } : {}),
|
|
65
|
+
...(httpsProxy ? { httpsProxy } : {}),
|
|
66
|
+
...(noProxy ? { noProxy } : {}),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Split a `NO_PROXY` value into normalized rules. Separators are commas and
|
|
71
|
+
* whitespace; empty entries are dropped and matching is case-insensitive. */
|
|
72
|
+
export function parseNoProxy(value: string | undefined): string[] {
|
|
73
|
+
if (!value) return [];
|
|
74
|
+
return value
|
|
75
|
+
.split(/[,\s]+/)
|
|
76
|
+
.map((rule) => rule.trim().toLowerCase())
|
|
77
|
+
.filter((rule) => rule.length > 0);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Whether `origin` bypasses the proxy under `rules` (already parsed by
|
|
82
|
+
* {@link parseNoProxy}).
|
|
83
|
+
*
|
|
84
|
+
* Follows the de-facto `NO_PROXY` convention rather than inventing one:
|
|
85
|
+
* - `*` bypasses everything.
|
|
86
|
+
* - a bare host matches that host exactly, on any port.
|
|
87
|
+
* - a leading `.` or `*.` matches the domain and every subdomain, so
|
|
88
|
+
* `.corp.example` covers `api.corp.example` AND `corp.example`.
|
|
89
|
+
* - an entry carrying `:port` matches only that port; the origin's port is
|
|
90
|
+
* resolved from the scheme when the URL omits it.
|
|
91
|
+
*
|
|
92
|
+
* A malformed origin never bypasses: failing closed here sends the request
|
|
93
|
+
* through the proxy, which an operator can observe, rather than silently
|
|
94
|
+
* leaking it direct.
|
|
95
|
+
*/
|
|
96
|
+
export function shouldBypassProxy(origin: string, rules: ReadonlyArray<string>): boolean {
|
|
97
|
+
if (rules.length === 0) return false;
|
|
98
|
+
if (rules.includes('*')) return true;
|
|
99
|
+
|
|
100
|
+
let url: URL;
|
|
101
|
+
try {
|
|
102
|
+
url = new URL(origin);
|
|
103
|
+
} catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
107
|
+
const port = url.port || (url.protocol === 'https:' ? '443' : '80');
|
|
108
|
+
|
|
109
|
+
for (const rule of rules) {
|
|
110
|
+
const { host: ruleHost, port: rulePort } = splitRule(rule);
|
|
111
|
+
if (rulePort !== undefined && rulePort !== port) continue;
|
|
112
|
+
if (ruleHost.length === 0) continue;
|
|
113
|
+
|
|
114
|
+
const suffix = ruleHost.startsWith('*.')
|
|
115
|
+
? ruleHost.slice(1)
|
|
116
|
+
: ruleHost.startsWith('.')
|
|
117
|
+
? ruleHost
|
|
118
|
+
: undefined;
|
|
119
|
+
if (suffix) {
|
|
120
|
+
// `.corp.example` covers the apex too, matching curl.
|
|
121
|
+
if (host === suffix.slice(1) || host.endsWith(suffix)) return true;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (host === ruleHost) return true;
|
|
125
|
+
}
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Split a bypass rule into host and optional port.
|
|
131
|
+
*
|
|
132
|
+
* A bare IPv6 literal contains colons of its own, so `::1` must not be read as
|
|
133
|
+
* host `:` on port `1`. As in a URL, a port qualifier on an IPv6 address
|
|
134
|
+
* requires brackets (`[::1]:7000`); an unbracketed rule with more than one
|
|
135
|
+
* colon is therefore an address, never a host:port pair.
|
|
136
|
+
*/
|
|
137
|
+
function splitRule(rule: string): { host: string; port?: string } {
|
|
138
|
+
if (rule.startsWith('[')) {
|
|
139
|
+
const end = rule.indexOf(']');
|
|
140
|
+
if (end === -1) return { host: rule.slice(1) };
|
|
141
|
+
const host = rule.slice(1, end);
|
|
142
|
+
const rest = rule.slice(end + 1);
|
|
143
|
+
return rest.startsWith(':') && /^\d+$/.test(rest.slice(1))
|
|
144
|
+
? { host, port: rest.slice(1) }
|
|
145
|
+
: { host };
|
|
146
|
+
}
|
|
147
|
+
const first = rule.indexOf(':');
|
|
148
|
+
if (first === -1 || first !== rule.lastIndexOf(':')) return { host: rule };
|
|
149
|
+
const maybePort = rule.slice(first + 1);
|
|
150
|
+
return /^\d+$/.test(maybePort)
|
|
151
|
+
? { host: rule.slice(0, first), port: maybePort }
|
|
152
|
+
: { host: rule };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* A proxy URL with any embedded credentials masked, safe to log or show in
|
|
157
|
+
* `moxxy doctor`. Proxy URLs routinely carry `user:password@`, and this string
|
|
158
|
+
* ends up on terminals and in support pastes.
|
|
159
|
+
*/
|
|
160
|
+
export function redactProxyUrl(value: string): string {
|
|
161
|
+
try {
|
|
162
|
+
const url = new URL(value);
|
|
163
|
+
if (!url.username && !url.password) return url.toString();
|
|
164
|
+
url.username = url.username ? '***' : '';
|
|
165
|
+
url.password = url.password ? '***' : '';
|
|
166
|
+
return url.toString();
|
|
167
|
+
} catch {
|
|
168
|
+
// Unparseable: reveal nothing rather than echoing a possible secret.
|
|
169
|
+
return '<invalid proxy url>';
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** True when at least one proxy is configured, i.e. there is work to do. */
|
|
174
|
+
export function hasProxy(settings: EgressProxySettings): boolean {
|
|
175
|
+
return Boolean(settings.httpProxy ?? settings.httpsProxy);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
interface UndiciModule {
|
|
179
|
+
readonly Agent: new (opts?: unknown) => UndiciDispatcher;
|
|
180
|
+
readonly ProxyAgent: new (opts: { uri: string }) => UndiciDispatcher;
|
|
181
|
+
readonly setGlobalDispatcher: (dispatcher: UndiciDispatcher) => void;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
interface UndiciDispatcher {
|
|
185
|
+
dispatch(opts: { origin?: string | URL }, handler: unknown): boolean;
|
|
186
|
+
close(): Promise<void>;
|
|
187
|
+
destroy(): Promise<void>;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export interface InstallEgressProxyOptions {
|
|
191
|
+
/** Surface for the "configured but unusable" case. Defaults to stderr. */
|
|
192
|
+
readonly warn?: (message: string) => void;
|
|
193
|
+
/** Injectable module loader for tests. */
|
|
194
|
+
readonly loadUndici?: () => Promise<UndiciModule>;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Install a global dispatcher routing outbound requests through the configured
|
|
199
|
+
* proxy, honouring the bypass rules. No-ops (and reports why) when no proxy is
|
|
200
|
+
* configured, so this is safe to call unconditionally at boot.
|
|
201
|
+
*
|
|
202
|
+
* Never throws: a broken proxy setting must degrade to a loud warning plus
|
|
203
|
+
* direct connections, not a failed boot.
|
|
204
|
+
*/
|
|
205
|
+
export async function installEgressProxy(
|
|
206
|
+
settings: EgressProxySettings,
|
|
207
|
+
opts: InstallEgressProxyOptions = {},
|
|
208
|
+
): Promise<EgressStatus> {
|
|
209
|
+
if (!hasProxy(settings)) return { enabled: false, reason: 'no proxy configured' };
|
|
210
|
+
|
|
211
|
+
const warn = opts.warn ?? ((m: string) => process.stderr.write(`[moxxy] ${m}\n`));
|
|
212
|
+
for (const [label, value] of [
|
|
213
|
+
['http_proxy', settings.httpProxy],
|
|
214
|
+
['https_proxy', settings.httpsProxy],
|
|
215
|
+
] as const) {
|
|
216
|
+
if (value === undefined) continue;
|
|
217
|
+
try {
|
|
218
|
+
new URL(value);
|
|
219
|
+
} catch {
|
|
220
|
+
const reason = `${label} is not a valid URL; continuing without a proxy`;
|
|
221
|
+
warn(reason);
|
|
222
|
+
return { enabled: false, reason };
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
let undici: UndiciModule;
|
|
227
|
+
try {
|
|
228
|
+
undici = await (opts.loadUndici ?? defaultLoadUndici)();
|
|
229
|
+
} catch {
|
|
230
|
+
const reason =
|
|
231
|
+
'a proxy is configured but the `undici` package is unavailable; ' +
|
|
232
|
+
'outbound requests will bypass it and may fail';
|
|
233
|
+
warn(reason);
|
|
234
|
+
return { enabled: false, reason };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const rules = parseNoProxy(settings.noProxy);
|
|
238
|
+
const direct = new undici.Agent();
|
|
239
|
+
const httpProxy = settings.httpProxy ? new undici.ProxyAgent({ uri: settings.httpProxy }) : undefined;
|
|
240
|
+
const httpsProxy = settings.httpsProxy
|
|
241
|
+
? new undici.ProxyAgent({ uri: settings.httpsProxy })
|
|
242
|
+
: undefined;
|
|
243
|
+
|
|
244
|
+
const route = (origin: string | URL | undefined): UndiciDispatcher => {
|
|
245
|
+
if (origin === undefined) return direct;
|
|
246
|
+
const href = typeof origin === 'string' ? origin : origin.href;
|
|
247
|
+
if (shouldBypassProxy(href, rules)) return direct;
|
|
248
|
+
const secure = href.startsWith('https:');
|
|
249
|
+
return (secure ? httpsProxy : httpProxy) ?? direct;
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const all = [direct, httpProxy, httpsProxy].filter(Boolean) as UndiciDispatcher[];
|
|
253
|
+
const router: UndiciDispatcher = {
|
|
254
|
+
dispatch: (dispatchOpts, handler) => route(dispatchOpts.origin).dispatch(dispatchOpts, handler),
|
|
255
|
+
close: async () => {
|
|
256
|
+
await Promise.all(all.map((d) => d.close()));
|
|
257
|
+
},
|
|
258
|
+
destroy: async () => {
|
|
259
|
+
await Promise.all(all.map((d) => d.destroy()));
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
undici.setGlobalDispatcher(router);
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
enabled: true,
|
|
266
|
+
...(settings.httpProxy ? { httpProxy: redactProxyUrl(settings.httpProxy) } : {}),
|
|
267
|
+
...(settings.httpsProxy ? { httpsProxy: redactProxyUrl(settings.httpsProxy) } : {}),
|
|
268
|
+
...(rules.length > 0 ? { noProxy: rules } : {}),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function defaultLoadUndici(): Promise<UndiciModule> {
|
|
273
|
+
return (await import('undici')) as unknown as UndiciModule;
|
|
274
|
+
}
|
package/src/events.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { EventId, PluginId, SessionId, SkillId, ToolCallId, TurnId } from './ids.js';
|
|
2
|
+
import type { Principal } from './principal.js';
|
|
2
3
|
|
|
3
4
|
export type EventSource = 'user' | 'model' | 'tool' | 'plugin' | 'system' | 'compactor';
|
|
4
5
|
|
|
@@ -10,6 +11,23 @@ export interface EventBase {
|
|
|
10
11
|
readonly turnId: TurnId;
|
|
11
12
|
readonly causationId?: EventId;
|
|
12
13
|
readonly source: EventSource;
|
|
14
|
+
/**
|
|
15
|
+
* Who the action was taken on behalf of. `source` is a CATEGORY (user, model,
|
|
16
|
+
* tool, …); this is the SUBJECT, and without it a transcript proves a machine
|
|
17
|
+
* did something rather than that a person did.
|
|
18
|
+
*
|
|
19
|
+
* Stamped on EVERY event, not only turn boundaries, and carrying the whole
|
|
20
|
+
* principal rather than a reference. That repeats ~55 bytes per event, which
|
|
21
|
+
* is small against an envelope that already carries three ULIDs, and it buys
|
|
22
|
+
* the property an audit sink needs: a single record is self-attributing, so
|
|
23
|
+
* shipping one line to a SIEM requires no fold and no join. This is the same
|
|
24
|
+
* trade CloudTrail and the Kubernetes audit log make.
|
|
25
|
+
*
|
|
26
|
+
* OPTIONAL because the log is append-only and persisted: sessions recorded
|
|
27
|
+
* before identity existed replay with no actor, and must keep replaying.
|
|
28
|
+
* Treat absent as "unattributed", never as an error.
|
|
29
|
+
*/
|
|
30
|
+
readonly actor?: Principal;
|
|
13
31
|
}
|
|
14
32
|
|
|
15
33
|
export interface UserPromptAttachment {
|
package/src/fs-utils.test.ts
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
|
-
import { mkdtemp, readFile, readdir, stat } from 'node:fs/promises';
|
|
1
|
+
import { chmod, mkdir, mkdtemp, readFile, readdir, stat, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { tmpdir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
ensurePrivateDir,
|
|
7
|
+
ensurePrivateFile,
|
|
8
|
+
moxxyHome,
|
|
9
|
+
moxxyPath,
|
|
10
|
+
pruneStaleTempFiles,
|
|
11
|
+
writeFileAtomic,
|
|
12
|
+
writeFileAtomicSync,
|
|
13
|
+
} from './fs-utils.js';
|
|
6
14
|
|
|
7
15
|
describe('writeFileAtomic', () => {
|
|
8
16
|
let dir: string;
|
|
@@ -105,3 +113,91 @@ describe('moxxyHome / moxxyPath', () => {
|
|
|
105
113
|
expect(moxxyHome().endsWith('/.moxxy')).toBe(true);
|
|
106
114
|
});
|
|
107
115
|
});
|
|
116
|
+
|
|
117
|
+
// POSIX modes are meaningless on Windows (chmod only toggles the read-only
|
|
118
|
+
// flag), where these helpers are deliberate no-ops. Assert the guarantee only
|
|
119
|
+
// where the platform can actually provide it.
|
|
120
|
+
describe.skipIf(process.platform === 'win32')('ensurePrivateDir / ensurePrivateFile', () => {
|
|
121
|
+
let dir: string;
|
|
122
|
+
beforeEach(async () => {
|
|
123
|
+
dir = await mkdtemp(join(tmpdir(), 'moxxy-priv-'));
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('creates a directory owner-only', async () => {
|
|
127
|
+
const target = join(dir, 'sessions');
|
|
128
|
+
await ensurePrivateDir(target);
|
|
129
|
+
expect((await stat(target)).mode & 0o777).toBe(0o700);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('tightens a directory that already exists with group/other bits', async () => {
|
|
133
|
+
const target = join(dir, 'legacy');
|
|
134
|
+
await mkdir(target, { mode: 0o755 });
|
|
135
|
+
await chmod(target, 0o755); // defeat umask so the pre-state is really loose
|
|
136
|
+
await ensurePrivateDir(target);
|
|
137
|
+
expect((await stat(target)).mode & 0o777).toBe(0o700);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('creates a missing file owner-only without truncating an existing one', async () => {
|
|
141
|
+
const target = join(dir, 'log.jsonl');
|
|
142
|
+
await ensurePrivateFile(target);
|
|
143
|
+
expect((await stat(target)).mode & 0o777).toBe(0o600);
|
|
144
|
+
|
|
145
|
+
await writeFile(target, 'line\n');
|
|
146
|
+
await ensurePrivateFile(target);
|
|
147
|
+
expect(await readFile(target, 'utf8')).toBe('line\n');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('tightens a file that already exists world-readable', async () => {
|
|
151
|
+
const target = join(dir, 'legacy.jsonl');
|
|
152
|
+
await writeFile(target, 'secret transcript\n');
|
|
153
|
+
await chmod(target, 0o644);
|
|
154
|
+
await ensurePrivateFile(target);
|
|
155
|
+
expect((await stat(target)).mode & 0o777).toBe(0o600);
|
|
156
|
+
expect(await readFile(target, 'utf8')).toBe('secret transcript\n');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('creates parent directories', async () => {
|
|
160
|
+
const target = join(dir, 'a', 'b', 'c');
|
|
161
|
+
await ensurePrivateDir(target);
|
|
162
|
+
expect((await stat(target)).isDirectory()).toBe(true);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe('pruneStaleTempFiles', () => {
|
|
167
|
+
let dir: string;
|
|
168
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
169
|
+
beforeEach(async () => {
|
|
170
|
+
dir = await mkdtemp(join(tmpdir(), 'moxxy-prune-'));
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const tmpName = (base: string): string =>
|
|
174
|
+
`${base}.4242.3f2504e0-4f89-11d3-9a0c-0305e82c3301.tmp`;
|
|
175
|
+
|
|
176
|
+
it('removes an abandoned temp file older than a day', async () => {
|
|
177
|
+
const stale = join(dir, tmpName('schedules.json'));
|
|
178
|
+
await writeFile(stale, '{}');
|
|
179
|
+
const removed = await pruneStaleTempFiles([dir], Date.now() + 2 * DAY);
|
|
180
|
+
expect(removed).toBe(1);
|
|
181
|
+
expect(await readdir(dir)).toEqual([]);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// The guarantee that makes this safe to run while another moxxy process is
|
|
185
|
+
// mid-write: a live temp file is milliseconds old and can never be selected.
|
|
186
|
+
it('leaves a freshly-written temp file alone', async () => {
|
|
187
|
+
const live = join(dir, tmpName('vault.json'));
|
|
188
|
+
await writeFile(live, '{}');
|
|
189
|
+
expect(await pruneStaleTempFiles([dir])).toBe(0);
|
|
190
|
+
expect(await readdir(dir)).toEqual([tmpName('vault.json')]);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('ignores files that merely end in .tmp', async () => {
|
|
194
|
+
await writeFile(join(dir, 'notes.tmp'), 'user data');
|
|
195
|
+
await writeFile(join(dir, 'draft.md.tmp'), 'user data');
|
|
196
|
+
expect(await pruneStaleTempFiles([dir], Date.now() + 2 * DAY)).toBe(0);
|
|
197
|
+
expect((await readdir(dir)).sort()).toEqual(['draft.md.tmp', 'notes.tmp']);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('never throws on a missing directory', async () => {
|
|
201
|
+
await expect(pruneStaleTempFiles([join(dir, 'nope')])).resolves.toBe(0);
|
|
202
|
+
});
|
|
203
|
+
});
|