@moxxy/sdk 0.33.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/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 +11 -4
- 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/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
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
|
+
});
|
package/src/fs-utils.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { chmodSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { chmodSync, mkdirSync, promises as fsp, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { chmod, mkdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { dirname, join } from 'node:path';
|
|
@@ -11,6 +11,19 @@ export interface WriteFileAtomicOptions {
|
|
|
11
11
|
readonly encoding?: BufferEncoding;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* The private-path helpers below dispatch through `fs.promises` rather than
|
|
16
|
+
* destructured `node:fs/promises` bindings so the framework keeps ONE mockable
|
|
17
|
+
* filesystem boundary: a destructured binding is captured at import time and no
|
|
18
|
+
* longer observable, which silently blinds the regression guards that count
|
|
19
|
+
* `fs.open` / `fs.mkdir` calls (persistence.ready.test.ts).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Owner-only directory: no group/other bits, so nobody else can even traverse in. */
|
|
23
|
+
export const PRIVATE_DIR_MODE = 0o700;
|
|
24
|
+
/** Owner-only file. */
|
|
25
|
+
export const PRIVATE_FILE_MODE = 0o600;
|
|
26
|
+
|
|
14
27
|
/**
|
|
15
28
|
* Crash-atomic file write: write a unique sibling temp file, then `rename` it
|
|
16
29
|
* over the target. POSIX `rename` is atomic on the same filesystem, so a crash
|
|
@@ -84,3 +97,109 @@ export function moxxyHome(): string {
|
|
|
84
97
|
export function moxxyPath(...segments: string[]): string {
|
|
85
98
|
return join(moxxyHome(), ...segments);
|
|
86
99
|
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* POSIX permission bits are meaningless on Windows (`chmod` only toggles the
|
|
103
|
+
* read-only flag; access is governed by ACLs), so the tightening helpers below
|
|
104
|
+
* are no-ops there rather than pretending to enforce something they cannot.
|
|
105
|
+
*/
|
|
106
|
+
const POSIX_PERMS = process.platform !== 'win32';
|
|
107
|
+
|
|
108
|
+
/** Group/other bits. Their presence is what makes a path readable by another
|
|
109
|
+
* local account, and is the only condition worth a `chmod` syscall. */
|
|
110
|
+
const GROUP_OTHER = 0o077;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* `mkdir -p` a directory that must stay owner-only, tightening it when it
|
|
114
|
+
* already exists with looser bits.
|
|
115
|
+
*
|
|
116
|
+
* The tightening pass is what makes this safe to adopt mid-life: installs
|
|
117
|
+
* created before this was enforced have a `0755` directory on disk, and
|
|
118
|
+
* `mkdir` is a no-op on an existing path, so mode alone would never fix them.
|
|
119
|
+
*
|
|
120
|
+
* Best-effort by design: a permission error (a directory owned by another user,
|
|
121
|
+
* an exotic filesystem, a network mount) must never take down a boot whose only
|
|
122
|
+
* problem is that a chmod did not stick. Directory CREATION still throws, since
|
|
123
|
+
* failing to create the state directory is a real failure.
|
|
124
|
+
*/
|
|
125
|
+
export async function ensurePrivateDir(dir: string): Promise<void> {
|
|
126
|
+
await fsp.mkdir(dir, { recursive: true, mode: PRIVATE_DIR_MODE });
|
|
127
|
+
await tightenIfLoose(dir, PRIVATE_DIR_MODE);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Create-if-absent a file that must stay owner-only, tightening it when it
|
|
132
|
+
* already exists with looser bits. `open(path, 'a')` creates with
|
|
133
|
+
* `0o666 & ~umask` (typically `0644`), so an append-only log needs the mode
|
|
134
|
+
* applied explicitly and retroactively.
|
|
135
|
+
*/
|
|
136
|
+
export async function ensurePrivateFile(file: string): Promise<void> {
|
|
137
|
+
const handle = await fsp.open(file, 'a', PRIVATE_FILE_MODE);
|
|
138
|
+
await handle.close();
|
|
139
|
+
await tightenIfLoose(file, PRIVATE_FILE_MODE);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function tightenIfLoose(target: string, mode: number): Promise<void> {
|
|
143
|
+
if (!POSIX_PERMS) return;
|
|
144
|
+
try {
|
|
145
|
+
const st = await fsp.stat(target);
|
|
146
|
+
if ((st.mode & GROUP_OTHER) === 0) return;
|
|
147
|
+
await fsp.chmod(target, mode);
|
|
148
|
+
} catch {
|
|
149
|
+
/* best-effort: never fail a boot over a chmod */
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The temp-file shape {@link writeFileAtomic} renames from:
|
|
155
|
+
* `<target>.<pid>.<uuid>.tmp`. Anchored, and specific enough that a user file
|
|
156
|
+
* that merely ends in `.tmp` can never match.
|
|
157
|
+
*/
|
|
158
|
+
const ATOMIC_TMP = /\.\d+\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.tmp$/i;
|
|
159
|
+
|
|
160
|
+
/** Age past which an atomic-write temp file is certainly abandoned. A live
|
|
161
|
+
* write is sub-second; anything a day old outlived the process that made it. */
|
|
162
|
+
const STALE_TMP_AGE_MS = 24 * 60 * 60 * 1000;
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Delete abandoned {@link writeFileAtomic} temp files in `dirs`.
|
|
166
|
+
*
|
|
167
|
+
* `writeFileAtomic` unlinks its temp file when the write throws, but it cannot
|
|
168
|
+
* when the process is `SIGKILL`ed mid-write, so residue accumulates for the
|
|
169
|
+
* life of the install (six such files, the oldest a month old, prompted this).
|
|
170
|
+
*
|
|
171
|
+
* Two properties keep this safe against a CONCURRENT moxxy process writing to
|
|
172
|
+
* the same directory: only the exact `.<pid>.<uuid>.tmp` shape is considered,
|
|
173
|
+
* and only entries older than {@link STALE_TMP_AGE_MS}. A temp file that is
|
|
174
|
+
* still live is milliseconds old and can never be selected.
|
|
175
|
+
*
|
|
176
|
+
* Non-recursive and fully best-effort: it returns the number removed and never
|
|
177
|
+
* throws, so callers can fire it detached at boot without guarding it.
|
|
178
|
+
*/
|
|
179
|
+
export async function pruneStaleTempFiles(
|
|
180
|
+
dirs: ReadonlyArray<string>,
|
|
181
|
+
now = Date.now(),
|
|
182
|
+
): Promise<number> {
|
|
183
|
+
let removed = 0;
|
|
184
|
+
for (const dir of dirs) {
|
|
185
|
+
let names: string[];
|
|
186
|
+
try {
|
|
187
|
+
names = await fsp.readdir(dir);
|
|
188
|
+
} catch {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
for (const name of names) {
|
|
192
|
+
if (!ATOMIC_TMP.test(name)) continue;
|
|
193
|
+
const full = join(dir, name);
|
|
194
|
+
try {
|
|
195
|
+
const st = await fsp.stat(full);
|
|
196
|
+
if (now - st.mtimeMs < STALE_TMP_AGE_MS) continue;
|
|
197
|
+
await fsp.rm(full, { force: true });
|
|
198
|
+
removed++;
|
|
199
|
+
} catch {
|
|
200
|
+
/* raced with another pruner, or not ours to delete */
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return removed;
|
|
205
|
+
}
|
package/src/hooks.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { EventLogReader } from './log.js';
|
|
|
3
3
|
import type { PendingToolCall } from './permission.js';
|
|
4
4
|
import type { ProviderRequest } from './provider.js';
|
|
5
5
|
import type { SessionId, TurnId } from './ids.js';
|
|
6
|
+
import type { Principal } from './principal.js';
|
|
6
7
|
import type { ServiceRegistry } from './services.js';
|
|
7
8
|
|
|
8
9
|
export interface AppContext {
|
|
@@ -10,6 +11,13 @@ export interface AppContext {
|
|
|
10
11
|
readonly cwd: string;
|
|
11
12
|
readonly log: EventLogReader;
|
|
12
13
|
readonly env: Readonly<Record<string, string | undefined>>;
|
|
14
|
+
/**
|
|
15
|
+
* Who the session is attributed to, when the surface could establish it.
|
|
16
|
+
* This is what lets a policy or audit hook answer "who is asking" instead of
|
|
17
|
+
* only "what is being asked". Absent means unattributed: a hook that gates on
|
|
18
|
+
* identity must fail closed on `undefined` rather than assuming an admin.
|
|
19
|
+
*/
|
|
20
|
+
readonly actor?: Principal;
|
|
13
21
|
/**
|
|
14
22
|
* Inter-plugin service registry — publish a service in `onInit` for sibling
|
|
15
23
|
* plugins to consume in theirs. See {@link ServiceRegistry}.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { hostCredentialName } from './secret-provider.js';
|
|
3
|
+
|
|
4
|
+
describe('hostCredentialName', () => {
|
|
5
|
+
it('upper-snakes a host, mirroring the provider-key convention', () => {
|
|
6
|
+
expect(hostCredentialName('github.example.com')).toBe('MOXXY_CREDENTIAL_GITHUB_EXAMPLE_COM');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('accepts a full URL and uses only its host', () => {
|
|
10
|
+
expect(hostCredentialName('https://registry.example.internal/moxxy/index.json')).toBe(
|
|
11
|
+
'MOXXY_CREDENTIAL_REGISTRY_EXAMPLE_INTERNAL',
|
|
12
|
+
);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// A credential belongs to the host, not to a socket, so the same secret
|
|
16
|
+
// serves the mirror whether or not the URL happens to name a port.
|
|
17
|
+
it('ignores the port', () => {
|
|
18
|
+
expect(hostCredentialName('registry.internal:8443')).toBe(hostCredentialName('registry.internal'));
|
|
19
|
+
expect(hostCredentialName('https://registry.internal:8443/x')).toBe(
|
|
20
|
+
hostCredentialName('registry.internal'),
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('collapses runs of non-alphanumerics and trims the edges', () => {
|
|
25
|
+
expect(hostCredentialName('my--host..internal')).toBe('MOXXY_CREDENTIAL_MY_HOST_INTERNAL');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('handles an IPv6 literal', () => {
|
|
29
|
+
expect(hostCredentialName('https://[::1]:9000/x')).toBe('MOXXY_CREDENTIAL_1');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Deriving a name from garbage would silently look up the wrong secret, which
|
|
33
|
+
// reads as "no credential configured" rather than as the typo it is.
|
|
34
|
+
it('returns null rather than a name derived from nonsense', () => {
|
|
35
|
+
for (const bad of ['', ' ', 'not a host', 'https://', '://x']) {
|
|
36
|
+
expect(hostCredentialName(bad)).toBeNull();
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -42,6 +42,50 @@ export type {
|
|
|
42
42
|
|
|
43
43
|
export type { EventLogReader } from './log.js';
|
|
44
44
|
|
|
45
|
+
// Identity. The type + pure helpers ride the main barrel; the OS resolver needs
|
|
46
|
+
// `node:os` and is exported from `@moxxy/sdk/server`.
|
|
47
|
+
export type { Principal } from './principal.js';
|
|
48
|
+
export { samePrincipal, formatPrincipal } from './principal.js';
|
|
49
|
+
|
|
50
|
+
export { verifyEd25519 } from './signature.js';
|
|
51
|
+
|
|
52
|
+
export { TOOL_ICONS, isToolIcon, type ToolIcon } from './tool-icon.js';
|
|
53
|
+
|
|
54
|
+
export type {
|
|
55
|
+
AuditExporterDef,
|
|
56
|
+
AuditExportBatch,
|
|
57
|
+
AuditExportContext,
|
|
58
|
+
AuditExportResource,
|
|
59
|
+
} from './audit-export.js';
|
|
60
|
+
export { defineAuditExporter } from './audit-export.js';
|
|
61
|
+
|
|
62
|
+
// Audit trail: the tamper-evident receipt, distinct from the event log.
|
|
63
|
+
export type {
|
|
64
|
+
AuditAction,
|
|
65
|
+
AuditRecord,
|
|
66
|
+
AuditSinkDef,
|
|
67
|
+
AuditSinkScope,
|
|
68
|
+
AuditSinkSession,
|
|
69
|
+
UnchainedAuditRecord,
|
|
70
|
+
} from './audit.js';
|
|
71
|
+
export { defineAuditSink, auditActionOf } from './audit.js';
|
|
72
|
+
|
|
73
|
+
// Where a named secret comes from (external store instead of the local vault).
|
|
74
|
+
export type {
|
|
75
|
+
SecretProviderDef,
|
|
76
|
+
SecretProviderScope,
|
|
77
|
+
SecretProviderSession,
|
|
78
|
+
} from './secret-provider.js';
|
|
79
|
+
export { defineSecretProvider, hostCredentialName } from './secret-provider.js';
|
|
80
|
+
|
|
81
|
+
// Secret redaction for anything leaving the process.
|
|
82
|
+
export {
|
|
83
|
+
isSecretKey,
|
|
84
|
+
redactSecrets,
|
|
85
|
+
redactSecretText,
|
|
86
|
+
REDACTED_PLACEHOLDER,
|
|
87
|
+
} from './redact.js';
|
|
88
|
+
|
|
45
89
|
export type {
|
|
46
90
|
RunTurnOptions,
|
|
47
91
|
SessionLogReader,
|
|
@@ -217,6 +261,11 @@ export {
|
|
|
217
261
|
type ResolvedProviderTools,
|
|
218
262
|
} from './provider-tool-utils.js';
|
|
219
263
|
export type { WriteFileAtomicOptions } from './fs-utils.js';
|
|
264
|
+
export type {
|
|
265
|
+
EgressProxySettings,
|
|
266
|
+
EgressStatus,
|
|
267
|
+
InstallEgressProxyOptions,
|
|
268
|
+
} from './egress.js';
|
|
220
269
|
export type { ChannelRunStatus } from './channel-status.js';
|
|
221
270
|
export type { CrossProcessFireLock, CrossProcessFireLockOptions } from './cross-process-lock.js';
|
|
222
271
|
export { createMutex, type Mutex } from './mutex.js';
|