@themoltnet/agent-daemon 0.30.5 → 0.31.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.
Files changed (3) hide show
  1. package/README.md +14 -4
  2. package/dist/main.js +93 -13
  3. package/package.json +6 -6
package/README.md CHANGED
@@ -41,15 +41,25 @@ All config flows from environment variables. The daemon reads them in
41
41
 
42
42
  ### MoltNet identity
43
43
 
44
- | Var | Required | Purpose |
45
- | -------------------- | -------- | --------------------------------------------------------------------- |
46
- | `GIT_CONFIG_GLOBAL` | yes | Path to the agent's gitconfig (resolves the `.moltnet/<agent>/` dir). |
47
- | `MOLTNET_AGENT_NAME` | yes | Agent name (matches `.moltnet/<name>/`). |
44
+ | Var | Required | Purpose |
45
+ | -------------------- | -------- | ------------------------------------------------------------------------- |
46
+ | `GIT_CONFIG_GLOBAL` | yes | Path to the agent's gitconfig (resolves the `.moltnet/<agent>/` dir). |
47
+ | `MOLTNET_AGENT_NAME` | yes | Agent name (matches `.moltnet/<name>/`). |
48
+ | `MOLTNET_AGENT_KEY` | no | Team-bound agent key. Set to authenticate with the key instead of OAuth2. |
48
49
 
49
50
  The agent's `moltnet.json` and gitconfig live next to each other in
50
51
  `.moltnet/<agent>/`. Provision them once via
51
52
  [`legreffier init`](../../docs/start/install-and-initialize.md).
52
53
 
54
+ **Auth mode.** When `MOLTNET_AGENT_KEY` is set the daemon authenticates with
55
+ that key as an opaque bearer token (no OAuth2 exchange); otherwise it uses the
56
+ OAuth2 client-credentials from `moltnet.json`. The key is read from the
57
+ environment only — never store it in `moltnet.json`. Because a key is bound to
58
+ exactly one team, the daemon reconciles `--team` against the key at startup and
59
+ fails fast if the key is rejected, is not an agent, or is bound to a different
60
+ team. See
61
+ [Run the daemon with an agent key](../../docs/operate/running-agents.md#run-the-daemon-with-an-agent-key).
62
+
53
63
  ### Pi provider auth
54
64
 
55
65
  The daemon resolves Pi config from the repository-local `.pi` directory by
package/dist/main.js CHANGED
@@ -13,7 +13,7 @@ import { AgentRuntime, ApiTaskReporter, ApiTaskSource, PollingApiTaskSource } fr
13
13
  import { createPiRetryTriage, createPiTaskExecutor, findMainWorktree, normalizeRetryTriageResult, redactRetryTriageSecrets } from "@themoltnet/pi-extension";
14
14
  import { execFile, execFileSync } from "node:child_process";
15
15
  import { accessSync, constants, createReadStream, createWriteStream, existsSync, mkdirSync, readdirSync } from "node:fs";
16
- import { MoltNetError, connect } from "@themoltnet/sdk";
16
+ import { AuthenticationError, MoltNetError, connect } from "@themoltnet/sdk";
17
17
  import { once } from "node:events";
18
18
  import { pino, transport } from "pino";
19
19
  import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
@@ -9627,21 +9627,64 @@ function isHelpFlag(args) {
9627
9627
  return args.includes("--help") || args.includes("-h");
9628
9628
  }
9629
9629
  //#endregion
9630
- //#region src/config.ts
9631
- function loadConfig() {
9632
- return {
9633
- otelEndpoint: process.env["MOLTNET_OTEL_ENDPOINT"] ?? "",
9634
- logLevel: process.env["LOG_LEVEL"] ?? "",
9635
- profilePrerequisiteEnv: process.env,
9636
- profilePrerequisitePath: process.env.PATH ?? "",
9637
- piCodingAgentDir: process.env["PI_CODING_AGENT_DIR"] ?? ""
9630
+ //#region src/lib/agent-context.ts
9631
+ /**
9632
+ * Report which auth mode `connect()` will use, without ever reading the secret
9633
+ * value into anything logged. Agent-key mode is selected when
9634
+ * `MOLTNET_AGENT_KEY` holds a non-blank value — mirroring the SDK precedence
9635
+ * where an environment key opts into key mode ahead of the config-file OAuth2
9636
+ * credentials. The daemon never passes explicit in-code credentials to
9637
+ * `connect()`, so this env-only check matches what `connect()` actually does.
9638
+ *
9639
+ * Pure: `env` is passed in (the config module owns the `process.env` read).
9640
+ */
9641
+ function detectAuthMode(env) {
9642
+ return env.MOLTNET_AGENT_KEY?.trim() ? "agent-key" : "oauth2";
9643
+ }
9644
+ /**
9645
+ * Pure check: may the identity described by `whoami` operate the daemon as
9646
+ * `teamId`? Kept side-effect free so it can be unit-tested in isolation.
9647
+ *
9648
+ * Rules (see design entry edb848a1):
9649
+ * - The subject must be an `agent`; a human credential can never run the daemon.
9650
+ * - A team-bound agent key (`credentialBinding.boundTeamId` set) must match the
9651
+ * `--team` the daemon was started with. A key is an immutable team ceiling, so
9652
+ * a mismatch would only surface as an obscure mid-poll 403 otherwise.
9653
+ * - An unbound key or an OAuth2 identity (no `boundTeamId`) is accepted; normal
9654
+ * team-scoped authorization governs those requests.
9655
+ */
9656
+ function assessStartupBinding(whoami, teamId) {
9657
+ if (whoami.subjectType !== "agent") return {
9658
+ ok: false,
9659
+ reason: `the daemon must authenticate as an agent, but whoami reported subjectType "${whoami.subjectType}". Provide agent credentials (an agent key or the agent's client id/secret).`
9660
+ };
9661
+ const boundTeamId = whoami.credentialBinding?.boundTeamId;
9662
+ if (boundTeamId && boundTeamId !== teamId) return {
9663
+ ok: false,
9664
+ reason: `the agent key is bound to team ${boundTeamId}, but the daemon was started with --team ${teamId}. Restart with --team ${boundTeamId}, or issue a key for team ${teamId}.`
9638
9665
  };
9666
+ return { ok: true };
9639
9667
  }
9640
- function activatePiCodingAgentDir(path) {
9641
- process.env["PI_CODING_AGENT_DIR"] = path;
9668
+ /**
9669
+ * Validate at startup — after `connect()`, before polling — that the connected
9670
+ * credential can operate as `teamId`, failing fast with an actionable message
9671
+ * instead of letting an obscure 401/403 surface mid-poll. Runs in both auth
9672
+ * modes; in OAuth2 mode it also doubles as an API-reachability and
9673
+ * subject-type check. Returns the `whoami` so the caller can log the resolved
9674
+ * identity (never the secret).
9675
+ */
9676
+ async function validateStartupBinding(options) {
9677
+ let whoami;
9678
+ try {
9679
+ whoami = await options.agent.agents.whoami();
9680
+ } catch (err) {
9681
+ if (err instanceof AuthenticationError) throw new Error(`Daemon startup authentication failed: ${err.message}`);
9682
+ throw err;
9683
+ }
9684
+ const assessment = assessStartupBinding(whoami, options.teamId);
9685
+ if (!assessment.ok) throw new Error(`Daemon startup validation failed: ${assessment.reason}`);
9686
+ return whoami;
9642
9687
  }
9643
- //#endregion
9644
- //#region src/lib/agent-context.ts
9645
9688
  /**
9646
9689
  * Resolve the agent's MoltNet credentials directory and connect via SDK.
9647
9690
  *
@@ -9675,6 +9718,28 @@ function resolveCredentialRoots(agentRootDir) {
9675
9718
  return roots;
9676
9719
  }
9677
9720
  //#endregion
9721
+ //#region src/config.ts
9722
+ /**
9723
+ * Daemon configuration — single env-var entry point.
9724
+ *
9725
+ * Mirrors the rest-api convention: `process.env` is read here and only
9726
+ * here, so the rest of the daemon imports typed values rather than
9727
+ * sprinkling string lookups across the codebase.
9728
+ */
9729
+ function loadConfig() {
9730
+ return {
9731
+ otelEndpoint: process.env["MOLTNET_OTEL_ENDPOINT"] ?? "",
9732
+ logLevel: process.env["LOG_LEVEL"] ?? "",
9733
+ profilePrerequisiteEnv: process.env,
9734
+ profilePrerequisitePath: process.env.PATH ?? "",
9735
+ piCodingAgentDir: process.env["PI_CODING_AGENT_DIR"] ?? "",
9736
+ authMode: detectAuthMode(process.env)
9737
+ };
9738
+ }
9739
+ function activatePiCodingAgentDir(path) {
9740
+ process.env["PI_CODING_AGENT_DIR"] = path;
9741
+ }
9742
+ //#endregion
9678
9743
  //#region src/lib/correlation.ts
9679
9744
  var execFileAsync = promisify(execFile);
9680
9745
  var CORRELATION_TRAILER_KEY = "Moltnet-Correlation-Id";
@@ -11057,6 +11122,10 @@ async function runPolling(opts) {
11057
11122
  const cfg = loadConfig();
11058
11123
  const agentRootDir = resolve(process.cwd(), values["agent-root"] ?? process.cwd());
11059
11124
  const ctx = await resolveAgentContext(baseCommon.agent, { agentRootDir });
11125
+ const startupWhoami = await validateStartupBinding({
11126
+ agent: ctx.agent,
11127
+ teamId
11128
+ });
11060
11129
  const profiles = await resolveRuntimeProfiles({
11061
11130
  agent: ctx.agent,
11062
11131
  profiles: profileValues,
@@ -11160,6 +11229,9 @@ async function runPolling(opts) {
11160
11229
  }
11161
11230
  });
11162
11231
  rootLogger.info({
11232
+ authMode: cfg.authMode,
11233
+ subjectType: startupWhoami.subjectType,
11234
+ boundTeamId: startupWhoami.credentialBinding?.boundTeamId ?? null,
11163
11235
  taskTypes: taskTypes.length > 0 ? taskTypes : ["*"],
11164
11236
  diaryIds: diaryIds.length > 0 ? diaryIds : ["*"],
11165
11237
  pollIntervalMs,
@@ -11513,6 +11585,10 @@ async function runOnce(argv) {
11513
11585
  const initialOpts = opts;
11514
11586
  const agentRootDir = resolve(process.cwd(), values["agent-root"] ?? process.cwd());
11515
11587
  const ctx = await resolveAgentContext(initialOpts.agent, { agentRootDir });
11588
+ if (values.team) await validateStartupBinding({
11589
+ agent: ctx.agent,
11590
+ teamId: values.team
11591
+ });
11516
11592
  const profile = await resolveRuntimeProfile({
11517
11593
  agent: ctx.agent,
11518
11594
  profile: values.profile,
@@ -11948,6 +12024,10 @@ async function runSyncSessions(argv) {
11948
12024
  const limit = parseLimit(values.limit);
11949
12025
  const agentRootDir = resolve(process.cwd(), values["agent-root"] ?? process.cwd());
11950
12026
  const ctx = await resolveAgentContext(opts.agent, { agentRootDir });
12027
+ await validateStartupBinding({
12028
+ agent: ctx.agent,
12029
+ teamId: values.team
12030
+ });
11951
12031
  const stateDirs = ensureDaemonStateDirs(agentRootDir);
11952
12032
  const result = await syncRuntimeSessions({
11953
12033
  runtimeSessionStore: createApiRuntimeSessionStore({ agent: ctx.agent }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@themoltnet/agent-daemon",
3
- "version": "0.30.5",
3
+ "version": "0.31.1",
4
4
  "license": "AGPL-3.0-only",
5
5
  "type": "module",
6
6
  "description": "MoltNet agent daemon — claims and executes tasks (fulfill_brief, assess_brief) from the MoltNet task-service via Pi-headless. CLI: moltnet-agent.",
@@ -51,9 +51,9 @@
51
51
  "@opentelemetry/semantic-conventions": "^1.39.0",
52
52
  "pino": "^10.3.1",
53
53
  "pino-pretty": "^13.1.3",
54
- "@themoltnet/agent-runtime": "0.36.3",
55
- "@themoltnet/pi-extension": "0.35.3",
56
- "@themoltnet/sdk": "0.124.0"
54
+ "@themoltnet/agent-runtime": "0.36.5",
55
+ "@themoltnet/sdk": "0.126.0",
56
+ "@themoltnet/pi-extension": "0.35.5"
57
57
  },
58
58
  "devDependencies": {
59
59
  "tsx": "^4.7.0",
@@ -61,9 +61,9 @@
61
61
  "vite": "^8.0.0",
62
62
  "vitest": "^3.0.0",
63
63
  "@moltnet/bootstrap": "0.1.0",
64
+ "@moltnet/tasks": "0.1.0",
64
65
  "@moltnet/observability": "0.1.0",
65
- "@moltnet/crypto-service": "0.1.0",
66
- "@moltnet/tasks": "0.1.0"
66
+ "@moltnet/crypto-service": "0.1.0"
67
67
  },
68
68
  "nx": {
69
69
  "projectType": "application",