@starci/hfs 4.0.9 → 4.0.10

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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/lint/run.mjs +80 -7
  3. package/package.json +3 -2
  4. package/runtime/engine/admission.mjs +308 -0
  5. package/runtime/engine/canonical-json.mjs +10 -0
  6. package/runtime/engine/config.mjs +36 -44
  7. package/runtime/engine/db/blob.mjs +315 -0
  8. package/runtime/engine/db/ledger-paths.mjs +83 -0
  9. package/runtime/engine/db/ledger.mjs +1118 -0
  10. package/runtime/engine/db/machine-connection.mjs +135 -0
  11. package/runtime/engine/db/machine-schema.mjs +84 -0
  12. package/runtime/engine/db/machine.mjs +1367 -0
  13. package/runtime/engine/db/migrations/machine/0001-init.sql +924 -0
  14. package/runtime/engine/db/migrations/runtime/0001-init.sql +1108 -0
  15. package/runtime/engine/db/provider-reservations.mjs +101 -0
  16. package/runtime/engine/digest.mjs +16 -0
  17. package/runtime/engine/refuse.mjs +11 -0
  18. package/runtime/engine/secrets.mjs +130 -0
  19. package/runtime/knowledge/hfs/canon-pins.yaml +10 -10
  20. package/runtime/knowledge/hfs/rules.yaml +8 -8
  21. package/runtime/modules/kernel/failure-codes.yaml +40 -0
  22. package/runtime/modules/models/registry.yaml +1 -39
  23. package/runtime/modules/models/runtimes.yaml +0 -6
  24. package/runtime/modules/ops/_labels.yaml +53 -0
  25. package/runtime/scripts/api/fs/lib.mjs +6 -0
  26. package/runtime/scripts/api/git/lib.mjs +2 -0
  27. package/runtime/scripts/api/node/lib.mjs +14 -0
  28. package/runtime/scripts/api/node/spawn-node.mjs +6 -0
  29. package/runtime/scripts/api/process/lib.mjs +111 -0
  30. package/runtime/scripts/api/process/owned-process.mjs +78 -0
  31. package/runtime/scripts/api/process/resolve-real-tool.mjs +46 -0
  32. package/runtime/scripts/api/process/run-program.mjs +7 -0
  33. package/runtime/scripts/api/process/stop-owned-process.mjs +9 -0
  34. package/runtime/scripts/api/sops/decrypt.mjs +9 -16
  35. package/runtime/scripts/api/sops/lib.mjs +230 -10
  36. package/runtime/scripts/api/sops/seal.mjs +8 -4
  37. package/runtime/scripts/connectors/lib.mjs +488 -0
  38. package/runtime/scripts/hfs/architecture/fe-slot-allows.mjs +1 -1
  39. package/runtime/scripts/hfs/secret.mjs +45 -11
  40. package/runtime/scripts/lib/clip.mjs +19 -0
  41. package/runtime/scripts/lib/display-names.mjs +259 -0
  42. package/runtime/scripts/lib/example-refs.mjs +158 -0
  43. package/runtime/scripts/lib/fs-kind.mjs +14 -4
  44. package/runtime/scripts/lib/json-schema.mjs +52 -0
  45. package/runtime/scripts/lib/mutation-fence.mjs +15 -0
  46. package/runtime/scripts/lib/path-key.mjs +4 -4
  47. package/runtime/scripts/lib/process-identity.mjs +6 -0
  48. package/runtime/scripts/lib/read-yaml.mjs +18 -0
  49. package/runtime/scripts/lib/redact.mjs +161 -0
  50. package/runtime/scripts/lib/sleep.mjs +7 -0
  51. package/runtime/scripts/lib/sops-envelope.mjs +54 -0
  52. package/runtime/scripts/lib/source-phrases.mjs +34 -0
  53. package/runtime/scripts/lib/sqlite.mjs +21 -0
@@ -2,17 +2,21 @@
2
2
  // (scripts/hfs/secret.mjs). The plaintext travels on the child's stdin and never on its argument list, so it is not visible in a
3
3
  // process listing; the ciphertext comes back on stdout.
4
4
  import { spawnSync } from 'node:child_process';
5
- import { resolveSops } from './lib.mjs';
5
+ import { sopsIdentityEnv } from '../../../engine/secrets.mjs';
6
+ import { resolveSops, runSelectedSops } from './lib.mjs';
6
7
 
7
8
  /**
8
9
  * Run `<bin> encrypt` (bin null: the sops resolveSops finds) over `plaintext` of format `inputType` (json, yaml or dotenv). Sops reads stdin only with a `--filename-override`, the path the sealed
9
10
  * file will have: recipients are age keys (`--age`), and when none are given sops picks them from the repository's .sops.yaml for that path. {status, stdout, stderr, error}.
10
11
  */
11
- export function seal(bin, { inputType, plaintext, recipients = [], filenameOverride }, { env = process.env, cwd = undefined, maxBuffer = 16 * 1024 * 1024 } = {}) {
12
+ export function seal(bin, { inputType, plaintext, recipients = [], filenameOverride }, { invocation = null, env = process.env, cwd = undefined, maxBuffer = 16 * 1024 * 1024, timeout = undefined } = {}) {
12
13
  const args = ['encrypt', '--input-type', inputType, '--output-type', inputType, '--filename-override', filenameOverride];
13
14
  if (recipients.length) args.push('--age', recipients.join(','));
14
- const exe = bin ?? resolveSops(env);
15
+ const selected = sopsIdentityEnv(env);
16
+ if (selected.error?.identityRefusal === 'inline-context-unqualified') return runSelectedSops(bin, { operation: 'seal', params: { inputType, plaintext, recipients, filenameOverride } }, { selection: selected, invocation, env, cwd, maxBuffer, timeout });
17
+ if (selected.error) return { status: null, stdout: '', stderr: '', error: selected.error };
18
+ const exe = bin ?? resolveSops(selected.env);
15
19
  if (!exe) return { status: null, stdout: '', stderr: '', error: Object.assign(new Error('sops is not installed (Windows: winget install Mozilla.SOPS)'), { code: 'SOPS_MISSING' }) };
16
- const r = spawnSync(exe, args, { cwd, env, input: plaintext, encoding: 'utf8', windowsHide: true, maxBuffer });
20
+ const r = spawnSync(exe, args, { cwd, env: selected.env, input: plaintext, encoding: 'utf8', windowsHide: true, maxBuffer });
17
21
  return { status: r.status, stdout: r.stdout, stderr: r.stderr, error: r.error ?? null };
18
22
  }
@@ -0,0 +1,488 @@
1
+ // scripts/connectors/lib.mjs — what the ask gateway, the tunnel manager and the
2
+ // Telegram notifier share: the host locks every singleton manager claims, the
3
+ // connectors rows (machine.sqlite `connectors`: gateway, tunnel,
4
+ // telegram-bridge, telegram-route, supervisor-channel:<id>), the repositories
5
+ // whose asks go public, the read-only projection of open ask forms, and the
6
+ // small process helpers. docs/connectors.md is the design note.
7
+ //
8
+ // Every ledger read here goes through inspectLedger (read-only): the
9
+ // connectors observe asks, they never write a ledger. Every piece of host state
10
+ // lives in machine.sqlite (engine/db/machine.mjs): a manager lock is a
11
+ // host_locks row, a launch still starting is that row in state 'starting'.
12
+ import fs from 'node:fs';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+ import { spawnNode } from '../api/node/spawn-node.mjs';
16
+ import { inspectLedger, ledgerFileFor, isRuntimeRoot, hasLedger } from '../../engine/db/ledger.mjs';
17
+ import { machineLog, pidAlive, readMachine, withMachine } from '../../engine/db/machine.mjs';
18
+ import { skillRoot, starciSourceRoot } from '../../engine/runtime-root.mjs';
19
+ import { loadConfig } from '../../engine/config.mjs';
20
+ import { parseJson, readJsonFile } from '../lib/json.mjs';
21
+ import { jobDisplayNameOf, workflowNameOf } from '../lib/display-names.mjs';
22
+ import { sleep } from '../lib/sleep.mjs';
23
+ import { OWNED_PROCESS_SCHEMA } from '../lib/process-identity.mjs';
24
+ import { stopOwnedProcess } from '../api/process/stop-owned-process.mjs';
25
+ import { canonicalJSON } from '../../engine/canonical-json.mjs';
26
+
27
+
28
+ /** When this host last booted (ms). */
29
+ const hostBootAt = () => Date.now() - os.uptime() * 1000;
30
+ /**
31
+ * Whether the process a state record names ({pid, startedAt}) is still that process: its pid is
32
+ * live AND it started in this boot. After a reboot the recorded pid may name an unrelated process,
33
+ * and a connector that trusted it would never start again. `startedAt` is an ISO time or epoch ms.
34
+ */
35
+ export const recordAlive = (record) => {
36
+ if (!record?.pid || !pidAlive(record.pid)) return false;
37
+ const started = typeof record.startedAt === 'number' ? record.startedAt : Date.parse(record.startedAt ?? '');
38
+ return !Number.isFinite(started) || started >= hostBootAt() - 60_000;
39
+ };
40
+
41
+ /* ------------------------------------------------------------ host locks (machine.sqlite host_locks) */
42
+
43
+ /**
44
+ * How long a held manager lock stays unexpired without a renewal. The holder renews it every LOCK_RENEW_MS from an
45
+ * unref'd timer, so an expired lock (v_leaks) is one whose holder stopped renewing. Who holds a lock is decided by its
46
+ * holder pid (recordAlive: live and of this boot), never by the expiry: a busy holder that missed a renewal keeps it.
47
+ */
48
+ const LOCK_TTL_MS = 10 * 60_000;
49
+ const LOCK_RENEW_MS = 3 * 60_000;
50
+ const holderLabel = () => (process.argv[1] ? path.basename(process.argv[1]) : 'node');
51
+ /** A host_locks row as the record callers read: {pid, startedAt (ISO), at, handedOverFrom, state, holder}. */
52
+ const lockRecord = (row) => (row ? { pid: row.holder_pid, startedAt: new Date(row.started_at).toISOString(), at: row.started_at,
53
+ handedOverFrom: row.handed_over_from ?? null, state: row.state, holder: row.holder ?? null } : null);
54
+ const liveRow = (row) => Boolean(row) && row.state !== 'released' && recordAlive({ pid: row.holder_pid, startedAt: row.started_at });
55
+
56
+ // The renewal timers of the locks this process holds, by name.
57
+ const renewals = new Map();
58
+ const stopRenewal = (name) => { clearInterval(renewals.get(name)); renewals.delete(name); };
59
+ function startRenewal(name, env) {
60
+ stopRenewal(name);
61
+ const timer = setInterval(() => {
62
+ let still = true;
63
+ try { still = withMachine((m) => m.renewHostLock({ name, ttlMs: LOCK_TTL_MS }), { env }); } catch { /* a busy store: the next tick tries again */ }
64
+ if (!still) stopRenewal(name);
65
+ }, LOCK_RENEW_MS);
66
+ timer.unref?.();
67
+ renewals.set(name, timer);
68
+ }
69
+ /** The handle a successful claim returns: release() frees the lock only while it still names this process. */
70
+ function heldBy(name, env, extra = {}) {
71
+ startRenewal(name, env);
72
+ const release = () => {
73
+ stopRenewal(name);
74
+ try { withMachine((m) => m.releaseHostLock({ name }), { env }); } catch { /* the store is gone: nothing left to free */ }
75
+ };
76
+ return { ok: true, release, name, ...extra };
77
+ }
78
+
79
+ /**
80
+ * Take `name` for this process inside one transaction on `m`: refused while another live process of this boot holds
81
+ * it (state 'held'); a dead holder, one from an earlier boot, or a launch still 'starting' is replaced. Returns
82
+ * {ok:true} or {ok:false, holder}.
83
+ */
84
+ function takeLock(m, name) {
85
+ return m.transaction(() => {
86
+ const custody = recordOf(m.connectorOf(name));
87
+ if (connectorManagerBlocked(custody)) return { ok: false, holder: custody, reason: 'connector-child-custody-unreconciled' };
88
+ const cur = m.hostLock(name);
89
+ if (cur && cur.state === 'held' && cur.holder_pid !== process.pid && liveRow(cur)) return { ok: false, holder: lockRecord(cur) };
90
+ if (cur && cur.state !== 'released' && cur.holder_pid !== process.pid) m.releaseHostLock({ name, force: true });
91
+ const got = m.acquireHostLock({ name, holder: holderLabel(), ttlMs: LOCK_TTL_MS, state: 'held' });
92
+ return got.ok ? { ok: true } : { ok: false, holder: lockRecord(got.holder) };
93
+ });
94
+ }
95
+
96
+ /**
97
+ * Claim the single-manager host lock `name` for this process. `current` is the manager's own record (its connectors
98
+ * row: gateway, tunnel): a live one that is not this process owns the state even without the lock. A lock whose
99
+ * holder is dead or from an earlier boot is stale and taken over. Returns {ok:true, release} or {ok:false, holder}.
100
+ */
101
+ export function claimManager(name, { current = null, env = process.env } = {}) {
102
+ if (connectorManagerBlocked(current)) return { ok: false, holder: current, reason: 'connector-child-custody-unreconciled' };
103
+ if (current && current.pid !== process.pid && recordAlive(current)) return { ok: false, holder: current };
104
+ const got = withMachine((m) => takeLock(m, name), { env });
105
+ return got.ok ? heldBy(name, env) : { ok: false, holder: got.holder ?? null, ...(got.reason ? { reason: got.reason } : {}) };
106
+ }
107
+
108
+ /**
109
+ * Claim a manager lock that may be handed over (scripts/machine/self-reload.mjs): a loop that re-execs itself
110
+ * spawns its replacement with `from` = its own pid and waits, still holding the lock, until the lock names
111
+ * the replacement. The replacement takes the row over only while it still names `from`, in one transaction,
112
+ * so there is no moment the lock is free for a third claimant. Without `from`, or when the lock no longer
113
+ * names it, this is claimManager. Returns {ok:true, release, takenOver?} or {ok:false, holder}.
114
+ */
115
+ export function claimOrTakeOver(name, { from = null, env = process.env } = {}) {
116
+ const fromPid = Number(from);
117
+ if (Number.isInteger(fromPid) && fromPid > 0) {
118
+ const took = withMachine((m) => m.transaction(() => {
119
+ if (connectorManagerBlocked(recordOf(m.connectorOf(name)))) return false;
120
+ const cur = m.hostLock(name);
121
+ if (!cur || cur.state === 'released' || cur.holder_pid !== fromPid) return false;
122
+ const at = m.now();
123
+ return m.update('host_locks', { holder_pid: process.pid, holder: holderLabel(), started_at: at, heartbeat_at: at, expires_at: at + LOCK_TTL_MS,
124
+ handed_over_from: fromPid, state: 'held' }, { name, holder_pid: fromPid }).changes > 0;
125
+ }), { env });
126
+ if (took) return heldBy(name, env, { takenOver: true });
127
+ }
128
+ return claimManager(name, { env });
129
+ }
130
+
131
+ /** Make the lock name this process again (a handover that failed after the replacement took it). */
132
+ export const reassertManager = (name, { env = process.env } = {}) => {
133
+ const ok = withMachine((m) => m.transaction(() => {
134
+ const cur = m.hostLock(name);
135
+ if (cur && cur.state !== 'released' && cur.holder_pid !== process.pid) m.releaseHostLock({ name, force: true });
136
+ return m.acquireHostLock({ name, holder: holderLabel(), ttlMs: LOCK_TTL_MS, state: 'held' }).ok;
137
+ }), { env });
138
+ if (ok) startRenewal(name, env);
139
+ return ok;
140
+ };
141
+
142
+ /** The live holder of a manager lock ({pid, startedAt, handedOverFrom, ...}), or null. A launch still starting is not one. */
143
+ export const lockHolder = (name, env = process.env) => {
144
+ const row = readMachine((m) => m.hostLock(name), null, { env });
145
+ return row?.state === 'held' && liveRow(row) ? lockRecord(row) : null;
146
+ };
147
+
148
+ /**
149
+ * A manager a starter just launched but that has not claimed its lock yet (node takes a while to
150
+ * start on a loaded host). `start` marks the lock row 'starting' with the launched pid right after the
151
+ * spawn, and every liveness test counts it for STARTING_MS while that pid lives, so two starters in
152
+ * that window do not both launch a manager. The launched manager's claim turns the row 'held'.
153
+ */
154
+ const STARTING_MS = 30_000;
155
+ export const markStarting = (name, pid, env = process.env) => {
156
+ if (!Number.isInteger(pid) || pid <= 0) return false;
157
+ return withMachine((m) => m.transaction(() => {
158
+ const cur = m.hostLock(name);
159
+ // A live holder (the launched manager already claimed, or another one) is never overwritten.
160
+ if (cur && cur.state === 'held' && liveRow(cur)) return false;
161
+ if (cur && cur.state !== 'released' && cur.holder_pid !== pid) m.releaseHostLock({ name, force: true });
162
+ return m.acquireHostLock({ name, holder: 'starting', pid, ttlMs: STARTING_MS, state: 'starting' }).ok;
163
+ }), { env });
164
+ };
165
+ export const startingHolder = (name, env = process.env, { windowMs = STARTING_MS, now = Date.now() } = {}) => {
166
+ const row = readMachine((m) => m.hostLock(name), null, { env });
167
+ return row?.state === 'starting' && now - row.started_at < windowMs && pidAlive(row.holder_pid)
168
+ ? { pid: row.holder_pid, at: row.started_at, startedAt: new Date(row.started_at).toISOString() } : null;
169
+ };
170
+
171
+ /**
172
+ * Run async `fn` while holding the host lock `name` (a short cross-process mutex: the Telegram notice store, the
173
+ * media dedupe). Waits up to `waitMs`, then resolves {ok:false, skipped} without running `fn`. Calls from one
174
+ * process are chained, since a host lock is per pid.
175
+ */
176
+ const chains = new Map();
177
+ export function withHostMutex(name, fn, { env = process.env, waitMs = 10_000, stepMs = 100 } = {}) {
178
+ const prior = chains.get(name) ?? Promise.resolve();
179
+ const run = prior.catch(() => {}).then(async () => {
180
+ const end = Date.now() + waitMs;
181
+ for (;;) {
182
+ let got;
183
+ try { got = withMachine((m) => takeLock(m, name), { env }); } catch { got = { ok: false }; }
184
+ if (got.ok) break;
185
+ if (Date.now() > end) return { ok: false, skipped: `${name} is locked` };
186
+ await sleep(stepMs);
187
+ }
188
+ try { return await fn(); } finally { try { withMachine((m) => m.releaseHostLock({ name }), { env }); } catch { /* gone */ } }
189
+ });
190
+ chains.set(name, run);
191
+ run.catch(() => {}).finally(() => { if (chains.get(name) === run) chains.delete(name); });
192
+ return run;
193
+ }
194
+
195
+ export const CONNECTOR_LAUNCH_ENV = 'STARCI_CONNECTOR_LAUNCH';
196
+
197
+ /* ------------------------------------------------------------ connectors rows (machine.sqlite connectors) */
198
+
199
+ const recordOf = (row) => (row ? { ...(row.config ?? {}), pid: row.pid ?? row.config?.pid ?? null, port: row.port ?? row.config?.port ?? null,
200
+ publicUrl: row.public_url ?? null, state: row.state ?? null, cursor: row.cursor ?? null, updatedAt: row.updated_at } : null);
201
+ /**
202
+ * One connector's record, or null: its config_json spread, with the row's pid, port, publicUrl, state, cursor and
203
+ * updatedAt. `name`: gateway | tunnel | telegram-bridge | telegram-route | supervisor-channel:<id>.
204
+ */
205
+ export const connectorState = (name, env = process.env) => readMachine((m) => recordOf(m.connectorOf(name)), null, { env });
206
+ /** Every connector row whose name starts with `prefix`, as connectorState records with their `name`. */
207
+ export const connectorStates = (prefix, env = process.env) => readMachine((m) => m.db.prepare('SELECT name FROM connectors WHERE substr(name,1,length(?))=? ORDER BY name')
208
+ .all(prefix, prefix).map((r) => ({ name: r.name, ...recordOf(m.connectorOf(r.name)) })), [], { env });
209
+ /**
210
+ * Write one connector row: only the fields given change (`config` and `cursor` replace their column whole). Returns
211
+ * true. No secret ever goes here (the token lives in .starcistacks / the secrets file).
212
+ */
213
+ export const writeConnectorState = (name, { kind, state, pid, port, publicUrl, config, cursor } = {}, env = process.env) => withMachine((m) => m.transaction(() => {
214
+ const current = recordOf(m.connectorOf(name));
215
+ if (connectorChildUnresolved(current) && config !== undefined
216
+ && (config.childLaunchNonce !== current.childLaunchNonce || canonicalJSON(config.childIdentity ?? null) !== canonicalJSON(current.childIdentity ?? null)
217
+ || canonicalJSON(config.childCapture ?? null) !== canonicalJSON(current.childCapture ?? null)
218
+ || closedProcess(current.stopReceipt?.manager, current.processIdentity)
219
+ && canonicalJSON(config.stopReceipt?.manager ?? null) !== canonicalJSON(current.stopReceipt.manager)))
220
+ throw Error('connector-child-custody-unreconciled');
221
+ const row = { name, updated_at: m.now(), kind, state, pid, port, public_url: publicUrl, config_json: config, cursor_json: cursor };
222
+ m.upsert('connectors', Object.fromEntries(Object.entries(row).filter(([, v]) => v !== undefined)), ['name']);
223
+ return true;
224
+ }), { env });
225
+
226
+
227
+ const sameIdentity = (a, b) => Boolean(a && b) && canonicalJSON(a) === canonicalJSON(b);
228
+ const sameConnectorOwner = (a, b) => Boolean(a && b) && a.pid === b.pid && a.source === b.source && sameIdentity(a.processIdentity, b.processIdentity);
229
+ const closedProcess = (receipt, identity) => receipt?.schema === OWNED_PROCESS_SCHEMA && receipt.ok === true
230
+ && ['gone', 'stopped'].includes(receipt.outcome) && receipt.proof === 'process-handle-signaled'
231
+ && receipt.pid === identity?.pid && sameIdentity(receipt.identity, identity);
232
+ const closedChild = (receipt, record) => Boolean(receipt && record.childLaunchNonce && receipt.launchNonce === record.childLaunchNonce)
233
+ && (closedProcess(receipt, record.childIdentity) || receipt.ok === true && receipt.proof === 'owned-child-process-exit'
234
+ && receipt.pid === record.childIdentity?.pid || receipt.ok === true && receipt.proof === 'owned-child-spawn-failed' && receipt.pid == null);
235
+
236
+ /** Original child custody remains owed even when its manager or lock no longer lives. */
237
+ export const connectorChildUnresolved = (record) => Boolean(record && (record.childLaunchNonce || record.childPid || record.childIdentity))
238
+ && !closedChild(record.childClosure, record) && !closedChild(record.stopReceipt?.child, record);
239
+ export const connectorManagerBlocked = (record) => connectorChildUnresolved(record)
240
+ && (closedProcess(record.stopReceipt?.manager, record.processIdentity) || !recordAlive(record));
241
+
242
+ const retainConnectorClosure = (name, expected, receipt, env) => withMachine((m) => m.transaction(() => {
243
+ const row = m.connectorOf(name), current = recordOf(row);
244
+ if (!sameConnectorOwner(current, expected) || canonicalJSON(current) !== canonicalJSON(expected)) return false;
245
+ m.upsert('connectors', { name, updated_at: m.now(), config_json: { ...(row.config ?? {}), stopReceipt: receipt } }, ['name']);
246
+ return true;
247
+ }), { env });
248
+
249
+ /** Close only recorded process objects; unknown effects retain the original row and a concurrent new owner is never overwritten. */
250
+ export function stopConnector(name, { source, child = false, env = process.env, read = connectorState, stop = stopOwnedProcess,
251
+ retain = (expected, receipt) => retainConnectorClosure(name, expected, receipt, env),
252
+ commit = (expected, receipt) => withMachine((m) => m.transaction(() => {
253
+ const row = m.connectorOf(name), current = recordOf(row);
254
+ if (!sameConnectorOwner(current, expected) || canonicalJSON(current) !== canonicalJSON(expected)) return false;
255
+ const stoppedAt = new Date(m.now()).toISOString();
256
+ m.upsert('connectors', { name, state: 'stopped', pid: null, public_url: null, updated_at: m.now(),
257
+ config_json: { ...(row.config ?? {}), pid: null, childPid: null, connected: false, stoppedAt, stopReceipt: receipt } }, ['name']);
258
+ return true;
259
+ }), { env }) } = {}) {
260
+ let original;
261
+ try { original = read(name, env); } catch (error) { return { ok: false, effectState: 'unknown', reason: 'connector-state-unreadable', error: String(error?.message ?? error) }; }
262
+ if (!original) return { ok: false, effectState: 'unknown', reason: 'connector-custody-missing' };
263
+ if (original.source !== source) return { ok: false, effectState: 'none', reason: 'connector-source-conflict', custody: original };
264
+ const prior = original.stopReceipt;
265
+ if (original.state === 'stopped' && closedProcess(prior?.manager, original.processIdentity)
266
+ && (!child || closedChild(prior?.child, original)))
267
+ return { ok: true, already: true, effectState: 'completed', stopped: original.processIdentity.pid, receipt: prior };
268
+ const identity = original.processIdentity;
269
+ if (!identity || identity.pid !== original.pid) return { ok: false, effectState: 'none', reason: 'connector-process-custody-required', custody: original };
270
+ let manager;
271
+ if (closedProcess(prior?.manager, identity)) manager = prior.manager;
272
+ else { try { manager = stop(identity); } catch (error) { manager = { ok: false, error: String(error?.message ?? error) }; } }
273
+ if (!closedProcess(manager, identity)) return { ok: false, effectState: manager?.outcome === 'refused' ? 'none' : 'unknown',
274
+ reason: 'connector-manager-closure-unverified', custody: original, receipt: { manager } };
275
+ let current;
276
+ try { current = read(name, env); } catch (error) { return { ok: false, effectState: 'unknown', reason: 'connector-state-unreadable', custody: original, receipt: { manager }, error: String(error?.message ?? error) }; }
277
+ if (!sameConnectorOwner(current, original)) return { ok: false, effectState: 'unknown', reason: 'connector-owner-changed', custody: original, receipt: { manager } };
278
+ if (child) {
279
+ try {
280
+ if (retain(current, { manager, child: current.stopReceipt?.child ?? null }) !== true)
281
+ return { ok: false, effectState: 'unknown', reason: 'connector-stop-record-refused', custody: current, receipt: { manager } };
282
+ current = read(name, env);
283
+ if (!sameConnectorOwner(current, original)) return { ok: false, effectState: 'unknown', reason: 'connector-owner-changed', custody: original, receipt: { manager } };
284
+ } catch (error) { return { ok: false, effectState: 'unknown', reason: 'connector-stop-record-refused', custody: current, receipt: { manager }, error: String(error?.message ?? error) }; }
285
+ }
286
+ let childReceipt = null;
287
+ if (child) {
288
+ if (closedChild(current.stopReceipt?.child, current)) childReceipt = current.stopReceipt.child;
289
+ else if (!current.childPid) {
290
+ if (!closedChild(current.childClosure, current)) return { ok: false, effectState: 'unknown', reason: 'connector-child-custody-unverified', custody: current, receipt: { manager } };
291
+ childReceipt = current.childClosure;
292
+ } else {
293
+ if (!current.childIdentity || current.childIdentity.pid !== current.childPid)
294
+ return { ok: false, effectState: 'unknown', reason: 'connector-child-custody-unverified', custody: current, receipt: { manager } };
295
+ try { childReceipt = { ...stop(current.childIdentity), launchNonce: current.childLaunchNonce }; } catch (error) { childReceipt = { ok: false, error: String(error?.message ?? error) }; }
296
+ if (!closedProcess(childReceipt, current.childIdentity)) return { ok: false, effectState: 'unknown',
297
+ reason: 'connector-child-closure-unverified', custody: current, receipt: { manager, child: childReceipt } };
298
+ }
299
+ }
300
+ const receipt = { manager, child: childReceipt };
301
+ try {
302
+ if (child) {
303
+ if (retain(current, receipt) !== true) return { ok: false, effectState: 'unknown', reason: 'connector-stop-record-refused', custody: current, receipt };
304
+ current = read(name, env);
305
+ if (!sameConnectorOwner(current, original) || !closedChild(current?.stopReceipt?.child, current))
306
+ return { ok: false, effectState: 'unknown', reason: 'connector-owner-changed', custody: original, receipt };
307
+ }
308
+ if (commit(current, receipt) !== true) return { ok: false, effectState: 'unknown', reason: 'connector-stop-record-refused', custody: current, receipt };
309
+ } catch (error) { return { ok: false, effectState: 'unknown', reason: 'connector-stop-record-refused', custody: current, receipt, error: String(error?.message ?? error) }; }
310
+ return { ok: true, effectState: 'completed', stopped: original.pid, receipt };
311
+ }
312
+
313
+ /** One connector log line in machine_logs (actor connector, kind `<source>.<kind>`). Never throws. */
314
+ export const connectorLog = (source, msg, { env = process.env, level = 'info', kind = 'log', data = null } = {}) =>
315
+ machineLog({ actor: 'connector', kind: `${source}.${kind}`, msg: String(msg), level, data }, { env });
316
+
317
+ /** Launch `node <script> ...args` detached from this process, output discarded. */
318
+ export const spawnDetached = (script, args = [], { env = process.env } = {}) => {
319
+ const child = spawnNode([script, ...args], { detached: true, stdio: 'ignore', cwd: skillRoot, env });
320
+ child.unref();
321
+ return child.pid ?? null;
322
+ };
323
+
324
+
325
+
326
+
327
+ /**
328
+ * The repositories whose ledgers the connectors read. `connectors.repos` when the owner listed any
329
+ * (relative entries resolve against the source root); otherwise the source root itself plus the Work
330
+ * owner of every .workspaces/projects/<p>/work.json binding — each kept only when it holds a ledger.
331
+ */
332
+ export function askRepos(connectors, { env = process.env, extra = [] } = {}) {
333
+ const source = starciSourceRoot(env);
334
+ const listed = (connectors?.repos ?? []).map((repo) => path.resolve(source, repo));
335
+ let candidates = listed;
336
+ if (!listed.length) {
337
+ candidates = [source];
338
+ const projects = path.join(source, '.workspaces', 'projects');
339
+ let entries = [];
340
+ try { entries = fs.readdirSync(projects, { withFileTypes: true }); } catch { /* no bindings */ }
341
+ for (const entry of entries) {
342
+ if (!entry.isDirectory()) continue;
343
+ const doc = readJsonFile(path.join(projects, entry.name, 'work.json'));
344
+ const rel = doc?.schema === 'starci/workspace-binding@2' ? doc?.repository?.pathFromSource : null;
345
+ if (typeof rel === 'string' && rel.trim()) candidates.push(path.resolve(source, rel));
346
+ }
347
+ }
348
+ const seen = new Set(), out = [];
349
+ for (const repo of [...candidates, ...extra.map((r) => path.resolve(r))]) {
350
+ const key = process.platform === 'win32' ? repo.toLowerCase() : repo;
351
+ if (seen.has(key) || !hasLedger(repo)) continue;
352
+ seen.add(key); out.push(repo);
353
+ }
354
+ return out;
355
+ }
356
+
357
+ /** The nonce path segment serve-ask binds (`/a-<hex>`). */
358
+ export const NONCE = /^a-[0-9a-f]{8,64}$/;
359
+ const nonceOf = (url) => {
360
+ try { const first = new URL(url).pathname.split('/')[1] ?? ''; return NONCE.test(first) ? first : null; } catch { return null; }
361
+ };
362
+ const LOOPBACK = new Set(['127.0.0.1', 'localhost', '[::1]', '::1']);
363
+ const isLoopbackUrl = (url) => {
364
+ try { const u = new URL(url); return u.protocol === 'http:' && LOOPBACK.has(u.hostname); } catch { return false; }
365
+ };
366
+ /** A credential ask names custody files or env variables to fill (serve-ask payload.fields). */
367
+ const isCredentialAsk = (fields) => Boolean((fields?.files?.length ?? 0) + (fields?.vars?.length ?? 0));
368
+
369
+ const parse = parseJson;
370
+
371
+ /**
372
+ * Every ask whose serve-ask form is still open in one ledger, newest serving per dispatch:
373
+ * not answered, not expired or superseded after it was served, and inside its ttl.
374
+ */
375
+ export function servingAsks(db, { now = Date.now() } = {}) {
376
+ const rows = db.prepare(`SELECT seq, workflow_id, payload_json, created_at FROM events WHERE kind='ask-serving' ORDER BY seq DESC`).all();
377
+ const seen = new Set(), out = [];
378
+ const closedAfter = db.prepare(`SELECT 1 FROM events WHERE workflow_id=? AND json_extract(payload_json,'$.dispatchId')=?
379
+ AND (kind='ask-answered' OR (kind IN ('ask-serving-expired','ask-superseded') AND seq>?)) LIMIT 1`);
380
+ for (const row of rows) {
381
+ const payload = parse(row.payload_json, {}) ?? {};
382
+ const dispatchId = payload.dispatchId;
383
+ if (!dispatchId || seen.has(`${row.workflow_id}\u0000${dispatchId}`)) continue;
384
+ seen.add(`${row.workflow_id}\u0000${dispatchId}`);
385
+ if (closedAfter.get(row.workflow_id, dispatchId, row.seq)) continue;
386
+ const ask = servingRecord(row, payload, now);
387
+ if (ask) out.push(ask);
388
+ }
389
+ return out;
390
+ }
391
+
392
+ // One ask-serving row as an open form, or null: past its ttl, not a loopback nonce URL, or its
393
+ // recorded serve-ask process is gone (the form stops being served the moment its process exits).
394
+ function servingRecord(row, payload, now) {
395
+ if (Number.isFinite(payload.ttlMs) && row.created_at + payload.ttlMs <= now) return null;
396
+ const nonce = nonceOf(payload.url);
397
+ if (!nonce || !isLoopbackUrl(payload.url)) return null;
398
+ if (Number.isInteger(payload.pid) && !pidAlive(payload.pid)) return null;
399
+ return {
400
+ seq: row.seq, workflowId: row.workflow_id, dispatchId: payload.dispatchId, url: payload.url, nonce, pid: payload.pid ?? null,
401
+ fields: payload.fields ?? { files: [], vars: [] }, credential: isCredentialAsk(payload.fields), onDemand: payload.onDemand === true,
402
+ createdAt: row.created_at, expiresAt: Number.isFinite(payload.ttlMs) ? row.created_at + payload.ttlMs : null,
403
+ };
404
+ }
405
+
406
+ /**
407
+ * One filed ask as the owner-facing connectors see it (read-only on `db`): {workflowId, dispatchId,
408
+ * opId, title, question, closed, serving}, or null when no ask report was filed for it. `closed` is
409
+ * 'answered', 'superseded' (retired, and not re-parked since: a later ask-notified or ask-serving
410
+ * reopens it), else null. `serving` is the live form (servingAsks' rules) or null: an open ask
411
+ * with no live form is healthy, its link is generated on demand from Telegram.
412
+ */
413
+ export function askState(db, workflowId, dispatchId, { now = Date.now() } = {}) {
414
+ const report = db.prepare("SELECT a.op_id,r.job_id,r.report_json FROM reports r JOIN op_attempts a ON a.attempt_id=r.attempt_id WHERE r.workflow_id=? AND r.dispatch_id=? AND r.outcome='ask' ORDER BY r.report_id DESC LIMIT 1").get(workflowId, dispatchId);
415
+ if (!report) return null;
416
+ const last = (kinds) => db.prepare(`SELECT seq, payload_json, created_at FROM events WHERE workflow_id=? AND kind IN (${kinds.map(() => '?').join(',')})
417
+ AND json_extract(payload_json,'$.dispatchId')=? ORDER BY seq DESC LIMIT 1`).get(workflowId, ...kinds, dispatchId) ?? null;
418
+ const answered = last(['ask-answered']), superseded = last(['ask-superseded']), reopened = last(['ask-serving', 'ask-notified']);
419
+ const closed = answered ? 'answered' : superseded && !(reopened && reopened.seq > superseded.seq) ? 'superseded' : null;
420
+ let serving = null;
421
+ const row = last(['ask-serving']);
422
+ if (!closed && row) {
423
+ const ended = last(['ask-serving-expired', 'ask-superseded']);
424
+ if (!(ended && ended.seq > row.seq)) serving = servingRecord({ ...row, workflow_id: workflowId }, parse(row.payload_json, {}) ?? {}, now);
425
+ }
426
+ const rj = parse(report.report_json, {}) ?? {};
427
+ return {
428
+ // title: the workflow's display name (starci kernel rename / define-goal), else its goal slug; jobName: the asking
429
+ // op job's `<op label> · <what> · <workflow name>` (scripts/lib/display-names.mjs).
430
+ workflowId, dispatchId, opId: report.op_id ?? null, title: workflowNameOf(db, workflowId), jobName: askJobName(db, workflowId, report),
431
+ question: rj.question ?? { text: rj.summary ?? '', options: [] }, closed, serving,
432
+ };
433
+ }
434
+
435
+ /** The display name of the op job that filed an ask report, or null. */
436
+ const askJobName = (db, workflowId, report) => {
437
+ try {
438
+ const job = db.prepare("SELECT * FROM jobs WHERE workflow_id=? AND job_id=? AND kind<>'kernel' LIMIT 1")
439
+ .get(workflowId, report.job_id);
440
+ return job ? jobDisplayNameOf(db, job) : null;
441
+ } catch { return null; }
442
+ };
443
+
444
+ /** Every open ask of one ledger (askState with closed null), newest report first, in live workflows only. */
445
+ export function openAskList(db, { now = Date.now() } = {}) {
446
+ const rows = db.prepare(`SELECT r.workflow_id, r.dispatch_id FROM reports r JOIN workflows w ON w.workflow_id=r.workflow_id
447
+ WHERE r.outcome='ask' AND w.archived_at IS NULL AND (w.phase IS NULL OR w.phase<>'finished') ORDER BY r.report_id DESC`).all();
448
+ const seen = new Set(), out = [];
449
+ for (const row of rows) {
450
+ const id = `${row.workflow_id}\u0000${row.dispatch_id}`;
451
+ if (seen.has(id)) continue;
452
+ seen.add(id);
453
+ const state = askState(db, row.workflow_id, row.dispatch_id, { now });
454
+ if (state && !state.closed) out.push(state);
455
+ }
456
+ return out;
457
+ }
458
+
459
+ /** The repos the Telegram ask notices name (notifications kind 'ask'), so the gateway can route their forms. */
460
+ export const notifiedRepos = (env = process.env) => readMachine((m) => [...new Set(m.db.prepare("SELECT json_extract(ref,'$.repo') repo FROM notifications WHERE kind='ask' AND json_valid(ref)").all()
461
+ .map((r) => r.repo).filter((r) => typeof r === 'string' && r))], [], { env });
462
+
463
+ /** Open one repo's ledger read-only for `fn`, always closing it; a missing or unreadable ledger yields `fallback`. */
464
+ export function withLedgerRead(repo, fn, fallback = null) {
465
+ let handle = null;
466
+ try { handle = inspectLedger({ file: ledgerFileFor(repo) }); } catch { return fallback; }
467
+ try { return fn(handle.db); } finally { try { handle.close(); } catch { /* closed */ } }
468
+ }
469
+
470
+ /** Open asks across repos, each tagged with its repo. */
471
+ export const servingAsksAcross = (repos, opts = {}) =>
472
+ repos.flatMap((repo) => withLedgerRead(repo, (db) => servingAsks(db, opts).map((ask) => ({ ...ask, repo })), []));
473
+
474
+ /** The owner config, or null when it cannot be read (a connector never dies on a broken file it only observes). */
475
+ export const ownerConfig = () => { try { return loadConfig(); } catch { return null; } };
476
+
477
+ export const argsOf = (argv) => {
478
+ const out = { _: [] };
479
+ for (let i = 0; i < argv.length; i++) {
480
+ const k = argv[i];
481
+ if (!k.startsWith('--')) { out._.push(k); continue; }
482
+ const key = k.slice(2), next = argv[i + 1];
483
+ let value = true;
484
+ if (next !== undefined && !next.startsWith('--')) { value = next; i++; }
485
+ out[key] = key in out ? [].concat(out[key], value) : value;
486
+ }
487
+ return out;
488
+ };
@@ -10,7 +10,7 @@ import { isFeTestPath } from '../rules/fe-no-tests.mjs';
10
10
  * root. A file that sits where the slot expects a folder (`hooks/useX.ts`, so the "domain" is a file name) is one too.
11
11
  * Files no slot owns are HFS_PATH_NO_SLOT's (`starci app check`), so together every front-end file is placed by exactly one slot and
12
12
  * named by it. A test path (a `.spec.tsx` or `.test.tsx` beside a component, a test directory or test tooling) is FE_NO_TESTS's
13
- * (R97, contract-change fe-no-tests): that rule is the one finding of such a file, so this check leaves it alone, as
13
+ * (R97 FE_NO_TESTS): that rule is the one finding of such a file, so this check leaves it alone, as
14
14
  * HFS_PATH_NO_SLOT does (scripts/hfs/path-findings.mjs).
15
15
  */
16
16
  export const FE_SLOT_ALLOWS_RULE_IDS = ['FE_SLOT_FILE_ROLE'];
@@ -12,11 +12,19 @@
12
12
  // Exit 0 done, 2 a refusal or bad usage (a secret value is never part of a message).
13
13
  import fs from 'node:fs';
14
14
  import path from 'node:path';
15
- import { randomBytes } from 'node:crypto';
15
+ import { runProgram } from '../api/process/run-program.mjs';
16
+ import { resolveRealTool } from '../api/process/resolve-real-tool.mjs';
17
+ const sopsInvocation = Object.freeze({runProgram,resolveRealTool});
18
+ import { randomBytes, randomUUID } from 'node:crypto';
19
+ import { claimManager } from '../connectors/lib.mjs';
20
+ import { sha256 } from '../../engine/digest.mjs';
21
+ import { isSopsEnvelope } from '../lib/sops-envelope.mjs';
16
22
  import { decrypt as sopsDecrypt } from '../api/sops/decrypt.mjs';
17
23
  import { seal as sopsSeal } from '../api/sops/seal.mjs';
18
24
 
19
25
  class SecretError extends Error {}
26
+ // claimManager is process-owned; refuse a recursive same-process write as well.
27
+ const writingSecrets = new Set();
20
28
 
21
29
  const SLUG = /^[a-z0-9][a-z0-9-]*$/u;
22
30
  const KEY = /^[A-Za-z_][A-Za-z0-9_]*$/u;
@@ -75,18 +83,19 @@ const fileOf = (directory, slug) => {
75
83
  /** The default sops seam: the runtime's sops api, which finds the binary on this machine. */
76
84
  function defaultSops(env = process.env) {
77
85
  const refuse = (what, result) => {
86
+ if (result.error?.identityRefusal) return Object.assign(new SecretError(result.error.message), { identityRefusal: result.error.identityRefusal });
78
87
  if (result.error?.code === 'SOPS_MISSING') return new SecretError(result.error.message);
79
88
  return new SecretError(`sops could not ${what} (exit ${String(result.status)})`);
80
89
  };
81
90
  return {
82
91
  decrypt(file, format) {
83
- const result = sopsDecrypt(null, ['decrypt', '--input-type', format, '--output-type', 'json', file], { env });
92
+ const result = sopsDecrypt(null, ['decrypt', '--input-type', format, '--output-type', 'json', file], { env, invocation: sopsInvocation });
84
93
  if (result.status !== 0) throw refuse(`decrypt ${path.basename(file)}; is the age identity installed?`, result);
85
- return JSON.parse(result.stdout);
94
+ try { return JSON.parse(result.stdout); } catch { throw new SecretError('sops returned an invalid plaintext document'); }
86
95
  },
87
96
  seal(request) {
88
- const result = sopsSeal(null, request, { env });
89
- if (result.status !== 0) throw refuse(`seal the secret: ${String(result.stderr).trim().split(String.fromCharCode(10)).at(-1)}`, result);
97
+ const result = sopsSeal(null, request, { env, invocation: sopsInvocation });
98
+ if (result.status !== 0) throw refuse('seal the secret', result);
90
99
  return result.stdout;
91
100
  },
92
101
  };
@@ -109,8 +118,15 @@ function parse(argv) {
109
118
  const readStdin = () => fs.readFileSync(0, 'utf8');
110
119
 
111
120
  /** Seal `key = value` into the secret `slug`: an existing document keeps its other keys and its recipients. */
112
- function writeSecret({ file, key, value, age, sops }) {
113
- if (!KEY.test(key)) throw new SecretError(`${key} is not a key name`);
121
+ function writeSecret({ file, values, age, sops, processEnv = process.env }) {
122
+ for (const key of Object.keys(values)) if (!KEY.test(key)) throw new SecretError(`${key} is not a key name`);
123
+ const absolute = path.resolve(file), target = process.platform === 'win32' ? absolute.toLowerCase() : absolute;
124
+ const name = `sealed-secret-${sha256(target)}`;
125
+ if (writingSecrets.has(name)) throw new SecretError('the sealed secret is being updated; retry after that write settles');
126
+ const held = claimManager(name, { env: processEnv });
127
+ if (!held.ok) throw new SecretError('the sealed secret is being updated; retry after that write settles');
128
+ writingSecrets.add(name);
129
+ try {
114
130
  let format = 'json';
115
131
  let map = {};
116
132
  let recipients = age;
@@ -121,10 +137,28 @@ function writeSecret({ file, key, value, age, sops }) {
121
137
  map = sops.decrypt(file, format);
122
138
  if (!age.length) recipients = envelope.recipients;
123
139
  }
124
- map[key] = value;
140
+ Object.assign(map, values);
125
141
  const sealed = sops.seal({ inputType: format, plaintext: plaintextOf(format, map), recipients, filenameOverride: file });
142
+ let parsed;
143
+ try { parsed = envelopeOf(sealed); } catch { throw new SecretError('sops returned an invalid sealed document'); }
144
+ if (!isSopsEnvelope(sealed) || parsed.keys.length !== Object.keys(map).length || !parsed.keys.every(key => Object.hasOwn(map, key))) throw new SecretError('sops returned an invalid sealed document');
126
145
  fs.mkdirSync(path.dirname(file), { recursive: true });
127
- fs.writeFileSync(file, sealed);
146
+ const temporary = path.join(path.dirname(file), `.${path.basename(file)}-${randomUUID()}.tmp`);
147
+ try {
148
+ fs.writeFileSync(temporary, sealed, { flag: 'wx', mode: 0o600, flush: true });
149
+ fs.renameSync(temporary, file);
150
+ } finally { if (fs.existsSync(temporary)) fs.unlinkSync(temporary); }
151
+ } finally { writingSecrets.delete(name); held.release(); }
152
+ }
153
+
154
+ /** Update named keys through the same canonical sealed-secret owner used by the CLI. */
155
+ export function setSecretValues(repoRoot, { slug, values, env = null }, { sops = null, age = [], processEnv = process.env } = {}) {
156
+ const directory = secretsDirectory(repoRoot, env);
157
+ const file = fileOf(directory, slug);
158
+ if (!values || typeof values !== 'object' || Array.isArray(values) || !Object.keys(values).length) throw new SecretError('secret update requires named values');
159
+ for (const value of Object.values(values)) if (typeof value !== 'string' || value === '') throw new SecretError('secret values must be nonempty strings');
160
+ writeSecret({ file, values, age, sops: sops ?? defaultSops(processEnv), processEnv });
161
+ return { file, via: 'sealed-secret' };
128
162
  }
129
163
 
130
164
  /** `starci app secret <verb> ...`; `stdin` returns the value `set` seals, `sops` and `random` are test seams. */
@@ -159,13 +193,13 @@ export function secretMain(argv, { stdout = (s) => process.stdout.write(s), stde
159
193
  if (verb === 'set') {
160
194
  const value = String(stdin()).replace(/\r?\n$/u, '');
161
195
  if (value === '') throw new SecretError('the value on stdin is empty');
162
- writeSecret({ file, key, value, age: opts.age, sops: seam });
196
+ writeSecret({ file, values: { [key]: value }, age: opts.age, sops: seam, processEnv: env });
163
197
  stdout(`sealed ${opts.positional[0]} (${key})\n`);
164
198
  return 0;
165
199
  }
166
200
  const bytes = opts.bytes === undefined ? 32 : Number(opts.bytes);
167
201
  if (!Number.isInteger(bytes) || bytes < 16 || bytes > 1024) throw new SecretError('--bytes is a whole number from 16 to 1024');
168
- writeSecret({ file, key, value: random(bytes).toString('base64url'), age: opts.age, sops: seam });
202
+ writeSecret({ file, values: { [key]: random(bytes).toString('base64url') }, age: opts.age, sops: seam, processEnv: env });
169
203
  stdout(`generated and sealed ${opts.positional[0]} (${key}, ${bytes} bytes); read it with starci app secret show\n`);
170
204
  return 0;
171
205
  } catch (error) {