@nettee/beacon 0.1.8 → 0.1.9

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
@@ -48,6 +48,7 @@ Copy the files under [`examples`](examples) into a private Beacon home and repla
48
48
  ```text
49
49
  ~/.beacon/
50
50
  ├── config.yaml
51
+ ├── runtime.env
51
52
  ├── secrets.json
52
53
  └── profiles/
53
54
  └── example/
@@ -59,9 +60,27 @@ Protect the home and secret file before running Beacon:
59
60
 
60
61
  ```sh
61
62
  chmod 700 /Users/USERNAME/.beacon
63
+ test ! -e /Users/USERNAME/.beacon/runtime.env || chmod 600 /Users/USERNAME/.beacon/runtime.env
62
64
  chmod 600 /Users/USERNAME/.beacon/secrets.json
63
65
  ```
64
66
 
67
+ `runtime.env` is optional. When present, Beacon loads every `KEY=VALUE` entry
68
+ once at startup and passes it to every Pi Run. This gives launchd deployments a
69
+ deterministic runtime environment without executing interactive shell files such
70
+ as `.zshrc`:
71
+
72
+ ```dotenv
73
+ GRAFANA_READER_TOKEN_PROD=replace-me
74
+ ```
75
+
76
+ The file must be owned by the Beacon user with mode `0600`. Blank lines and
77
+ lines beginning with `#` are ignored; values are literal and never evaluated as
78
+ shell syntax. Duplicate or invalid names, malformed entries, and reserved
79
+ process variables such as `PATH`, `NODE_OPTIONS`, `BEACON_*`, and `FEISHU_*`
80
+ fail startup. A missing file is treated as an empty runtime environment. Changes
81
+ take effect after Beacon restarts. Beacon does not log or persist values from
82
+ this file, but every configured Profile and Pi Run receives them.
83
+
65
84
  Configure a dedicated absolute Pi session root in `config.yaml`:
66
85
 
67
86
  ```yaml
@@ -76,7 +95,7 @@ created by Beacon 0.1.2 and earlier; when omitted it defaults to `sessions/`
76
95
  beside `config.yaml`. When configured, it must be absolute. Beacon creates
77
96
  per-Run directories with mode `0700` when Pi starts.
78
97
 
79
- Configuration is strict: unknown YAML/JSON fields, YAML aliases or warnings, missing paths, duplicate Schedule IDs, invalid timezones/cron expressions, unsafe Prompt paths, permissive secret permissions, and missing Profile credentials all fail startup. Beacon validates all Profiles before opening a Feishu connection.
98
+ Configuration is strict: unknown YAML/JSON fields, YAML aliases or warnings, missing paths, duplicate Schedule IDs, invalid timezones/cron expressions, unsafe Prompt paths, permissive secret or runtime-environment permissions, and missing Profile credentials all fail startup. Beacon validates all Profiles before opening a Feishu connection.
80
99
 
81
100
  Each Schedule uses a five-field cron expression and an IANA timezone. Its `delivery.chat_id` may identify either a direct chat or a group chat; Beacon deliberately does not infer or fall back to another destination. A newly discovered Schedule starts at the current time. After sleep or restart, overdue occurrences are reconciled and coalesced to the most recent one.
82
101
 
@@ -75,6 +75,7 @@ export async function loadGlobalConfig(path) {
75
75
  homeDirectory,
76
76
  profilesDirectory,
77
77
  secretsPath: resolve(homeDirectory, "secrets.json"),
78
+ runtimeEnvironmentPath: resolve(homeDirectory, "runtime.env"),
78
79
  pi: {
79
80
  executable,
80
81
  codingAgentDirectory,
@@ -0,0 +1,91 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ const variableNamePattern = /^[A-Za-z_][A-Za-z0-9_]*$/;
3
+ const reservedVariableNames = new Set([
4
+ "PATH",
5
+ "HOME",
6
+ "USER",
7
+ "LOGNAME",
8
+ "SHELL",
9
+ "TMPDIR",
10
+ "LANG",
11
+ "LC_ALL",
12
+ "TERM",
13
+ "COLORTERM",
14
+ "SSH_AUTH_SOCK",
15
+ "XDG_CONFIG_HOME",
16
+ "XDG_CACHE_HOME",
17
+ "GH_CONFIG_DIR",
18
+ "NODE_OPTIONS",
19
+ "PI_CODING_AGENT_DIR",
20
+ "PI_CODING_AGENT_SESSION_DIR",
21
+ "PI_PACKAGE_DIR",
22
+ "PI_OFFLINE",
23
+ "PI_TELEMETRY",
24
+ ]);
25
+ export function assertRuntimeEnvironmentKey(key) {
26
+ if (!variableNamePattern.test(key)) {
27
+ throw new Error(`Invalid runtime environment variable name: ${key}`);
28
+ }
29
+ if (reservedVariableNames.has(key) ||
30
+ key.startsWith("BEACON_") ||
31
+ key.startsWith("FEISHU_")) {
32
+ throw new Error(`Runtime environment variable is reserved: ${key}`);
33
+ }
34
+ }
35
+ function parseRuntimeEnvironment(raw, path) {
36
+ const environment = {};
37
+ const lines = raw.replace(/^\uFEFF/, "").split(/\r?\n/);
38
+ for (const [index, line] of lines.entries()) {
39
+ const trimmed = line.trim();
40
+ if (!trimmed || trimmed.startsWith("#"))
41
+ continue;
42
+ const separator = line.indexOf("=");
43
+ if (separator < 0) {
44
+ throw new Error(`Invalid runtime environment entry at ${path}:${index + 1}: expected KEY=VALUE`);
45
+ }
46
+ const key = line.slice(0, separator).trim();
47
+ assertRuntimeEnvironmentKey(key);
48
+ if (Object.hasOwn(environment, key)) {
49
+ throw new Error(`Duplicate runtime environment variable at ${path}:${index + 1}: ${key}`);
50
+ }
51
+ environment[key] = line.slice(separator + 1).trim();
52
+ }
53
+ return environment;
54
+ }
55
+ export async function loadRuntimeEnvironment(path) {
56
+ let metadata;
57
+ try {
58
+ metadata = await stat(path);
59
+ }
60
+ catch (error) {
61
+ if (typeof error === "object" &&
62
+ error !== null &&
63
+ "code" in error &&
64
+ error.code === "ENOENT") {
65
+ return {};
66
+ }
67
+ throw new Error(`Cannot inspect Beacon runtime environment file at ${path}`, {
68
+ cause: error,
69
+ });
70
+ }
71
+ if (!metadata.isFile()) {
72
+ throw new Error(`Beacon runtime environment path is not a file: ${path}`);
73
+ }
74
+ if ((metadata.mode & 0o777) !== 0o600) {
75
+ throw new Error(`Beacon runtime environment file must have mode 0600: ${path}`);
76
+ }
77
+ if (typeof process.getuid === "function" &&
78
+ metadata.uid !== process.getuid()) {
79
+ throw new Error(`Beacon runtime environment file must be owned by the current user: ${path}`);
80
+ }
81
+ let raw;
82
+ try {
83
+ raw = await readFile(path, "utf8");
84
+ }
85
+ catch (error) {
86
+ throw new Error(`Cannot read Beacon runtime environment file at ${path}`, {
87
+ cause: error,
88
+ });
89
+ }
90
+ return parseRuntimeEnvironment(raw, path);
91
+ }
package/dist/doctor.js CHANGED
@@ -1,10 +1,12 @@
1
1
  import { loadGlobalConfig } from "./config/global.js";
2
2
  import { loadProfileRegistry } from "./config/registry.js";
3
+ import { loadRuntimeEnvironment } from "./config/runtime-environment.js";
3
4
  import { loadFeishuCredentials } from "./config/secrets.js";
4
5
  import { createFeishuGateway } from "./feishu/gateway.js";
5
6
  import { runPiAgent } from "./runtime/pi-rpc.js";
6
7
  export async function runDoctor(configPath) {
7
8
  const global = await loadGlobalConfig(configPath);
9
+ const runtimeEnvironment = await loadRuntimeEnvironment(global.runtimeEnvironmentPath);
8
10
  const profiles = await loadProfileRegistry(global.profilesDirectory);
9
11
  for (const profile of profiles) {
10
12
  const credentials = await loadFeishuCredentials(profile.id, global.secretsPath);
@@ -20,6 +22,7 @@ export async function runDoctor(configPath) {
20
22
  timeoutMs: global.runs.timeoutSeconds * 1_000,
21
23
  terminateGraceMs: global.runs.terminateGraceSeconds * 1_000,
22
24
  environment: { PI_CODING_AGENT_DIR: global.pi.codingAgentDirectory },
25
+ runtimeEnvironment,
23
26
  });
24
27
  if (result.text !== "BEACON_PI_RPC_OK") {
25
28
  throw new Error(`Pi readiness returned unexpected text for Profile ${profile.id}: ${JSON.stringify(result.text)}`);
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { loadGlobalConfig } from "./config/global.js";
3
3
  import { loadProfileRegistry } from "./config/registry.js";
4
+ import { loadRuntimeEnvironment } from "./config/runtime-environment.js";
4
5
  import { renderFinalOutcomeAsText } from "./outcome/content.js";
5
6
  import { startOutcomeServer } from "./outcome/server.js";
6
7
  import { createPiRunOrchestrator } from "./run/create-pi-orchestrator.js";
@@ -15,6 +16,7 @@ export async function runManualTrigger(configPath, profileId, input, output = wr
15
16
  if (!input.trim())
16
17
  throw new Error("Manual Trigger input must not be empty");
17
18
  const global = await loadGlobalConfig(configPath);
19
+ const runtimeEnvironment = await loadRuntimeEnvironment(global.runtimeEnvironmentPath);
18
20
  const profiles = await loadProfileRegistry(global.profilesDirectory);
19
21
  const profile = profiles.find((candidate) => candidate.id === profileId);
20
22
  if (!profile)
@@ -38,6 +40,7 @@ export async function runManualTrigger(configPath, profileId, input, output = wr
38
40
  };
39
41
  const orchestrator = createPiRunOrchestrator({
40
42
  config: global,
43
+ runtimeEnvironment,
41
44
  profile,
42
45
  store,
43
46
  queue: new RunQueue(global.runs.maxConcurrent, global.runs.maxQueued),
@@ -16,6 +16,7 @@ export function createPiRunOrchestrator(options) {
16
16
  environment: {
17
17
  PI_CODING_AGENT_DIR: options.config.pi.codingAgentDirectory,
18
18
  },
19
+ runtimeEnvironment: options.runtimeEnvironment,
19
20
  }),
20
21
  delivery: options.delivery,
21
22
  });
@@ -3,6 +3,7 @@ import { chmod, mkdir } from "node:fs/promises";
3
3
  import { isAbsolute } from "node:path";
4
4
  import { createInterface } from "node:readline";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { assertRuntimeEnvironmentKey } from "../config/runtime-environment.js";
6
7
  export class PiRuntimeError extends Error {
7
8
  code;
8
9
  constructor(code, message, options) {
@@ -86,13 +87,18 @@ const inheritedEnvironmentKeys = [
86
87
  "PI_OFFLINE",
87
88
  "PI_TELEMETRY",
88
89
  ];
89
- function buildPiEnvironment(outcome, configured = {}) {
90
+ function buildPiEnvironment(outcome, configured = {}, runtimeEnvironment = {}) {
90
91
  const environment = {};
91
92
  for (const key of inheritedEnvironmentKeys) {
92
93
  const value = process.env[key];
93
94
  if (value !== undefined)
94
95
  environment[key] = value;
95
96
  }
97
+ for (const [key, value] of Object.entries(runtimeEnvironment)) {
98
+ assertRuntimeEnvironmentKey(key);
99
+ if (value !== undefined)
100
+ environment[key] = value;
101
+ }
96
102
  for (const [key, value] of Object.entries(configured)) {
97
103
  if (!inheritedEnvironmentKeys.includes(key)) {
98
104
  throw new Error(`Pi environment key is not allowlisted: ${key}`);
@@ -167,7 +173,7 @@ export async function runPiAgent(request, options = {}) {
167
173
  }
168
174
  const child = spawn(executable, buildArguments(request), {
169
175
  cwd: request.workspace,
170
- env: buildPiEnvironment(request.outcome, options.environment),
176
+ env: buildPiEnvironment(request.outcome, options.environment, options.runtimeEnvironment),
171
177
  stdio: ["pipe", "pipe", "pipe"],
172
178
  });
173
179
  const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { loadGlobalConfig } from "./config/global.js";
3
3
  import { loadProfileRegistry } from "./config/registry.js";
4
+ import { loadRuntimeEnvironment } from "./config/runtime-environment.js";
4
5
  import { loadFeishuCredentials } from "./config/secrets.js";
5
6
  import { createFeishuGateway } from "./feishu/gateway.js";
6
7
  import { startOutcomeServer } from "./outcome/server.js";
@@ -50,6 +51,7 @@ export async function triggerScheduleOnce(options) {
50
51
  }
51
52
  export async function runScheduleTrigger(configPath, profileId, scheduleId) {
52
53
  const global = await loadGlobalConfig(configPath);
54
+ const runtimeEnvironment = await loadRuntimeEnvironment(global.runtimeEnvironmentPath);
53
55
  const profiles = await loadProfileRegistry(global.profilesDirectory);
54
56
  const profile = profiles.find((candidate) => candidate.id === profileId);
55
57
  if (!profile)
@@ -61,6 +63,7 @@ export async function runScheduleTrigger(configPath, profileId, scheduleId) {
61
63
  const store = new TriggerStore(profile.directory, profile.id);
62
64
  const orchestrator = createPiRunOrchestrator({
63
65
  config: global,
66
+ runtimeEnvironment,
64
67
  profile,
65
68
  store,
66
69
  queue: new RunQueue(global.runs.maxConcurrent, global.runs.maxQueued),
package/dist/service.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { loadGlobalConfig } from "./config/global.js";
2
2
  import { loadProfileRegistry } from "./config/registry.js";
3
+ import { loadRuntimeEnvironment } from "./config/runtime-environment.js";
3
4
  import { loadFeishuCredentials } from "./config/secrets.js";
4
5
  import { createFeishuGateway } from "./feishu/gateway.js";
5
6
  import { createFeishuMessagePipeline } from "./feishu/message-pipeline.js";
@@ -29,6 +30,7 @@ function shutdownController() {
29
30
  }
30
31
  export async function runBeacon(configPath) {
31
32
  const global = await loadGlobalConfig(configPath);
33
+ const runtimeEnvironment = await loadRuntimeEnvironment(global.runtimeEnvironmentPath);
32
34
  const profiles = await loadProfileRegistry(global.profilesDirectory);
33
35
  const credentials = await Promise.all(profiles.map((profile) => loadFeishuCredentials(profile.id, global.secretsPath)));
34
36
  const outcomes = await startOutcomeServer();
@@ -46,6 +48,7 @@ export async function runBeacon(configPath) {
46
48
  const store = new TriggerStore(profile.directory, profile.id);
47
49
  const orchestrator = createPiRunOrchestrator({
48
50
  config: global,
51
+ runtimeEnvironment,
49
52
  profile,
50
53
  store,
51
54
  queue,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nettee/beacon",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "A single-host bridge from messaging and schedules to isolated agent runs",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {