@camstack/server 1.1.76 → 1.2.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.
@@ -0,0 +1,184 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.AgentUpdateService = exports.AGENT_RESTART_GRACE_MS = exports.AGENT_RUNTIME_ADDON_ID = void 0;
37
+ exports.isRunningInContainer = isRunningInContainer;
38
+ exports.resolveAgentPackageJsonPath = resolveAgentPackageJsonPath;
39
+ exports.scheduleAgentRestart = scheduleAgentRestart;
40
+ exports.agentRuntimeManifestEntry = agentRuntimeManifestEntry;
41
+ /**
42
+ * AgentUpdateService — agent-side adapter over the SHARED `RootUpdateService`
43
+ * (`@camstack/node-root`, bundled into the `@camstack/server` dist by tsup).
44
+ * Post-consolidation the agent boots the SAME `@camstack/server` closure as
45
+ * the hub (via `CAMSTACK_ROLE=agent`), so this service stages/applies
46
+ * `@camstack/server` closures in `<agentDataDir>/server-root/`, applied on
47
+ * restart by the shared STARTER (single-copy in-place swap — no probation,
48
+ * no auto-rollback).
49
+ *
50
+ * The engine lives in the shared core; this module binds the agent's
51
+ * parameters, which differ from the hub only in the RESTART seam:
52
+ * - spec `@camstack/server` + `dist/launcher.js` (the single root spec)
53
+ * - env markers `CAMSTACK_SERVER_{BOOT_MODE,ACTIVE_VERSION}` +
54
+ * `CAMSTACK_SEED_SERVER_DIR` (written by the shared starter / image) —
55
+ * identical to the hub, because the on-disk root is `@camstack/server`
56
+ * - restart = graceful self-SIGTERM → the supervisor (docker restart
57
+ * policy / Electron main) relaunches → the shared starter swaps the copy
58
+ * - confirmBootHealthy = the agent's `$hub.registerNode` reaching its
59
+ * acked state (wired in main.ts) — a single-copy GC sweep, no promotion
60
+ *
61
+ * Spec: docs/superpowers/specs/2026-07-18-single-framework-copy-collapse-design.md
62
+ */
63
+ const fs = __importStar(require("node:fs"));
64
+ const path = __importStar(require("node:path"));
65
+ const index_js_1 = require("../server-root/index.js");
66
+ const system_exec_npm_js_1 = require("../core/server-update/system-exec-npm.js");
67
+ /**
68
+ * The synthetic addonId the agent's OWN runtime registers infra providers
69
+ * under (no addon owns them — the bootstrap does). Appears in the agent's
70
+ * `$hub.registerNode` manifest so the hub's cap routing can reach the
71
+ * agent-hosted `server-management` provider via `nodeId` pinning.
72
+ */
73
+ exports.AGENT_RUNTIME_ADDON_ID = 'agent-runtime';
74
+ /** Grace before the self-SIGTERM so the tRPC reply can flush to the hub. */
75
+ exports.AGENT_RESTART_GRACE_MS = 2_000;
76
+ /**
77
+ * Best-effort container detection for the restart-policy startup warning
78
+ * (security review #3). `/.dockerenv` is present in Docker/Podman containers;
79
+ * this is diagnostic only — never gates any logic.
80
+ */
81
+ function isRunningInContainer(existsSyncFn) {
82
+ return existsSyncFn('/.dockerenv');
83
+ }
84
+ /**
85
+ * Resolve the RUNNING root (`@camstack/server`) package.json. The agent boots
86
+ * the same `@camstack/server` closure as the hub, whose dist lives at
87
+ * `<pkg>/dist/*.js`, so `../package.json` is the normal hit; the second
88
+ * candidate covers a nested layout.
89
+ */
90
+ function resolveAgentPackageJsonPath(fromDir) {
91
+ const candidates = [
92
+ path.resolve(fromDir, '..', 'package.json'),
93
+ path.resolve(fromDir, '..', '..', 'package.json'),
94
+ ];
95
+ for (const candidate of candidates) {
96
+ try {
97
+ const raw = JSON.parse(fs.readFileSync(candidate, 'utf-8'));
98
+ if (raw.name === index_js_1.HUB_ROOT_SPEC.packageName)
99
+ return candidate;
100
+ }
101
+ catch {
102
+ /* keep searching */
103
+ }
104
+ }
105
+ return candidates[0] ?? path.resolve(fromDir, '..', 'package.json');
106
+ }
107
+ /**
108
+ * Default restart seam: log, then after a short grace self-deliver SIGTERM so
109
+ * the bootstrap's graceful shutdown runs (addons stopped, broker stopped,
110
+ * exit 0) and the container restart policy relaunches into the starter. A
111
+ * hard exit fallback guards against a wedged shutdown.
112
+ *
113
+ * When `dataDir` is given, drop a restart-intent MARKER in the server-root dir
114
+ * BEFORE the SIGTERM so an external process supervisor (phase 3: the Electron
115
+ * shell) can tell this INTENTIONAL update/rollback restart apart from a crash
116
+ * and not penalise its crash-backoff. The docker restart policy ignores the
117
+ * marker (it relaunches regardless), so this is a no-op there. Best-effort: a
118
+ * marker-write failure must never block the restart.
119
+ */
120
+ function scheduleAgentRestart(logger, requestedBy, dataDir) {
121
+ if (dataDir !== undefined && dataDir.length > 0) {
122
+ try {
123
+ (0, index_js_1.writeRestartIntentMarker)((0, index_js_1.serverRootDir)(dataDir), {
124
+ requestedAtMs: Date.now(),
125
+ reason: requestedBy,
126
+ });
127
+ }
128
+ catch (err) {
129
+ logger.warn('failed to write restart-intent marker', {
130
+ meta: { error: err instanceof Error ? err.message : String(err) },
131
+ });
132
+ }
133
+ }
134
+ logger.warn('agent restart requested — exiting for supervisor relaunch', {
135
+ meta: { requestedBy, graceMs: exports.AGENT_RESTART_GRACE_MS },
136
+ });
137
+ const grace = setTimeout(() => {
138
+ process.kill(process.pid, 'SIGTERM');
139
+ const hard = setTimeout(() => process.exit(0), 15_000);
140
+ hard.unref();
141
+ }, exports.AGENT_RESTART_GRACE_MS);
142
+ grace.unref();
143
+ }
144
+ class AgentUpdateService extends index_js_1.RootUpdateService {
145
+ constructor(options) {
146
+ super({
147
+ spec: index_js_1.HUB_ROOT_SPEC,
148
+ // The agent's on-disk root IS `@camstack/server` (booted via
149
+ // CAMSTACK_ROLE=agent by the shared starter), so it reads the SAME
150
+ // `CAMSTACK_SERVER_*` boot markers the starter writes.
151
+ envNames: {
152
+ bootMode: 'CAMSTACK_SERVER_BOOT_MODE',
153
+ activeVersion: 'CAMSTACK_SERVER_ACTIVE_VERSION',
154
+ seedDir: 'CAMSTACK_SEED_SERVER_DIR',
155
+ },
156
+ logger: options.logger,
157
+ restartServer: options.restartAgent ??
158
+ ((requestedBy) => scheduleAgentRestart(options.logger, requestedBy, options.dataDir)),
159
+ dataDir: options.dataDir,
160
+ runningPackageJsonPath: options.runningPackageJsonPath ?? resolveAgentPackageJsonPath(__dirname),
161
+ workspaceProbeDir: __dirname,
162
+ // Default to the runNpm-backed adapter — a packaged desktop agent ships a
163
+ // bare Node with no system npm, so the raw `execFile('npm')` default would
164
+ // ENOENT. `cacheDir` mirrors AddonInstaller's `<dataDir>/addons/.npm-bootstrap`.
165
+ execNpm: options.execNpm ??
166
+ (0, system_exec_npm_js_1.buildSystemExecNpm)({
167
+ cacheDir: path.join(options.dataDir, 'addons', '.npm-bootstrap'),
168
+ logger: options.logger,
169
+ }),
170
+ env: options.env,
171
+ now: options.now,
172
+ });
173
+ }
174
+ }
175
+ exports.AgentUpdateService = AgentUpdateService;
176
+ /**
177
+ * The manifest entry for the agent runtime's own providers. Appended to
178
+ * `buildAgentOwnManifest`'s output so `server-management` reaches the hub's
179
+ * `HubNodeRegistry` (and therefore `createCapabilityProxy` node routing) even
180
+ * though no `loadedAddons` entry owns it.
181
+ */
182
+ function agentRuntimeManifestEntry() {
183
+ return { addonId: exports.AGENT_RUNTIME_ADDON_ID, capabilities: ['server-management'] };
184
+ }
@@ -0,0 +1,14 @@
1
+ "use strict";
2
+ /**
3
+ * Agent-side model distribution (P2): pull a staged model tarball from the hub
4
+ * and untar it into this node's modelsDir. Factored behind a seam so the
5
+ * fetch/extract/fs effects are injectable in tests.
6
+ */
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.applyModelDistribution = applyModelDistribution;
9
+ async function applyModelDistribution(seam, params) {
10
+ seam.mkdirp(seam.modelsDir);
11
+ const buffer = await seam.fetchBundle(params.source);
12
+ await seam.extract(buffer, seam.modelsDir);
13
+ return { success: true, modelId: params.modelId, format: params.format, path: seam.modelsDir };
14
+ }
@@ -0,0 +1,139 @@
1
+ "use strict";
2
+ /**
3
+ * Derive the agent's `CAMSTACK_HUB_URL` from its friendly `hubAddress`.
4
+ *
5
+ * `hubAddress` is the agent's single source of truth for "where is the hub"
6
+ * (the Moleculer TCP dial target). The cross-node consumers of
7
+ * `CAMSTACK_HUB_URL` — the pipeline-runner remote-source leg and the
8
+ * cross-node recorder pull — only ever extract the HOSTNAME from it
9
+ * (`resolveHubHostname` / `extractHost` in
10
+ * `packages/addon-pipeline/src/shared/hub-hostname.ts`). Deriving the value
11
+ * here removes the historical requirement to set a second, redundant env var
12
+ * by hand on every remote agent (the cross-node-source trap).
13
+ *
14
+ * Accepts every friendly form `normalizeHubUrl`
15
+ * (`packages/system/src/kernel/moleculer/broker-factory.ts`) accepts:
16
+ * `host`, `host:port`, `host:port/nodeID`, `nodeID@host`,
17
+ * `nodeID@host:port`, and bracketed IPv6 (`[::1]:6000`).
18
+ * The optional `nodeID@` prefix and `/nodeID` suffix are stripped and the
19
+ * Moleculer `:port` (6000-family) is dropped — only the host survives.
20
+ *
21
+ * Port note: the emitted `:4443` is the DEFAULT hub API port (matching the
22
+ * hub-side default in `server/backend/src/api/addon-upload.ts` and the
23
+ * manually-proven value used on live remote agents), NOT a probed value.
24
+ * Because every current consumer keeps only the host, the port is convention;
25
+ * a future full-URL consumer must treat it as the default, not authoritative.
26
+ */
27
+ Object.defineProperty(exports, "__esModule", { value: true });
28
+ exports.deriveHubUrlFromHubAddress = deriveHubUrlFromHubAddress;
29
+ exports.deriveHubUrlForExport = deriveHubUrlForExport;
30
+ exports.pickIpv4 = pickIpv4;
31
+ exports.normalizeObservedHost = normalizeObservedHost;
32
+ exports.deriveHubUrlFromRegistry = deriveHubUrlFromRegistry;
33
+ const HUB_API_PORT = 4443;
34
+ function deriveHubUrlFromHubAddress(hubAddress) {
35
+ const trimmed = hubAddress.trim();
36
+ if (trimmed.length === 0)
37
+ return undefined;
38
+ // Strip an optional `nodeID@` prefix.
39
+ const afterAt = trimmed.includes('@') ? (trimmed.split('@')[1] ?? '') : trimmed;
40
+ // Strip an optional `/nodeID` suffix — keep only the `host[:port]` authority.
41
+ const authority = afterAt.split('/')[0] ?? '';
42
+ const host = extractHostFromAuthority(authority);
43
+ if (host === undefined)
44
+ return undefined;
45
+ return `https://${host}:${HUB_API_PORT}`;
46
+ }
47
+ /**
48
+ * Compute the `CAMSTACK_HUB_URL` value the agent should export, or `undefined`
49
+ * when it must be left untouched. An explicit operator-provided env value
50
+ * (`hubUrlWasExplicit`) always wins and is never clobbered — this preserves
51
+ * today's working deployments. Kept pure (env is passed in) so both the
52
+ * boot-time derivation and the reconnect path can be unit-tested.
53
+ */
54
+ function deriveHubUrlForExport(hubUrlWasExplicit, hubAddress) {
55
+ if (hubUrlWasExplicit)
56
+ return undefined;
57
+ if (hubAddress === undefined)
58
+ return undefined;
59
+ return deriveHubUrlFromHubAddress(hubAddress);
60
+ }
61
+ /** Take the bare host from a `host[:port]` authority, keeping bracketed IPv6. */
62
+ function extractHostFromAuthority(authority) {
63
+ const value = authority.trim();
64
+ if (value.length === 0)
65
+ return undefined;
66
+ if (value.startsWith('[')) {
67
+ const end = value.indexOf(']');
68
+ if (end > 0)
69
+ return value.slice(0, end + 1);
70
+ return undefined;
71
+ }
72
+ const host = value.split(':')[0] ?? '';
73
+ return host.length > 0 ? host : undefined;
74
+ }
75
+ /** Node id the hub broker always registers under. */
76
+ const HUB_NODE_ID = 'hub';
77
+ const IPV4_MAPPED_PREFIX = '::ffff:';
78
+ const IPV4_DOTTED_QUAD = /^\d{1,3}(?:\.\d{1,3}){3}$/;
79
+ /**
80
+ * First non-internal IPv4 in a Moleculer node's advertised IP list.
81
+ * Canonical home of this helper — `resolveHubEndpoint` in `agent-http.ts`
82
+ * (UI-facing `resolvedHubAddress`) re-exports and reuses it so the UI display
83
+ * and the exported `CAMSTACK_HUB_URL` always agree.
84
+ */
85
+ function pickIpv4(ipList) {
86
+ if (!ipList)
87
+ return null;
88
+ for (const ip of ipList) {
89
+ if (ip.includes(':'))
90
+ continue; // skip IPv6
91
+ if (ip.startsWith('127.'))
92
+ continue; // skip loopback
93
+ return ip;
94
+ }
95
+ return null;
96
+ }
97
+ /**
98
+ * Normalize a socket-observed address so it survives a WHATWG-`URL` hostname
99
+ * write (`substituteRtspHost`): strip the `::ffff:` IPv4-mapped prefix,
100
+ * bracket bare IPv6 (an unbracketed IPv6 assigned to `url.hostname` is
101
+ * silently IGNORED — the URL would keep 127.0.0.1), pass plain IPv4 /
102
+ * hostnames / already-bracketed IPv6 through untouched.
103
+ */
104
+ function normalizeObservedHost(raw) {
105
+ if (raw === undefined || raw === null)
106
+ return undefined;
107
+ const trimmed = raw.trim();
108
+ if (trimmed.length === 0)
109
+ return undefined;
110
+ if (trimmed.startsWith('['))
111
+ return trimmed; // already-bracketed IPv6
112
+ if (trimmed.toLowerCase().startsWith(IPV4_MAPPED_PREFIX)) {
113
+ const mapped = trimmed.slice(IPV4_MAPPED_PREFIX.length);
114
+ if (IPV4_DOTTED_QUAD.test(mapped))
115
+ return mapped;
116
+ }
117
+ // Any remaining colon means bare IPv6 — bracket it.
118
+ if (trimmed.includes(':'))
119
+ return `[${trimmed}]`;
120
+ return trimmed;
121
+ }
122
+ /**
123
+ * Best hub host from the agent-side Moleculer registry view:
124
+ * `udpAddress` (wire truth) → first non-loopback IPv4 in `ipList`
125
+ * (hub self-advertised; container-internal in bridge-mode Docker) →
126
+ * `hostname` (hub's `os.hostname()`; often unresolvable — last resort).
127
+ * Returns `undefined` when the hub node is not (yet) known. Emits the SAME
128
+ * `https://host:4443` shape as `deriveHubUrlFromHubAddress` — see the port
129
+ * note at the top of this file.
130
+ */
131
+ function deriveHubUrlFromRegistry(nodes) {
132
+ const hub = nodes.find((node) => node.id === HUB_NODE_ID);
133
+ if (hub === undefined)
134
+ return undefined;
135
+ const host = normalizeObservedHost(hub.udpAddress) ?? pickIpv4(hub.ipList) ?? hub.hostname;
136
+ if (host === undefined || host.length === 0)
137
+ return undefined;
138
+ return `https://${host}:${HUB_API_PORT}`;
139
+ }
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.fetchBundleFromHub = fetchBundleFromHub;
4
+ const node_crypto_1 = require("node:crypto");
5
+ const undici_1 = require("undici");
6
+ /**
7
+ * Stream a staged addon tgz from the hub's one-time-token bundle route and
8
+ * verify integrity (byte count + sha256) before handing it to the installer.
9
+ */
10
+ async function fetchBundleFromHub(opts) {
11
+ // When a fetchImpl is injected (tests), call it as-is — the stub ignores dispatcher.
12
+ // For production (global fetch against the real hub's self-signed TLS cert), use
13
+ // undici.fetch with an Agent that disables CA verification. The hub serves HTTPS with a
14
+ // self-signed cert; bare fetch rejects it with UNABLE_TO_VERIFY_LEAF_SIGNATURE.
15
+ // Integrity is independently guaranteed by the sha256+bytes checks below; trust is
16
+ // established by the cluster relationship — consistent with the CLI's approach.
17
+ //
18
+ // We call undici.fetch (not the global fetch) so that `Agent` and `RequestInit` share
19
+ // the same undici type declarations; the global fetch augmentation uses `undici-types`
20
+ // which ships a structurally incompatible `Dispatcher` type. Both return a structurally
21
+ // compatible Response; we narrow via the shared `ResponseLike` interface to avoid
22
+ // cross-package type unification.
23
+ // `accept-encoding: identity` — never let the hub gzip/brotli this binary
24
+ // pull. A Brotli-encoded response made undici's BrotliDecompress abort with
25
+ // "TypeError: terminated", silently breaking the pull. The hub also opts the
26
+ // route out of compression; this is the client-side belt-and-suspenders.
27
+ const authHeader = { Authorization: `Bearer ${opts.token}`, 'accept-encoding': 'identity' };
28
+ const res = opts.fetchImpl
29
+ ? await opts.fetchImpl(opts.url, { headers: authHeader })
30
+ : await (0, undici_1.fetch)(opts.url, {
31
+ headers: authHeader,
32
+ dispatcher: new undici_1.Agent({ connect: { rejectUnauthorized: false } }),
33
+ });
34
+ if (!res.ok) {
35
+ throw new Error(`bundle fetch failed: HTTP ${res.status}`);
36
+ }
37
+ const buffer = Buffer.from(await res.arrayBuffer());
38
+ if (buffer.length !== opts.bytes) {
39
+ throw new Error(`bundle bytes mismatch: expected ${opts.bytes}, got ${buffer.length}`);
40
+ }
41
+ const sha = (0, node_crypto_1.createHash)('sha256').update(buffer).digest('hex');
42
+ if (sha !== opts.sha256) {
43
+ throw new Error(`bundle sha256 mismatch: expected ${opts.sha256}, got ${sha}`);
44
+ }
45
+ return buffer;
46
+ }