@metamynd/agentsafe-signer 0.15.3 → 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 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 nine-operation signing-socket protocol (`sign-authorize`, `sign-envelope`, `sign-payload`,
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 and Linux `ptrace`-denial**, closed declaratively rather than in the daemon's
134
- own runtime code: the generated systemd units now prefix `ExecStart=` with
135
- `setpriv --dumpable 0 --` (a real binary, no shell needed — ptrace-deny, pairs with the
136
- already-present `LimitCORE=0`), and the generated launchd plist routes through `/bin/sh -c
137
- 'ulimit -c 0; exec "$@"'` (launchd has no `LimitCORE`-equivalent key, and `ulimit` is a shell
138
- builtin) — both a true `exec()` chain, never a forked supervisor process. **Disclosed, not
139
- closed**: macOS has no `ptrace`-deny reachable this way at all — `PT_DENY_ATTACH` must be called
140
- *by* the target process itself via a syscall, the same architectural limitation `secure-memory.mjs`
141
- discloses for `mlock()`.
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
- - **The generated systemd unit has never started a daemon.** Running it for real on Ubuntu 24.04
160
- LTS / systemd 255 (the first time this was tried on any Linux host) found five defects in a
161
- single `ExecStart` line, four of them fatal before any daemon code ran. Three are fixed here;
162
- two remain and are listed under "OS service unit generation" below. Until those two are closed,
163
- `cli.mjs install`'s output is a template to adapt, not something that runs as generated.
164
- - `daemon-hardening.smoke.mjs` **currently fails** — on the two real defects above, not on a test
165
- bug. That is deliberate: it is the assertion that would have caught them, and greening it by
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 unit content from the design
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 service
270
- registry) and never runs `systemctl`/`launchctl`/`sc.exe` itself** — verified by
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
- Tested: the Tier 1/Tier 2 content differences match the design doc exactly (the `%h`-vs-absolute
282
- `ExecStart` path and the `RemoveIPC=true` placement — both real bugs the code-review pass on the
283
- design doc itself caught before any of this was written) for systemd; the launchd plist's
284
- `SockPathMode`/`RunAtLoad`/`KeepAlive` values; and the Windows `sc.exe` argv shape (Virtual Service
285
- Account, no password to manage).
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 %i` |
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. Also still untested: launchd and Windows
335
- installation, unchanged from the previous pass.
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 { generateSystemdUnits, generateLaunchdPlist, generateWindowsService } from './service-installer.mjs';
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
- const daemonPath = args['daemon-path'] ?? path.join(process.cwd(), 'daemon.mjs');
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({ identity, tier, daemonPath, socketDir: outDir });
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
- const { serviceName, enableCommand } = generateWindowsService({ identity, daemonPath });
63
- fs.writeFileSync(path.join(outDir, `${serviceName}.install.txt`), enableCommand + '\n');
64
- console.log(`Wrote the sc.exe install command to ${path.join(outDir, `${serviceName}.install.txt`)}`);
65
- console.log(`Run it yourself, from an elevated prompt:\n ${enableCommand}`);
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
- service: new Set(['sign-handshake-nonce', 'sign-log-checkpoint', 'get-identity', 'ping']),
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.15.3",
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",
@@ -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 service registry) and never invokes systemctl/launchctl/
6
- // sc.exe itself. It prints the exact command an operator runs to actually install the result;
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 shortId(identity) {
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;
@@ -37,6 +38,51 @@ function assertTier(tier) {
37
38
  if (tier !== 1 && tier !== 2) throw new Error(`tier must be 1 or 2, got ${tier}`);
38
39
  }
39
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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
+
40
86
  // --- Linux: systemd, socket-activated -------------------------------------------------------
41
87
 
42
88
  /**
@@ -46,7 +92,8 @@ function assertTier(tier) {
46
92
  export function generateSystemdUnits({ identity, tier, nodePath = defaultNodePath(), daemonPath, role = 'agent', cliPath }) {
47
93
  assertTier(tier);
48
94
  if (!identity || !daemonPath) throw new Error('generateSystemdUnits requires { identity, daemonPath }');
49
- const id = shortId(identity);
95
+ assertIdentity(identity);
96
+ const id = instanceId(identity);
50
97
 
51
98
  const socketUnit = [
52
99
  `# agentsafe-signer@.socket (instance: ${id})`,
@@ -75,13 +122,19 @@ export function generateSystemdUnits({ identity, tier, nodePath = defaultNodePat
75
122
  // starts and instantly "succeeds" while never listening. Found by installing and starting the
76
123
  // unit on a real systemd host. cli.mjs ships alongside daemon.mjs in this package's own `files`
77
124
  // list, so deriving it from the same directory is stable, and `cliPath` overrides it outright.
78
- if (role !== 'agent' && role !== 'service') throw new Error(`role must be 'agent' or 'service', got ${role}`);
79
- const resolvedCliPath = cliPath ?? daemonPath.replace(/daemon\.mjs$/, 'cli.mjs');
125
+ assertRole(role);
126
+ const resolvedCliPath = resolveCliPath(daemonPath, cliPath);
80
127
  const isAbsolutePath = resolvedCliPath.startsWith('/');
81
128
  const cliInvocationPath = tier === 1 && !isAbsolutePath ? `%h/${resolvedCliPath.replace(/^\.?\//, '')}` : resolvedCliPath;
82
129
  // %S is systemd's own state-directory root, so this lines up with StateDirectory= below rather
83
130
  // than hard-coding a path that has to be kept in sync with it by hand.
84
- const invocation = `${systemdArg(nodePath)} ${systemdArg(cliInvocationPath)} start --state-dir %S/agentsafe/%i --role ${role} --identity %i`;
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}`;
85
138
  // No setpriv wrapper. An earlier pass wrapped this in `setpriv --dumpable 0 --` to deny ptrace
86
139
  // via PR_SET_DUMPABLE=0. That option does not exist: util-linux's setpriv has no --dumpable flag
87
140
  // (checked on util-linux 2.39.3 / Ubuntu 24.04 LTS — the string is absent from the binary
@@ -153,55 +206,70 @@ export function generateSystemdUnits({ identity, tier, nodePath = defaultNodePat
153
206
  return { socketUnit, serviceUnit: serviceLines.join('\n'), id, enableCommand };
154
207
  }
155
208
 
156
- // --- macOS: launchd, socket-activated --------------------------------------------------------
209
+ // --- macOS: launchd LaunchAgent, started at login -------------------------------------------
157
210
 
158
211
  /**
159
- * @param {{identity:string, tier:1|2, nodePath?:string, daemonPath:string, socketDir:string}} opts
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}}
160
215
  */
161
- export function generateLaunchdPlist({ identity, tier, nodePath = '/usr/local/bin/node', daemonPath, socketDir }) {
216
+ export function generateLaunchdPlist({ identity, tier, nodePath = defaultNodePath(), daemonPath, role = 'agent', cliPath, stateDir, homeDir = os.homedir() }) {
162
217
  assertTier(tier);
163
- if (!identity || !daemonPath || !socketDir) throw new Error('generateLaunchdPlist requires { identity, daemonPath, socketDir }');
164
- const id = shortId(identity);
218
+ if (!identity || !daemonPath) throw new Error('generateLaunchdPlist requires { identity, daemonPath }');
219
+ assertIdentity(identity);
220
+ assertRole(role);
221
+ const id = instanceId(identity);
165
222
  const label = `ai.metamynd.agentsafe-signer.${id}`;
166
- const socketPath = path.posix.join(socketDir, `signer-${id}.sock`);
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`);
167
237
 
168
238
  // launchd's plist has no LimitCORE-equivalent key at all (unlike systemd), and `ulimit` is a
169
- // shell builtin, not a real binary — so, unlike setpriv above, this genuinely needs a shell
170
- // hop. `sh -c 'ulimit -c 0; exec "$@"' sh <node> <daemon> --identity <id>` sets $0='sh' (unused,
171
- // just a placeholder) and the rest as $1.. via "$@", then `exec` replaces the shell with node —
172
- // a true exec chain, not a forked supervisor, same as setpriv's own execve() above.
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.
173
244
  //
174
245
  // Disclosed, not fixed: macOS has no ptrace-deny equivalent reachable this way at all —
175
246
  // PT_DENY_ATTACH must be called BY the target process itself via a syscall, which needs a
176
- // native addon or FFI to reach from here, the same architectural limitation secure-memory.mjs's
177
- // own header discloses for mlock(). Only RLIMIT_CORE is closed on macOS by this generator.
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];
178
258
  const plist = [
179
259
  '<?xml version="1.0" encoding="UTF-8"?>',
180
260
  '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
181
261
  '<plist version="1.0">',
182
262
  '<dict>',
183
- ` <key>Label</key><string>${label}</string>`,
263
+ ` <key>Label</key><string>${xmlEscape(label)}</string>`,
184
264
  ' <key>ProgramArguments</key>',
185
265
  ' <array>',
186
- ' <string>/bin/sh</string>',
187
- ' <string>-c</string>',
188
- ' <string>ulimit -c 0; exec "$@"</string>',
189
- ' <string>sh</string>',
190
- ` <string>${nodePath}</string>`,
191
- ` <string>${daemonPath}</string>`,
192
- ' <string>--identity</string>',
193
- ` <string>${id}</string>`,
266
+ ...programArguments.map((a) => ` <string>${xmlEscape(a)}</string>`),
194
267
  ' </array>',
195
- ' <key>Sockets</key>',
196
- ' <dict>',
197
- ' <key>Listener</key>',
198
- ' <dict>',
199
- ` <key>SockPathName</key><string>${socketPath}</string>`,
200
- ' <key>SockPathMode</key><integer>384</integer>',
201
- ' </dict>',
202
- ' </dict>',
203
- ' <key>RunAtLoad</key><false/>',
268
+ ' <key>RunAtLoad</key><true/>',
204
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>`,
205
273
  ' <key>ProcessType</key><string>Background</string>',
206
274
  '</dict>',
207
275
  '</plist>',
@@ -209,43 +277,138 @@ export function generateLaunchdPlist({ identity, tier, nodePath = '/usr/local/bi
209
277
  ].join('\n');
210
278
 
211
279
  const plistFileName = `${label}.plist`;
280
+ const bootstrap = `launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/${plistFileName}`;
212
281
  const enableCommand =
213
282
  tier === 1
214
- ? `launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/${plistFileName}`
283
+ ? bootstrap
215
284
  : `# Tier 2 on macOS is a sandbox-exec Seatbelt profile layered on top of this same LaunchAgent, ` +
216
- `not a separate plist mechanism — see README.md "Status" for what's not yet generated here.\n` +
217
- `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}`;
218
286
 
219
- return { plist, plistFileName, id, enableCommand };
287
+ return { plist, plistFileName, label, id, stateDir: resolvedStateDir, logPath, enableCommand };
220
288
  }
221
289
 
222
- // --- Windows: a service under a Virtual Service Account, named pipe transport ---------------
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.';
223
306
 
224
307
  /**
225
- * Returns the exact sc.exe argv this would run — never runs it. Tier 1 has no natural analog on
226
- * Windows (services are inherently system-level there), so this always targets Tier 2's Virtual
227
- * Service Account, matching the design doc's own "Tier 2 by default here, not Tier 1" call.
228
- * @param {{identity:string, nodePath?:string, daemonPath:string}} opts
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
229
322
  */
230
- export function generateWindowsService({ identity, nodePath = 'node.exe', daemonPath }) {
231
- if (!identity || !daemonPath) throw new Error('generateWindowsService requires { identity, daemonPath }');
232
- const id = shortId(identity);
233
- const serviceName = `AgentSafeSigner-${id}`;
234
- const virtualAccount = `NT SERVICE\\${serviceName}`;
235
- const binPath = `"${nodePath}" "${daemonPath}" --identity ${id}`;
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}"`);
236
348
 
237
- const createArgv = ['sc.exe', 'create', serviceName, `binPath=${binPath}`, 'start=auto', `obj=${virtualAccount}`, 'password='];
238
- const startArgv = ['sc.exe', 'start', serviceName];
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];
239
404
 
240
405
  return {
241
- serviceName,
242
- virtualAccount,
406
+ taskName,
407
+ id,
408
+ xml,
409
+ stateDir: resolvedStateDir,
243
410
  createArgv,
244
- startArgv,
245
- enableCommand: `${createArgv.map(quoteIfNeeded).join(' ')}\n${startArgv.join(' ')}`,
411
+ runArgv,
412
+ enableCommand: `${createArgv.map(windowsArg).join(' ')}\n${runArgv.map(windowsArg).join(' ')}`,
246
413
  };
247
- }
248
-
249
- function quoteIfNeeded(arg) {
250
- return /\s/.test(arg) && !arg.includes('"') ? `"${arg}"` : arg;
251
- }
414
+ }