@metamynd/agentsafe-signer 0.15.1 → 0.17.1
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 +79 -30
- package/cli.mjs +37 -8
- package/daemon.mjs +65 -2
- package/package.json +1 -1
- package/policy-core.mjs +13 -5
- package/service-installer.mjs +233 -63
package/README.md
CHANGED
|
@@ -25,11 +25,37 @@ below) — no passphrase needed unless none is available on the host, in which c
|
|
|
25
25
|
tells you what to set. Opens the signing socket (always on) and the admin socket (closes after one
|
|
26
26
|
`generate-key` call or 60s, whichever comes first — see the design doc's "The admin interface").
|
|
27
27
|
|
|
28
|
+
**0.17.1 — `install` output that can actually start the daemon on macOS and Windows.** The launchd plist
|
|
29
|
+
and the Windows command ran `daemon.mjs` (a library with no argv handling) without `start`, the same defect
|
|
30
|
+
already fixed for systemd; the plist also waited on a launchd socket no client uses, and the Windows command
|
|
31
|
+
was an `sc.exe` service node cannot be (no SCM protocol, error 1053). Now: the plist runs `cli.mjs start` at
|
|
32
|
+
login; Windows gets a per-user Scheduled Task XML (`schtasks /Create /XML`), and `--tier 2` on Windows is
|
|
33
|
+
refused with a clear message. Every platform, systemd included, now passes the full DID as `--identity` —
|
|
34
|
+
the systemd unit passed the `%i` instance hash, which the daemon would reject on every sign. See "OS service
|
|
35
|
+
unit generation".
|
|
36
|
+
|
|
37
|
+
**0.17.0 — `refund` is its own service-call action.** `sign-service-call` accepts `action: 'refund'` with two
|
|
38
|
+
fields, `[amount, reason]` (`amount` as `String(n)`, or `''` for a full refund; `reason` or `''`), matching the
|
|
39
|
+
issuer's dedicated refund signature (MAGP §8.7.6). A refund used to be signed as a `void`, so one signature could
|
|
40
|
+
stand for either call; the issuer now refuses that. Needs `@metamynd/agentsafe-mcp-guard` 0.15.0 on the Service side.
|
|
41
|
+
|
|
42
|
+
**0.16.0 — a service-role daemon signs its Service's settlement calls (`sign-service-call`).** A Service
|
|
43
|
+
whose key lives here can now claim, capture, release, mark unknown and reconcile **as its own DID** (MAGP
|
|
44
|
+
§8.7.6) instead of anonymously — required for every mainnet hold, and on testnet once the owner registers
|
|
45
|
+
trusted counterparties (an anonymous claim is refused `COUNTERPARTY_AUTH_REQUIRED`). As with every op, the
|
|
46
|
+
daemon builds the `MAGP-SERVICE-v1 | action | authorizationId | …fields | nonce | issuedAt` message itself
|
|
47
|
+
from validated fields: `action` must be one of `claim`, `dispatched`, `unknown`, `capture`, `void`,
|
|
48
|
+
`reconcile` with exactly the field count the issuer signs for it, `authorizationId` a UUID, fields strings of
|
|
49
|
+
at most 512 characters, `issuedAt` fresh, and `serviceDid` (when given) the daemon's own identity. Service
|
|
50
|
+
role only — an agent-role daemon refuses it (`DAEMON_OPERATION_NOT_PERMITTED`), so an agent can never sign
|
|
51
|
+
as the counterparty that executes for it. Rate limit: 120 per 10 s. Needs `@metamynd/agentsafe-mcp-guard`
|
|
52
|
+
0.13.0 or later on the Service side.
|
|
53
|
+
|
|
28
54
|
## Status
|
|
29
55
|
|
|
30
56
|
**Implemented and tested:**
|
|
31
|
-
- The full
|
|
32
|
-
`sign-handshake-nonce`, `sign-key-control-challenge`, `sign-log-checkpoint`,
|
|
57
|
+
- The full ten-operation signing-socket protocol (`sign-authorize`, `sign-envelope`, `sign-payload`,
|
|
58
|
+
`sign-handshake-nonce`, `sign-service-call`, `sign-key-control-challenge`, `sign-log-checkpoint`,
|
|
33
59
|
`sign-local-decision`, `get-identity`, `ping`) and the two-operation admin socket
|
|
34
60
|
(`generate-key`, `status`), matching the design doc's closed operation set exactly — no generic
|
|
35
61
|
"sign this string" primitive.
|
|
@@ -130,15 +156,17 @@ tells you what to set. Opens the signing socket (always on) and the admin socket
|
|
|
130
156
|
by `sodium-native` (this package's only dependency, added deliberately — this specific property
|
|
131
157
|
cannot be reached via a shell-out the way every other gap here was closed, since it must run
|
|
132
158
|
inside the process that owns the memory). `secure-memory.smoke.mjs`.
|
|
133
|
-
- **Core-dump-disable
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
159
|
+
- **Core-dump-disable**, closed declaratively rather than in the daemon's own runtime code: the
|
|
160
|
+
generated systemd units set `LimitCORE=0`, and the generated launchd plist routes through
|
|
161
|
+
`/bin/sh -c 'ulimit -c 0; exec "$@"'` (launchd has no `LimitCORE`-equivalent key, and `ulimit` is
|
|
162
|
+
a shell builtin) — a true `exec()` chain, never a forked supervisor process. **`ptrace`-denial is
|
|
163
|
+
not implemented by a wrapper**: an earlier pass prefixed `ExecStart=` with
|
|
164
|
+
`setpriv --dumpable 0 --`, but that option does not exist (see the defect table under "OS service
|
|
165
|
+
unit generation"), so it was removed. On Linux, T3 rests on Tier 2's dedicated `DynamicUser` UID
|
|
166
|
+
(verified, below) and, at Tier 1, on the host's own `kernel.yama.ptrace_scope`. macOS has no
|
|
167
|
+
`ptrace`-deny reachable this way at all — `PT_DENY_ATTACH` must be called *by* the target process
|
|
168
|
+
itself via a syscall, the same architectural limitation `secure-memory.mjs` discloses for
|
|
169
|
+
`mlock()`. Windows has no core-dump or ptrace directive in the generated Scheduled Task.
|
|
142
170
|
- **`sign-local-decision`, closing the gap left when local-decision audit reporting first
|
|
143
171
|
shipped.** A daemon-custody agent can now sign its own local-first block/escalate/non-value-allow
|
|
144
172
|
verdict for `POST /policy/decisions/local` the same way the static-key provider always could —
|
|
@@ -156,14 +184,13 @@ tells you what to set. Opens the signing socket (always on) and the admin socket
|
|
|
156
184
|
bind a payload failed closed. `daemon-protocol.smoke.mjs`.
|
|
157
185
|
|
|
158
186
|
**Deliberately not yet implemented — real gaps, not oversights:**
|
|
159
|
-
- **
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
-
|
|
165
|
-
|
|
166
|
-
weakening it would restore the exact blind spot that let a never-runnable unit ship.
|
|
187
|
+
- **Socket activation** (systemd `LISTEN_FDS`, launchd `launch_activate_socket`) — see "Still
|
|
188
|
+
open" under "OS service unit generation". The daemon always binds its own
|
|
189
|
+
`<state-dir>/signer.sock`.
|
|
190
|
+
- **The launchd plist and the Windows Scheduled Task have never been installed on a real host.**
|
|
191
|
+
Their content is asserted by `service-installer.smoke.mjs`; only the systemd unit has been run.
|
|
192
|
+
- **Windows Tier 2** (a separate service account) — node does not implement the Service Control
|
|
193
|
+
Manager protocol, so `cli.mjs install --platform win32 --tier 2` refuses with a clear message.
|
|
167
194
|
- All the smoke tests the design doc names now exist as their own files:
|
|
168
195
|
`daemon-socket-permissions.smoke.mjs` (T4), `daemon-hardening.smoke.mjs` (Tier 1) and
|
|
169
196
|
`daemon-hardening-tier2.smoke.mjs` (Tier 2, verified against a real system unit — see above). **Log integrity** is its own real file
|
|
@@ -263,11 +290,11 @@ used for this check needs to wait for the stop before detaching.
|
|
|
263
290
|
|
|
264
291
|
## OS service unit generation
|
|
265
292
|
|
|
266
|
-
`service-installer.mjs` generates the real systemd/launchd/Windows
|
|
293
|
+
`service-installer.mjs` generates the real systemd/launchd/Windows definitions from the design
|
|
267
294
|
doc's own templates — parameterized by identity and tier — and `cli.mjs install` writes them to a
|
|
268
295
|
local `--out-dir` you choose. **It never writes to a real system service location
|
|
269
|
-
(`~/.config/systemd/user`, `/etc/systemd/system`, `~/Library/LaunchAgents`, the Windows
|
|
270
|
-
|
|
296
|
+
(`~/.config/systemd/user`, `/etc/systemd/system`, `~/Library/LaunchAgents`, the Windows Task
|
|
297
|
+
Scheduler) and never runs `systemctl`/`launchctl`/`schtasks` itself** — verified by
|
|
271
298
|
`service-installer.smoke.mjs`'s own static check of the module's source, not just asserted in
|
|
272
299
|
this paragraph. It prints the exact command to actually install the result; running that command
|
|
273
300
|
is always a separate, deliberate, human step — registering a real system service is a genuine
|
|
@@ -278,11 +305,30 @@ node cli.mjs install --identity <did> --daemon-path /opt/agentsafe/signer/daemon
|
|
|
278
305
|
--platform linux --tier 2 --out-dir ./service-units
|
|
279
306
|
```
|
|
280
307
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
308
|
+
What each platform gets (every one runs `cli.mjs start --state-dir <dir> --role <role> --identity
|
|
309
|
+
<full DID>`, never the `daemon.mjs` library module):
|
|
310
|
+
|
|
311
|
+
| Platform | Mechanism | State dir | Tiers |
|
|
312
|
+
|---|---|---|---|
|
|
313
|
+
| Linux | systemd `.socket` + `.service` | `%S/agentsafe/<id>` (`StateDirectory=`) | 1 (`--user`) and 2 (`DynamicUser`) |
|
|
314
|
+
| macOS | launchd LaunchAgent, `RunAtLoad`, core dumps off via `ulimit -c 0` | `~/Library/Application Support/agentsafe/<id>` (or `--state-dir`); log in `~/Library/Logs/agentsafe-signer.<id>.log` | 1; Tier 2's Seatbelt profile is not generated |
|
|
315
|
+
| Windows | Task Scheduler XML: logon trigger for one user, runs as that user unelevated (`InteractiveToken`, so the DPAPI KEK works), no 72h time limit, not stopped on battery | `%LOCALAPPDATA%\agentsafe\<id>` (or `--state-dir`) | 1 only — see below |
|
|
316
|
+
|
|
317
|
+
**Windows is a Scheduled Task, not a service.** Earlier versions printed `sc.exe create
|
|
318
|
+
AgentSafeSigner-<id> binPath="node.exe daemon.mjs --identity <id>"` under a Virtual Service
|
|
319
|
+
Account. That could not work: the Service Control Manager requires the process to call
|
|
320
|
+
`StartServiceCtrlDispatcher`/`SetServiceStatus`, node does not, and SCM kills such a process with
|
|
321
|
+
error 1053 after about 30 seconds. It also ran `daemon.mjs` without `start`. A real Windows service
|
|
322
|
+
needs an SCM-aware host (WinSW, NSSM) — a third-party dependency this package does not take — so
|
|
323
|
+
`--tier 2` on `win32` now exits with a message saying so. The task runs a console window at
|
|
324
|
+
logon; closing it stops the signer.
|
|
325
|
+
|
|
326
|
+
Tested by `service-installer.smoke.mjs` (content only — nothing is executed): the Tier 1/Tier 2
|
|
327
|
+
systemd differences (`%h`-vs-absolute `ExecStart`, `RemoveIPC=true` placement); the full launchd
|
|
328
|
+
`ProgramArguments` array, `RunAtLoad`/`KeepAlive`/`Umask`, and XML escaping; the Windows task's
|
|
329
|
+
`<Command>`/`<Arguments>` (including Windows command-line quoting of paths with spaces and trailing
|
|
330
|
+
backslashes), trigger, principal and settings, the `schtasks` argv, the UTF-16LE+BOM file the CLI
|
|
331
|
+
writes, and the Tier 2 refusal.
|
|
286
332
|
|
|
287
333
|
**Now also tested, for the first time, by actually installing and starting it** (Ubuntu 24.04.4
|
|
288
334
|
LTS, systemd 255, `systemctl --user`). The socket unit works: systemd created
|
|
@@ -301,8 +347,9 @@ running it:
|
|
|
301
347
|
| 2 | `%h/` prefixed onto an **absolute** `--daemon-path` | `ExecStart` resolved to `/home/u/home/u/...`; the CLI's own documented example passes an absolute path, so this was the common case | **Fixed** — `%h` now applied only to a relative path |
|
|
302
348
|
| 3 | `nodePath` defaulting to `/usr/bin/node` | Absent on any nvm/asdf/volta host (this one had node only under `~/.nvm`); systemd reports a bare `203/EXEC` with no hint which binary was missing | **Fixed** — defaults to `process.execPath` |
|
|
303
349
|
| 4 | `MemoryDenyWriteExecute=true` | V8 is a JIT; its `mprotect` fails with `ENOMEM` inside `v8::base::OS::SetPermissions` during Isolate init and node dies with `SIGTRAP` (`Check failed: 12 == errno`) before running any daemon code. **No Node service can set this directive** | **Fixed** — removed from both tiers |
|
|
304
|
-
| 5 | `ExecStart` points at `daemon.mjs` | `daemon.mjs` is a library module with no argv handling — `node daemon.mjs` returns 0 immediately and serves nothing | **Fixed** — `ExecStart` now runs `cli.mjs start --state-dir %S/agentsafe/%i --role <role> --identity
|
|
350
|
+
| 5 | `ExecStart` points at `daemon.mjs` | `daemon.mjs` is a library module with no argv handling — `node daemon.mjs` returns 0 immediately and serves nothing | **Fixed** — `ExecStart` now runs `cli.mjs start --state-dir %S/agentsafe/%i --role <role> --identity …` (see row 7 for the identity value) |
|
|
305
351
|
| 6 | `Type=notify` | Nothing in the package sends `sd_notify READY=1`, so systemd fails the unit with `Result: protocol` even though node is healthy and listening | **Fixed** — `Type=exec`, which still waits for a successful `execve()` without requiring the notify protocol |
|
|
352
|
+
| 7 | `--identity %i` | `%i` is the 8-hex-digit instance id, not the DID. The daemon compares every signing request's `agentDid` against `--identity`, so it answered `ping` (which is how the fixes above were verified) but would refuse every sign with `DAEMON_IDENTITY_MISMATCH`. Found by reading, not by running | **Fixed** (0.17.1) — `--identity <full DID>`, with `%`/`$` escaped for systemd |
|
|
306
353
|
|
|
307
354
|
**With all six fixed, the generated unit now starts and serves.** Verified by installing the
|
|
308
355
|
generator's own output unmodified (plus a drop-in supplying `AGENTSAFE_SIGNER_PASSPHRASE`, since
|
|
@@ -331,8 +378,10 @@ point at the daemon's own `<state-dir>/signer.sock` until this is closed.
|
|
|
331
378
|
Closing it properly means implementing the sd_notify/`LISTEN_FDS` protocol in the daemon — a real
|
|
332
379
|
feature, not a generator tweak, and deliberately out of scope for this verification pass in the
|
|
333
380
|
same way the macOS ptrace-deny gap above is: disclosed as a first-implementation-pass limit rather
|
|
334
|
-
than quietly left for someone to discover in production.
|
|
335
|
-
|
|
381
|
+
than quietly left for someone to discover in production. The launchd plist deliberately declares
|
|
382
|
+
no `Sockets` key (an earlier version did, with `RunAtLoad=false`, so the daemon only started when a
|
|
383
|
+
client connected to a launchd socket no client uses — it never started at all). Still untested:
|
|
384
|
+
launchd and Windows installation on a real host.
|
|
336
385
|
|
|
337
386
|
## OS-keychain KEK backends
|
|
338
387
|
|
package/cli.mjs
CHANGED
|
@@ -8,7 +8,8 @@ import path from 'node:path';
|
|
|
8
8
|
import { SignerDaemon } from './daemon.mjs';
|
|
9
9
|
import { deriveKek, loadOrCreateSalt } from './keystore.mjs';
|
|
10
10
|
import { resolveKek } from './kek-backends.mjs';
|
|
11
|
-
import {
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import { generateSystemdUnits, generateLaunchdPlist, generateWindowsScheduledTask, instanceId, WINDOWS_TIER2_UNSUPPORTED } from './service-installer.mjs';
|
|
12
13
|
import { verifyLogCheckpoints, listCheckpoints } from './log-checkpoint.mjs';
|
|
13
14
|
import { runLogAnchor } from './log-anchor.mjs';
|
|
14
15
|
import { runMigrate } from './migrate.mjs';
|
|
@@ -23,7 +24,22 @@ function runInstall(args) {
|
|
|
23
24
|
const platform = args.platform ?? process.platform;
|
|
24
25
|
const tier = args.tier === '2' || args.tier === 2 ? 2 : 1;
|
|
25
26
|
const identity = args.identity;
|
|
26
|
-
|
|
27
|
+
// Default: the daemon.mjs installed next to THIS cli.mjs, not one in the current directory — the
|
|
28
|
+
// generated unit runs cli.mjs from the same directory, so it must be a path that really exists.
|
|
29
|
+
const daemonPath = args['daemon-path'] ?? path.join(path.dirname(fileURLToPath(import.meta.url)), 'daemon.mjs');
|
|
30
|
+
// launchd and Task Scheduler have no %S/%h equivalent, so their state dir is an absolute path
|
|
31
|
+
// resolved at generation time from the generating user's home. Generating for another platform
|
|
32
|
+
// (or another user) must name it explicitly.
|
|
33
|
+
const stateDir = typeof args['state-dir'] === 'string' ? args['state-dir'] : undefined;
|
|
34
|
+
const homeDir = typeof args['home-dir'] === 'string' ? args['home-dir'] : undefined;
|
|
35
|
+
if (platform === 'darwin' && process.platform !== 'darwin' && !homeDir) {
|
|
36
|
+
console.error(`agentsafe-signer install --platform darwin on a ${process.platform} host requires --home-dir <the target user's macOS home, e.g. /Users/alice>`);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
if (platform === 'win32' && process.platform !== 'win32' && (!stateDir || typeof args.user !== 'string')) {
|
|
40
|
+
console.error(`agentsafe-signer install --platform win32 on a ${process.platform} host requires --state-dir <absolute Windows path> and --user <DOMAIN\\user>`);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
27
43
|
const outDir = args['out-dir'] ?? path.join(process.cwd(), 'service-units');
|
|
28
44
|
// --node-path matters most at Tier 2. The generator defaults to process.execPath, which is
|
|
29
45
|
// correct at Tier 1 (the unit runs AS the invoking user, so their nvm/asdf node is reachable)
|
|
@@ -54,15 +70,28 @@ function runInstall(args) {
|
|
|
54
70
|
' /usr/local/share/agentsafe/signer/.');
|
|
55
71
|
}
|
|
56
72
|
} else if (platform === 'darwin') {
|
|
57
|
-
const { plist, plistFileName, enableCommand } = generateLaunchdPlist({
|
|
73
|
+
const { plist, plistFileName, enableCommand, stateDir: plistStateDir, logPath } = generateLaunchdPlist({
|
|
74
|
+
identity, tier, daemonPath, role, ...(nodePath ? { nodePath } : {}), ...(stateDir ? { stateDir } : {}), ...(homeDir ? { homeDir } : {}),
|
|
75
|
+
});
|
|
58
76
|
fs.writeFileSync(path.join(outDir, plistFileName), plist);
|
|
59
77
|
console.log(`Wrote launchd plist to ${outDir}`);
|
|
78
|
+
console.log(`State dir: ${plistStateDir}\nLog file: ${logPath}`);
|
|
60
79
|
console.log(`Copy it into ~/Library/LaunchAgents/, then run:\n ${enableCommand}`);
|
|
61
80
|
} else if (platform === 'win32') {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
81
|
+
if (tier === 2) {
|
|
82
|
+
console.error(WINDOWS_TIER2_UNSUPPORTED);
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
const xmlPath = path.resolve(outDir, `AgentSafeSigner-${instanceId(identity)}.xml`);
|
|
86
|
+
const task = generateWindowsScheduledTask({
|
|
87
|
+
identity, daemonPath, role, xmlPath, ...(nodePath ? { nodePath } : {}), ...(stateDir ? { stateDir } : {}), ...(typeof args.user === 'string' ? { userId: args.user } : {}),
|
|
88
|
+
});
|
|
89
|
+
// schtasks /XML reads the file as UTF-16 (the encoding the XML declares), BOM included.
|
|
90
|
+
fs.writeFileSync(xmlPath, Buffer.from('\ufeff' + task.xml, 'utf16le'));
|
|
91
|
+
console.log(`Wrote the Scheduled Task definition to ${xmlPath}`);
|
|
92
|
+
console.log(`State dir: ${task.stateDir}`);
|
|
93
|
+
console.log(`It starts the signer at your logon, as you, unelevated. Run it yourself:\n ${task.enableCommand.replace(/\n/g, '\n ')}`);
|
|
94
|
+
console.log('A console window opens for the signer at logon; closing it stops the signer.');
|
|
66
95
|
} else {
|
|
67
96
|
console.error(`Unsupported --platform "${platform}" — use linux, darwin, or win32.`);
|
|
68
97
|
process.exit(1);
|
|
@@ -153,7 +182,7 @@ async function main() {
|
|
|
153
182
|
if (command === 'migrate') return runMigrateCommand(args);
|
|
154
183
|
if (command !== 'start') {
|
|
155
184
|
console.error('Usage: agentsafe-signer start --state-dir <dir> --role agent|service [--identity <did>] [--admin] [--allow-rekey]');
|
|
156
|
-
console.error(' or: agentsafe-signer install --identity <did> --daemon-path <path> [--platform linux|darwin|win32] [--tier 1|2] [--role agent|service] [--node-path <path>] [--out-dir <dir>]');
|
|
185
|
+
console.error(' or: agentsafe-signer install --identity <did> --daemon-path <path> [--platform linux|darwin|win32] [--tier 1|2] [--role agent|service] [--node-path <path>] [--state-dir <dir>] [--home-dir <macOS home>] [--user <DOMAIN\\user>] [--out-dir <dir>]');
|
|
157
186
|
console.error(' or: agentsafe-signer verify-log --state-dir <dir>');
|
|
158
187
|
console.error(' or: agentsafe-signer log-anchor --state-dir <dir> --api-url <backend base url>');
|
|
159
188
|
console.error(' or: agentsafe-signer migrate --state-dir <dir> --api-url <backend base url> --auth-token <token> --ref <agentIdentityId-or-did> --network-identity-id <id>');
|
package/daemon.mjs
CHANGED
|
@@ -18,7 +18,7 @@ export const MAX_LINE_BYTES = 64 * 1024;
|
|
|
18
18
|
export const FRESHNESS_MS = 5 * 60 * 1000;
|
|
19
19
|
export const CLOCK_SKEW_MS = 30 * 1000;
|
|
20
20
|
|
|
21
|
-
const ALL_SIGNING_OPS = new Set(['sign-authorize', 'sign-envelope', 'sign-payload', 'sign-handshake-nonce', 'sign-key-control-challenge', 'sign-log-checkpoint', 'sign-local-decision', 'get-identity', 'ping']);
|
|
21
|
+
const ALL_SIGNING_OPS = new Set(['sign-authorize', 'sign-envelope', 'sign-payload', 'sign-handshake-nonce', 'sign-key-control-challenge', 'sign-log-checkpoint', 'sign-local-decision', 'sign-service-call', 'get-identity', 'ping']);
|
|
22
22
|
|
|
23
23
|
// sign-log-checkpoint is in BOTH role sets, unlike every other sign-* op: it authenticates the
|
|
24
24
|
// daemon's own log-tamper-evidence checkpoint (T11), which every daemon keeps regardless of
|
|
@@ -29,7 +29,10 @@ const ALL_SIGNING_OPS = new Set(['sign-authorize', 'sign-envelope', 'sign-payloa
|
|
|
29
29
|
// mandate locally, so it has nothing to report.
|
|
30
30
|
const SIGNING_OPS_BY_ROLE = {
|
|
31
31
|
agent: new Set(['sign-authorize', 'sign-envelope', 'sign-payload', 'sign-handshake-nonce', 'sign-key-control-challenge', 'sign-log-checkpoint', 'sign-local-decision', 'get-identity', 'ping']),
|
|
32
|
-
|
|
32
|
+
// sign-service-call is SERVICE-role only: a Service's own settlement-surface call (claim / capture / void / …,
|
|
33
|
+
// MAGP §8.7.6). An agent never claims or settles its own holds — an agent-role daemon signing one would let an
|
|
34
|
+
// agent impersonate the counterparty that executes for it.
|
|
35
|
+
service: new Set(['sign-handshake-nonce', 'sign-service-call', 'sign-log-checkpoint', 'get-identity', 'ping']),
|
|
33
36
|
};
|
|
34
37
|
|
|
35
38
|
const DEFAULT_RATE_LIMITS = {
|
|
@@ -48,8 +51,31 @@ const DEFAULT_RATE_LIMITS = {
|
|
|
48
51
|
// catches a misbehaving or compromised anchor script hammering this op instead of running on
|
|
49
52
|
// its own schedule.
|
|
50
53
|
'sign-log-checkpoint': { max: 5, windowMs: 60 * 60 * 1000 },
|
|
54
|
+
// A settled call is a claim plus one close (capture / void / unknown), so two signatures per executed
|
|
55
|
+
// action; a busy gateway serves many agents, hence well above sign-authorize's per-agent ceiling.
|
|
56
|
+
'sign-service-call': { max: 120, windowMs: 10_000 },
|
|
51
57
|
};
|
|
52
58
|
|
|
59
|
+
// MAGP §8.7.6 — the issuer's own layout (backend counterparty-auth.ts buildCounterpartyMessage):
|
|
60
|
+
// MAGP-SERVICE-v1 | action | authorizationId | ...fields | nonce | issuedAt (each field "\" and "|" escaped)
|
|
61
|
+
// The per-action field counts are the ones the issuer reconstructs the message from; anything else could
|
|
62
|
+
// never verify there, so it is refused here rather than signed.
|
|
63
|
+
const SERVICE_CALL_PREFIX = 'MAGP-SERVICE-v1';
|
|
64
|
+
const SERVICE_CALL_FIELD_COUNTS = {
|
|
65
|
+
claim: [1, 2], // idempotency key [, payload digest field]
|
|
66
|
+
dispatched: [1, 1], // remote reference
|
|
67
|
+
unknown: [1, 1], // reason
|
|
68
|
+
capture: [3, 3], // amountCharged, bookingRef, settlementTxHash
|
|
69
|
+
void: [1, 1], // reason
|
|
70
|
+
refund: [2, 2], // amount ('' for a full refund), reason — its own action since 0.17.0, never signed as a void
|
|
71
|
+
reconcile: [0, 0],
|
|
72
|
+
};
|
|
73
|
+
const MAX_SERVICE_FIELD_LENGTH = 512;
|
|
74
|
+
const escapeServiceField = (v) => String(v).replace(/\\/g, '\\\\').replace(/\|/g, '\\|');
|
|
75
|
+
export function buildServiceCallMessage({ action, authorizationId, fields, nonce, issuedAt }) {
|
|
76
|
+
return [SERVICE_CALL_PREFIX, action, authorizationId, ...fields, nonce, issuedAt].map(escapeServiceField).join('|');
|
|
77
|
+
}
|
|
78
|
+
|
|
53
79
|
export const DEFAULT_CHECKPOINT_INTERVAL_MS = 15 * 60 * 1000;
|
|
54
80
|
|
|
55
81
|
// Matches agentsafe-guard.mjs's own REPORTABLE_LOCAL_DECISIONS exactly (LOCAL_DECISIONS the
|
|
@@ -251,6 +277,8 @@ export class SignerDaemon {
|
|
|
251
277
|
return this.#handleSignLogCheckpoint(params);
|
|
252
278
|
case 'sign-local-decision':
|
|
253
279
|
return this.#handleSignLocalDecision(params);
|
|
280
|
+
case 'sign-service-call':
|
|
281
|
+
return this.#handleSignServiceCall(params);
|
|
254
282
|
default:
|
|
255
283
|
throw new DaemonError('DAEMON_UNKNOWN_OPERATION');
|
|
256
284
|
}
|
|
@@ -345,6 +373,41 @@ export class SignerDaemon {
|
|
|
345
373
|
return { signature: this.#sign(Buffer.from(message, 'utf8')) };
|
|
346
374
|
}
|
|
347
375
|
|
|
376
|
+
/**
|
|
377
|
+
* A Service's own settlement-surface call, signed as its identity (MAGP §8.7.6): the claim that makes it the one
|
|
378
|
+
* party allowed to settle the hold, and the capture / void / unknown / reconcile that follow. Without this a
|
|
379
|
+
* daemon-custody Service could only call anonymously, which the issuer refuses for every mainnet hold and for any
|
|
380
|
+
* owner who has registered counterparties (COUNTERPARTY_AUTH_REQUIRED).
|
|
381
|
+
*
|
|
382
|
+
* As with every op here the daemon builds the message ITSELF from validated fields, never signing caller bytes:
|
|
383
|
+
* the fixed `MAGP-SERVICE-v1` prefix is a domain no other message this daemon signs starts with (authorize
|
|
384
|
+
* messages start with the DID, handshake nonces are bare UUIDs, checkpoints and payload bindings carry their own
|
|
385
|
+
* prefixes), so a signature made here cannot be replayed as anything else. `serviceDid`, when given, must be this
|
|
386
|
+
* daemon's own bound identity — a Service configured with the wrong DID fails here, loudly, instead of sending a
|
|
387
|
+
* signature the issuer will reject as COUNTERPARTY_AUTH_INVALID.
|
|
388
|
+
*/
|
|
389
|
+
#handleSignServiceCall(params = {}) {
|
|
390
|
+
this.#assertRateLimit('sign-service-call');
|
|
391
|
+
const { serviceDid, action, authorizationId, fields, nonce, issuedAt } = params;
|
|
392
|
+
if (typeof action !== 'string' || !Object.hasOwn(SERVICE_CALL_FIELD_COUNTS, action)) {
|
|
393
|
+
throw new DaemonError('DAEMON_MALFORMED_REQUEST', `action must be one of ${Object.keys(SERVICE_CALL_FIELD_COUNTS).join('|')}`);
|
|
394
|
+
}
|
|
395
|
+
if (typeof authorizationId !== 'string' || !UUID_RE.test(authorizationId)) throw new DaemonError('DAEMON_MALFORMED_REQUEST', 'authorizationId must be a UUID');
|
|
396
|
+
const [min, max] = SERVICE_CALL_FIELD_COUNTS[action];
|
|
397
|
+
if (!Array.isArray(fields) || fields.length < min || fields.length > max) {
|
|
398
|
+
throw new DaemonError('DAEMON_MALFORMED_REQUEST', `${action} takes ${min === max ? min : `${min}–${max}`} field(s)`);
|
|
399
|
+
}
|
|
400
|
+
if (!fields.every((f) => typeof f === 'string' && f.length <= MAX_SERVICE_FIELD_LENGTH)) {
|
|
401
|
+
throw new DaemonError('DAEMON_MALFORMED_REQUEST', `fields must be strings of at most ${MAX_SERVICE_FIELD_LENGTH} characters`);
|
|
402
|
+
}
|
|
403
|
+
if (typeof nonce !== 'string' || nonce.length < 8 || nonce.length > 128) throw new DaemonError('DAEMON_MALFORMED_REQUEST', 'nonce must be 8–128 characters');
|
|
404
|
+
if (typeof issuedAt !== 'string' || !freshnessOk(issuedAt)) throw new DaemonError('DAEMON_MALFORMED_REQUEST', 'issuedAt outside freshness window');
|
|
405
|
+
if (!this.#agentDid) throw new DaemonError('DAEMON_NOT_PROVISIONED');
|
|
406
|
+
if (serviceDid !== undefined && serviceDid !== this.#agentDid) throw new DaemonError('DAEMON_IDENTITY_MISMATCH');
|
|
407
|
+
const message = buildServiceCallMessage({ action, authorizationId, fields, nonce, issuedAt });
|
|
408
|
+
return { signature: this.#sign(Buffer.from(message, 'utf8')), serviceDid: this.#agentDid };
|
|
409
|
+
}
|
|
410
|
+
|
|
348
411
|
#handleSignEnvelope(params = {}) {
|
|
349
412
|
this.#assertRateLimit('sign-envelope');
|
|
350
413
|
this.#validateCoreFields(params);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@metamynd/agentsafe-signer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.1",
|
|
4
4
|
"description": "Local signer daemon for AgentSafe agent/service keys \u2014 the key never enters the calling guard's own process. See docs/design/agent-key-custody-local-signer-daemon-plan.md.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./daemon.mjs",
|
package/policy-core.mjs
CHANGED
|
@@ -643,16 +643,24 @@ function evaluateMandate(mandate, req) {
|
|
|
643
643
|
};
|
|
644
644
|
}
|
|
645
645
|
for (const p of perms) {
|
|
646
|
-
const
|
|
647
|
-
if (!
|
|
646
|
+
const failing2 = (p.constraint ?? []).find((c) => !constraintSatisfied(c, req, true));
|
|
647
|
+
if (!failing2) return { decision: "allow", reasonCode: "AUTHORIZED" };
|
|
648
648
|
}
|
|
649
|
-
const
|
|
649
|
+
const failing = (perms[0].constraint ?? []).filter((c) => !constraintSatisfied(c, req, true));
|
|
650
|
+
const firstFail = failing[0];
|
|
651
|
+
const reported = reportedFailure(failing) ?? firstFail;
|
|
650
652
|
return {
|
|
651
653
|
decision: firstFail?.onFail ?? "block",
|
|
652
|
-
reasonCode: reasonFor(
|
|
653
|
-
matched: { kind: "permission", target: perms[0].target, constraint:
|
|
654
|
+
reasonCode: reasonFor(reported, req),
|
|
655
|
+
matched: { kind: "permission", target: perms[0].target, constraint: reported }
|
|
654
656
|
};
|
|
655
657
|
}
|
|
658
|
+
function reportedFailure(failing) {
|
|
659
|
+
const first = failing[0];
|
|
660
|
+
if (!first || !AMOUNT_OPERANDS.has(first.leftOperand)) return void 0;
|
|
661
|
+
const decision = first.onFail ?? "block";
|
|
662
|
+
return failing.find((c) => !AMOUNT_OPERANDS.has(c.leftOperand) && (c.onFail ?? "block") === decision);
|
|
663
|
+
}
|
|
656
664
|
function remainingBudget(b) {
|
|
657
665
|
return Math.max(0, b.cap - b.spent - b.held);
|
|
658
666
|
}
|
package/service-installer.mjs
CHANGED
|
@@ -2,15 +2,16 @@
|
|
|
2
2
|
// docs/design/agent-key-custody-local-signer-daemon-plan.md's "OS service unit templates"
|
|
3
3
|
// section. Deliberately writes files ONLY to a local output directory the caller specifies —
|
|
4
4
|
// never to a real system location (~/.config/systemd/user, /etc/systemd/system,
|
|
5
|
-
// ~/Library/LaunchAgents, the Windows
|
|
6
|
-
//
|
|
5
|
+
// ~/Library/LaunchAgents, the Windows Task Scheduler) and never invokes systemctl/launchctl/
|
|
6
|
+
// schtasks itself. It prints the exact command an operator runs to actually install the result;
|
|
7
7
|
// running that command is always a separate, deliberate, human step. See README.md "Status" for
|
|
8
8
|
// why: registering a real system service is a genuine system modification, out of scope for what
|
|
9
9
|
// this module does on its own.
|
|
10
|
+
import os from 'node:os';
|
|
10
11
|
import path from 'node:path';
|
|
11
12
|
|
|
12
13
|
/** Deterministic, filesystem-safe short id for a logical identity (agentDid/serviceDid/name). */
|
|
13
|
-
function
|
|
14
|
+
export function instanceId(identity) {
|
|
14
15
|
// Not crypto-sensitive — just needs to be stable and safe in a unit filename/pipe name.
|
|
15
16
|
let h = 0;
|
|
16
17
|
for (let i = 0; i < identity.length; i++) h = (Math.imul(h, 31) + identity.charCodeAt(i)) | 0;
|
|
@@ -26,10 +27,62 @@ function defaultNodePath() {
|
|
|
26
27
|
return process.execPath || '/usr/bin/node';
|
|
27
28
|
}
|
|
28
29
|
|
|
30
|
+
/** One ExecStart= word. systemd splits the line on whitespace, so a path with a space in it (a node under
|
|
31
|
+
* `/opt/Node JS/`, or `C:\Program Files\nodejs\node.exe` when the unit is generated on Windows) became two words and
|
|
32
|
+
* the unit failed at exec. Double quotes are systemd's own quoting, with `\` and `"` escaped inside them. */
|
|
33
|
+
function systemdArg(value) {
|
|
34
|
+
return /\s/.test(value) ? `"${value.replace(/(["\\])/g, '\\$1')}"` : value;
|
|
35
|
+
}
|
|
36
|
+
|
|
29
37
|
function assertTier(tier) {
|
|
30
38
|
if (tier !== 1 && tier !== 2) throw new Error(`tier must be 1 or 2, got ${tier}`);
|
|
31
39
|
}
|
|
32
40
|
|
|
41
|
+
function assertRole(role) {
|
|
42
|
+
if (role !== 'agent' && role !== 'service') throw new Error(`role must be 'agent' or 'service', got ${role}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** `--identity` is the full DID the daemon compares every signing request's agentDid against
|
|
46
|
+
* (daemon.mjs #assertIdentity), so it goes into the generated command verbatim. A DID never
|
|
47
|
+
* contains whitespace, quotes or control characters; rejecting them here keeps every generated
|
|
48
|
+
* file free of quoting/injection edge cases (a newline would otherwise start a new unit directive). */
|
|
49
|
+
function assertIdentity(identity) {
|
|
50
|
+
if (typeof identity !== 'string' || identity.length === 0 || /[\s"'\x00-\x1f\x7f]/.test(identity)) {
|
|
51
|
+
throw new Error('identity must be a DID string with no whitespace, quotes or control characters');
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** cli.mjs ships next to daemon.mjs in this package's `files` list, so it is derived from the same
|
|
56
|
+
* directory; `cliPath` overrides it outright. */
|
|
57
|
+
function resolveCliPath(daemonPath, cliPath) {
|
|
58
|
+
return cliPath ?? daemonPath.replace(/daemon\.mjs$/, 'cli.mjs');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function xmlEscape(value) {
|
|
62
|
+
return String(value).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** One argument on a Windows command line, quoted by the rules node (MSVCRT/CommandLineToArgvW)
|
|
66
|
+
* uses to split it back: backslashes are literal unless they precede a quote. */
|
|
67
|
+
function windowsArg(value) {
|
|
68
|
+
if (value && !/[\s"]/.test(value)) return value;
|
|
69
|
+
let out = '"';
|
|
70
|
+
let backslashes = 0;
|
|
71
|
+
for (const ch of value) {
|
|
72
|
+
if (ch === '\\') {
|
|
73
|
+
backslashes++;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (ch === '"') {
|
|
77
|
+
out += '\\'.repeat(backslashes * 2 + 1) + '"';
|
|
78
|
+
} else {
|
|
79
|
+
out += '\\'.repeat(backslashes) + ch;
|
|
80
|
+
}
|
|
81
|
+
backslashes = 0;
|
|
82
|
+
}
|
|
83
|
+
return out + '\\'.repeat(backslashes * 2) + '"';
|
|
84
|
+
}
|
|
85
|
+
|
|
33
86
|
// --- Linux: systemd, socket-activated -------------------------------------------------------
|
|
34
87
|
|
|
35
88
|
/**
|
|
@@ -39,7 +92,8 @@ function assertTier(tier) {
|
|
|
39
92
|
export function generateSystemdUnits({ identity, tier, nodePath = defaultNodePath(), daemonPath, role = 'agent', cliPath }) {
|
|
40
93
|
assertTier(tier);
|
|
41
94
|
if (!identity || !daemonPath) throw new Error('generateSystemdUnits requires { identity, daemonPath }');
|
|
42
|
-
|
|
95
|
+
assertIdentity(identity);
|
|
96
|
+
const id = instanceId(identity);
|
|
43
97
|
|
|
44
98
|
const socketUnit = [
|
|
45
99
|
`# agentsafe-signer@.socket (instance: ${id})`,
|
|
@@ -68,13 +122,19 @@ export function generateSystemdUnits({ identity, tier, nodePath = defaultNodePat
|
|
|
68
122
|
// starts and instantly "succeeds" while never listening. Found by installing and starting the
|
|
69
123
|
// unit on a real systemd host. cli.mjs ships alongside daemon.mjs in this package's own `files`
|
|
70
124
|
// list, so deriving it from the same directory is stable, and `cliPath` overrides it outright.
|
|
71
|
-
|
|
72
|
-
const resolvedCliPath =
|
|
125
|
+
assertRole(role);
|
|
126
|
+
const resolvedCliPath = resolveCliPath(daemonPath, cliPath);
|
|
73
127
|
const isAbsolutePath = resolvedCliPath.startsWith('/');
|
|
74
128
|
const cliInvocationPath = tier === 1 && !isAbsolutePath ? `%h/${resolvedCliPath.replace(/^\.?\//, '')}` : resolvedCliPath;
|
|
75
129
|
// %S is systemd's own state-directory root, so this lines up with StateDirectory= below rather
|
|
76
130
|
// than hard-coding a path that has to be kept in sync with it by hand.
|
|
77
|
-
|
|
131
|
+
// --identity is the full DID, NOT %i. %i is the 8-hex-digit instance id (a filename-safe hash of
|
|
132
|
+
// the DID), and the daemon compares every signing request's agentDid against the value given
|
|
133
|
+
// here — so `--identity %i` started a daemon that answered ping but refused every sign request
|
|
134
|
+
// with DAEMON_IDENTITY_MISMATCH. `%` and `$` are systemd specifier/variable syntax in ExecStart=,
|
|
135
|
+
// doubled so a DID containing them (did:web percent-encodes ports as %3A) reaches argv literally.
|
|
136
|
+
const identityArg = identity.replace(/%/g, '%%').replace(/\$/g, '$$$$');
|
|
137
|
+
const invocation = `${systemdArg(nodePath)} ${systemdArg(cliInvocationPath)} start --state-dir %S/agentsafe/%i --role ${role} --identity ${identityArg}`;
|
|
78
138
|
// No setpriv wrapper. An earlier pass wrapped this in `setpriv --dumpable 0 --` to deny ptrace
|
|
79
139
|
// via PR_SET_DUMPABLE=0. That option does not exist: util-linux's setpriv has no --dumpable flag
|
|
80
140
|
// (checked on util-linux 2.39.3 / Ubuntu 24.04 LTS — the string is absent from the binary
|
|
@@ -146,55 +206,70 @@ export function generateSystemdUnits({ identity, tier, nodePath = defaultNodePat
|
|
|
146
206
|
return { socketUnit, serviceUnit: serviceLines.join('\n'), id, enableCommand };
|
|
147
207
|
}
|
|
148
208
|
|
|
149
|
-
// --- macOS: launchd,
|
|
209
|
+
// --- macOS: launchd LaunchAgent, started at login -------------------------------------------
|
|
150
210
|
|
|
151
211
|
/**
|
|
152
|
-
* @param {{identity:string, tier:1|2, nodePath?:string, daemonPath:string,
|
|
212
|
+
* @param {{identity:string, tier:1|2, nodePath?:string, daemonPath:string, role?:'agent'|'service',
|
|
213
|
+
* cliPath?:string, stateDir?:string, homeDir?:string}} opts
|
|
214
|
+
* @returns {{plist:string, plistFileName:string, label:string, id:string, stateDir:string, logPath:string, enableCommand:string}}
|
|
153
215
|
*/
|
|
154
|
-
export function generateLaunchdPlist({ identity, tier, nodePath = '
|
|
216
|
+
export function generateLaunchdPlist({ identity, tier, nodePath = defaultNodePath(), daemonPath, role = 'agent', cliPath, stateDir, homeDir = os.homedir() }) {
|
|
155
217
|
assertTier(tier);
|
|
156
|
-
if (!identity || !daemonPath
|
|
157
|
-
|
|
218
|
+
if (!identity || !daemonPath) throw new Error('generateLaunchdPlist requires { identity, daemonPath }');
|
|
219
|
+
assertIdentity(identity);
|
|
220
|
+
assertRole(role);
|
|
221
|
+
const id = instanceId(identity);
|
|
158
222
|
const label = `ai.metamynd.agentsafe-signer.${id}`;
|
|
159
|
-
|
|
223
|
+
// ProgramArguments runs cli.mjs start, not daemon.mjs — the defect already fixed for systemd.
|
|
224
|
+
// daemon.mjs is a library module with no argv handling: `node daemon.mjs --identity x` loads it,
|
|
225
|
+
// exits 0 and serves nothing. The old plist also passed only --identity (no `start`, no
|
|
226
|
+
// --state-dir, no --role), which cli.mjs would have rejected even if it had been the target.
|
|
227
|
+
const resolvedCliPath = resolveCliPath(daemonPath, cliPath);
|
|
228
|
+
// launchd has no %h equivalent and starts jobs with a working directory of /, so a relative
|
|
229
|
+
// path cannot resolve to anything meaningful. Refuse it rather than emit a job that cannot exec.
|
|
230
|
+
if (!resolvedCliPath.startsWith('/')) throw new Error(`launchd needs an absolute cli/daemon path, got "${resolvedCliPath}"`);
|
|
231
|
+
const resolvedStateDir = stateDir ?? path.posix.join(homeDir, 'Library', 'Application Support', 'agentsafe', id);
|
|
232
|
+
if (!resolvedStateDir.startsWith('/')) throw new Error(`launchd needs an absolute state dir, got "${resolvedStateDir}"`);
|
|
233
|
+
// ~/Library/Logs always exists on macOS, so launchd can open this before the daemon has created
|
|
234
|
+
// its state dir. Without it the daemon's stdout/stderr (including the KEK-backend line and any
|
|
235
|
+
// fatal error) would go nowhere.
|
|
236
|
+
const logPath = path.posix.join(homeDir, 'Library', 'Logs', `agentsafe-signer.${id}.log`);
|
|
160
237
|
|
|
161
238
|
// launchd's plist has no LimitCORE-equivalent key at all (unlike systemd), and `ulimit` is a
|
|
162
|
-
// shell builtin, not a real binary — so
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
239
|
+
// shell builtin, not a real binary — so this genuinely needs a shell hop.
|
|
240
|
+
// `sh -c 'ulimit -c 0; exec "$@"' sh <node> <cli> start ...` sets $0='sh' (unused, just a
|
|
241
|
+
// placeholder) and the rest as $1.. via "$@", then `exec` replaces the shell with node — a true
|
|
242
|
+
// exec chain, not a forked supervisor. Each argument is its own <string>, so no shell word
|
|
243
|
+
// splitting applies to paths with spaces.
|
|
166
244
|
//
|
|
167
245
|
// Disclosed, not fixed: macOS has no ptrace-deny equivalent reachable this way at all —
|
|
168
246
|
// PT_DENY_ATTACH must be called BY the target process itself via a syscall, which needs a
|
|
169
|
-
// native addon or FFI to reach from here
|
|
170
|
-
//
|
|
247
|
+
// native addon or FFI to reach from here. Only RLIMIT_CORE is closed on macOS by this generator.
|
|
248
|
+
//
|
|
249
|
+
// No launchd Sockets key and RunAtLoad=true. The old plist declared a launchd-owned socket in the
|
|
250
|
+
// install output directory with RunAtLoad=false, i.e. "start the daemon only when something
|
|
251
|
+
// connects to that socket". Nothing ever would: the daemon does not implement
|
|
252
|
+
// launch_activate_socket, it binds its own <state-dir>/signer.sock, and that is the path clients
|
|
253
|
+
// use. So the job never started. Same gap as the systemd .socket unit (see README.md "Status"),
|
|
254
|
+
// but here it prevented the daemon from starting at all, so it is removed rather than kept.
|
|
255
|
+
// KeepAlive stays false, matching the systemd unit (no Restart=): a daemon that fails to resolve
|
|
256
|
+
// its KEK exits non-zero and should not be relaunched in a loop.
|
|
257
|
+
const programArguments = ['/bin/sh', '-c', 'ulimit -c 0; exec "$@"', 'sh', nodePath, resolvedCliPath, 'start', '--state-dir', resolvedStateDir, '--role', role, '--identity', identity];
|
|
171
258
|
const plist = [
|
|
172
259
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
173
260
|
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
174
261
|
'<plist version="1.0">',
|
|
175
262
|
'<dict>',
|
|
176
|
-
` <key>Label</key><string>${label}</string>`,
|
|
263
|
+
` <key>Label</key><string>${xmlEscape(label)}</string>`,
|
|
177
264
|
' <key>ProgramArguments</key>',
|
|
178
265
|
' <array>',
|
|
179
|
-
|
|
180
|
-
' <string>-c</string>',
|
|
181
|
-
' <string>ulimit -c 0; exec "$@"</string>',
|
|
182
|
-
' <string>sh</string>',
|
|
183
|
-
` <string>${nodePath}</string>`,
|
|
184
|
-
` <string>${daemonPath}</string>`,
|
|
185
|
-
' <string>--identity</string>',
|
|
186
|
-
` <string>${id}</string>`,
|
|
266
|
+
...programArguments.map((a) => ` <string>${xmlEscape(a)}</string>`),
|
|
187
267
|
' </array>',
|
|
188
|
-
' <key>
|
|
189
|
-
' <dict>',
|
|
190
|
-
' <key>Listener</key>',
|
|
191
|
-
' <dict>',
|
|
192
|
-
` <key>SockPathName</key><string>${socketPath}</string>`,
|
|
193
|
-
' <key>SockPathMode</key><integer>384</integer>',
|
|
194
|
-
' </dict>',
|
|
195
|
-
' </dict>',
|
|
196
|
-
' <key>RunAtLoad</key><false/>',
|
|
268
|
+
' <key>RunAtLoad</key><true/>',
|
|
197
269
|
' <key>KeepAlive</key><false/>',
|
|
270
|
+
' <key>Umask</key><integer>63</integer>',
|
|
271
|
+
` <key>StandardOutPath</key><string>${xmlEscape(logPath)}</string>`,
|
|
272
|
+
` <key>StandardErrorPath</key><string>${xmlEscape(logPath)}</string>`,
|
|
198
273
|
' <key>ProcessType</key><string>Background</string>',
|
|
199
274
|
'</dict>',
|
|
200
275
|
'</plist>',
|
|
@@ -202,43 +277,138 @@ export function generateLaunchdPlist({ identity, tier, nodePath = '/usr/local/bi
|
|
|
202
277
|
].join('\n');
|
|
203
278
|
|
|
204
279
|
const plistFileName = `${label}.plist`;
|
|
280
|
+
const bootstrap = `launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/${plistFileName}`;
|
|
205
281
|
const enableCommand =
|
|
206
282
|
tier === 1
|
|
207
|
-
?
|
|
283
|
+
? bootstrap
|
|
208
284
|
: `# Tier 2 on macOS is a sandbox-exec Seatbelt profile layered on top of this same LaunchAgent, ` +
|
|
209
|
-
`not a separate plist mechanism — see README.md "Status" for what's not yet generated here.\n
|
|
210
|
-
`launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/${plistFileName}`;
|
|
285
|
+
`not a separate plist mechanism — see README.md "Status" for what's not yet generated here.\n${bootstrap}`;
|
|
211
286
|
|
|
212
|
-
return { plist, plistFileName, id, enableCommand };
|
|
287
|
+
return { plist, plistFileName, label, id, stateDir: resolvedStateDir, logPath, enableCommand };
|
|
213
288
|
}
|
|
214
289
|
|
|
215
|
-
// --- Windows: a
|
|
290
|
+
// --- Windows: a per-user Scheduled Task at logon ---------------------------------------------
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Windows Tier 2 is NOT supported by this generator. The previous version emitted
|
|
294
|
+
* `sc.exe create ... binPath="node.exe daemon.mjs --identity <id>"` under a Virtual Service
|
|
295
|
+
* Account, which could never work: the Service Control Manager requires the process to speak the
|
|
296
|
+
* service protocol (StartServiceCtrlDispatcher / SetServiceStatus) and node does not, so SCM kills
|
|
297
|
+
* it with error 1053 after ~30s. It also ran daemon.mjs (a library with no argv handling) without
|
|
298
|
+
* the `start` subcommand. Making it work needs a service host that speaks the SCM protocol (e.g.
|
|
299
|
+
* WinSW or NSSM) — a new third-party dependency, deliberately not added here.
|
|
300
|
+
*/
|
|
301
|
+
export const WINDOWS_TIER2_UNSUPPORTED =
|
|
302
|
+
'Tier 2 (a dedicated Windows service account) is not supported on Windows: node does not implement the ' +
|
|
303
|
+
'Service Control Manager protocol, so a plain `sc.exe create` service is killed by SCM (error 1053). ' +
|
|
304
|
+
'Use --tier 1 (a per-user Scheduled Task at logon), or wrap `node cli.mjs start ...` in an SCM-aware ' +
|
|
305
|
+
'service host such as WinSW or NSSM yourself.';
|
|
216
306
|
|
|
217
307
|
/**
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
308
|
+
* Generates a Task Scheduler XML definition that starts the signer at the user's logon, running AS
|
|
309
|
+
* that user with a limited token — the Windows analog of the Tier 1 systemd user unit. Never
|
|
310
|
+
* registers it: returns the XML and the exact schtasks argv an operator runs.
|
|
311
|
+
*
|
|
312
|
+
* Why a Scheduled Task and not a service: see WINDOWS_TIER2_UNSUPPORTED. Why XML and not
|
|
313
|
+
* `schtasks /Create /SC ONLOGON /TR ...`: /TR is capped at 261 characters (a node path + cli path +
|
|
314
|
+
* state dir + DID overflows it easily), and a /TR-created task gets the defaults
|
|
315
|
+
* ExecutionTimeLimit=PT72H (Task Scheduler kills the signer after three days) and
|
|
316
|
+
* DisallowStartIfOnBatteries/StopIfGoingOnBatteries=true (no signer on a laptop on battery).
|
|
317
|
+
* InteractiveToken, not S4U ("run whether logged on or not"): the Windows KEK backend is DPAPI in
|
|
318
|
+
* the user's scope, which needs the user's logon credentials that an S4U token does not carry.
|
|
319
|
+
*
|
|
320
|
+
* @param {{identity:string, tier?:1|2, nodePath?:string, daemonPath:string, role?:'agent'|'service',
|
|
321
|
+
* cliPath?:string, stateDir?:string, localAppData?:string, userId?:string, xmlPath?:string}} opts
|
|
222
322
|
*/
|
|
223
|
-
export function
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
323
|
+
export function generateWindowsScheduledTask({
|
|
324
|
+
identity,
|
|
325
|
+
tier = 1,
|
|
326
|
+
nodePath = defaultNodePath(),
|
|
327
|
+
daemonPath,
|
|
328
|
+
role = 'agent',
|
|
329
|
+
cliPath,
|
|
330
|
+
stateDir,
|
|
331
|
+
localAppData = process.env.LOCALAPPDATA ?? path.win32.join(os.homedir(), 'AppData', 'Local'),
|
|
332
|
+
userId = process.env.USERNAME ? (process.env.USERDOMAIN ? `${process.env.USERDOMAIN}\\${process.env.USERNAME}` : process.env.USERNAME) : undefined,
|
|
333
|
+
xmlPath,
|
|
334
|
+
}) {
|
|
335
|
+
assertTier(tier);
|
|
336
|
+
if (tier === 2) throw new Error(WINDOWS_TIER2_UNSUPPORTED);
|
|
337
|
+
if (!identity || !daemonPath) throw new Error('generateWindowsScheduledTask requires { identity, daemonPath }');
|
|
338
|
+
assertIdentity(identity);
|
|
339
|
+
assertRole(role);
|
|
340
|
+
if (!userId) throw new Error('generateWindowsScheduledTask requires { userId } (DOMAIN\\user) when USERNAME is not set');
|
|
341
|
+
const id = instanceId(identity);
|
|
342
|
+
const taskName = `AgentSafeSigner-${id}`;
|
|
343
|
+
// Task Scheduler starts actions in %windir%\system32, so relative paths cannot work.
|
|
344
|
+
const resolvedCliPath = resolveCliPath(daemonPath, cliPath);
|
|
345
|
+
if (!path.win32.isAbsolute(resolvedCliPath)) throw new Error(`a Scheduled Task needs an absolute cli/daemon path, got "${resolvedCliPath}"`);
|
|
346
|
+
const resolvedStateDir = stateDir ?? path.win32.join(localAppData, 'agentsafe', id);
|
|
347
|
+
if (!path.win32.isAbsolute(resolvedStateDir)) throw new Error(`a Scheduled Task needs an absolute state dir, got "${resolvedStateDir}"`);
|
|
229
348
|
|
|
230
|
-
|
|
231
|
-
const
|
|
349
|
+
// Runs cli.mjs `start` with the args it requires — the same defect already fixed for systemd.
|
|
350
|
+
const argv = [resolvedCliPath, 'start', '--state-dir', resolvedStateDir, '--role', role, '--identity', identity];
|
|
351
|
+
const commandField = /\s/.test(nodePath) ? `"${nodePath}"` : nodePath;
|
|
352
|
+
const argumentsField = argv.map(windowsArg).join(' ');
|
|
353
|
+
|
|
354
|
+
const xml = [
|
|
355
|
+
'<?xml version="1.0" encoding="UTF-16"?>',
|
|
356
|
+
'<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">',
|
|
357
|
+
' <RegistrationInfo>',
|
|
358
|
+
` <Description>AgentSafe local signer daemon for identity ${id}</Description>`,
|
|
359
|
+
' </RegistrationInfo>',
|
|
360
|
+
' <Triggers>',
|
|
361
|
+
' <LogonTrigger>',
|
|
362
|
+
' <Enabled>true</Enabled>',
|
|
363
|
+
` <UserId>${xmlEscape(userId)}</UserId>`,
|
|
364
|
+
' </LogonTrigger>',
|
|
365
|
+
' </Triggers>',
|
|
366
|
+
' <Principals>',
|
|
367
|
+
' <Principal id="Author">',
|
|
368
|
+
` <UserId>${xmlEscape(userId)}</UserId>`,
|
|
369
|
+
' <LogonType>InteractiveToken</LogonType>',
|
|
370
|
+
' <RunLevel>LeastPrivilege</RunLevel>',
|
|
371
|
+
' </Principal>',
|
|
372
|
+
' </Principals>',
|
|
373
|
+
' <Settings>',
|
|
374
|
+
' <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>',
|
|
375
|
+
' <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>',
|
|
376
|
+
' <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>',
|
|
377
|
+
' <AllowHardTerminate>true</AllowHardTerminate>',
|
|
378
|
+
' <StartWhenAvailable>false</StartWhenAvailable>',
|
|
379
|
+
' <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>',
|
|
380
|
+
' <IdleSettings>',
|
|
381
|
+
' <StopOnIdleEnd>false</StopOnIdleEnd>',
|
|
382
|
+
' <RestartOnIdle>false</RestartOnIdle>',
|
|
383
|
+
' </IdleSettings>',
|
|
384
|
+
' <AllowStartOnDemand>true</AllowStartOnDemand>',
|
|
385
|
+
' <Enabled>true</Enabled>',
|
|
386
|
+
' <Hidden>false</Hidden>',
|
|
387
|
+
' <RunOnlyIfIdle>false</RunOnlyIfIdle>',
|
|
388
|
+
' <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>',
|
|
389
|
+
' <Priority>7</Priority>',
|
|
390
|
+
' </Settings>',
|
|
391
|
+
' <Actions Context="Author">',
|
|
392
|
+
' <Exec>',
|
|
393
|
+
` <Command>${xmlEscape(commandField)}</Command>`,
|
|
394
|
+
` <Arguments>${xmlEscape(argumentsField)}</Arguments>`,
|
|
395
|
+
' </Exec>',
|
|
396
|
+
' </Actions>',
|
|
397
|
+
'</Task>',
|
|
398
|
+
'',
|
|
399
|
+
].join('\r\n');
|
|
400
|
+
|
|
401
|
+
const xmlFile = xmlPath ?? `${taskName}.xml`;
|
|
402
|
+
const createArgv = ['schtasks.exe', '/Create', '/TN', taskName, '/XML', xmlFile];
|
|
403
|
+
const runArgv = ['schtasks.exe', '/Run', '/TN', taskName];
|
|
232
404
|
|
|
233
405
|
return {
|
|
234
|
-
|
|
235
|
-
|
|
406
|
+
taskName,
|
|
407
|
+
id,
|
|
408
|
+
xml,
|
|
409
|
+
stateDir: resolvedStateDir,
|
|
236
410
|
createArgv,
|
|
237
|
-
|
|
238
|
-
enableCommand: `${createArgv.map(
|
|
411
|
+
runArgv,
|
|
412
|
+
enableCommand: `${createArgv.map(windowsArg).join(' ')}\n${runArgv.map(windowsArg).join(' ')}`,
|
|
239
413
|
};
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
function quoteIfNeeded(arg) {
|
|
243
|
-
return /\s/.test(arg) && !arg.includes('"') ? `"${arg}"` : arg;
|
|
244
|
-
}
|
|
414
|
+
}
|