@xtrape/capsule-agent-node 0.1.0-public-review.0 → 0.2.0

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
@@ -46,6 +46,15 @@ const agent = new CapsuleAgent({
46
46
  backendUrl: process.env.OPSTAGE_BACKEND_URL ?? "http://localhost:8080",
47
47
  registrationToken: process.env.OPSTAGE_REGISTRATION_TOKEN,
48
48
  tokenStore: { file: "./data/agent-token.txt" },
49
+ // Optional. If omitted, the SDK derives agent identity from `service`.
50
+ // Provide it explicitly when one Agent owns multiple Capsule Services on
51
+ // the same host, or when you want a stable agent code that survives
52
+ // service renames.
53
+ agent: {
54
+ code: "hello-capsule-agent",
55
+ name: "Hello Capsule Agent",
56
+ runtime: "nodejs",
57
+ },
49
58
  service: {
50
59
  code: "hello-capsule",
51
60
  name: "Hello Capsule",
@@ -276,7 +285,8 @@ Main exports:
276
285
  - `CapsuleAgent`
277
286
  - `FileTokenStore`
278
287
  - `AgentApiClient`
279
- - `AgentApiError`
288
+ - `AgentApiError`, `RegistrationError`, `AgentAuthError`, `NetworkError`
289
+ - `AgentLogLevel`, `AgentLogRecord`, `StructuredLogger`, `ConsoleLogger` (types)
280
290
  - SDK option and provider types from `types.ts`
281
291
 
282
292
  Core methods:
@@ -291,6 +301,90 @@ Core methods:
291
301
  | `.stop()` | Stops background loops. |
292
302
  | `.runHealth()` | Runs the configured health provider. |
293
303
 
304
+ ### Typed errors
305
+
306
+ > Added in `0.2.0`. Pre-`0.2` code that catches `AgentApiError` still works
307
+ > because the new classes extend it.
308
+
309
+ The SDK surfaces four error classes so callers can branch on the failure
310
+ mode without parsing messages:
311
+
312
+ | Class | When it's thrown | Should the caller retry? |
313
+ | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ |
314
+ | `RegistrationError` | `/api/agents/register` rejects the one-time token (expired, revoked, already used, malformed). Needs operator action — mint a fresh registration token. | No. |
315
+ | `AgentAuthError` | Any non-register call returns 401/403. The agent token has been revoked or the agent disabled. Re-register before resuming. | No. |
316
+ | `NetworkError` | `fetch()` threw before getting an HTTP response (DNS failure, ECONNREFUSED, abort, timeout). `status === 0`; carries an optional `cause`. | Yes — SDK already does. |
317
+ | `AgentApiError` | Catch-all parent. Direct instances carry HTTP 4xx/5xx that don't fit the more specific cases (e.g. 409 conflict, 422 validation, 5xx server error). | Only on 5xx. |
318
+
319
+ The SDK's internal `retry()` loop already implements this policy: only
320
+ `NetworkError` and 5xx `AgentApiError` are retried with exponential backoff;
321
+ 4xx — including the two typed subclasses — surfaces immediately so the caller
322
+ isn't waiting on a backoff that can never succeed.
323
+
324
+ ```ts
325
+ import {
326
+ CapsuleAgent,
327
+ RegistrationError,
328
+ AgentAuthError,
329
+ NetworkError,
330
+ } from "@xtrape/capsule-agent-node";
331
+
332
+ try {
333
+ await agent.start();
334
+ } catch (err) {
335
+ if (err instanceof RegistrationError) {
336
+ console.error("registration token is no longer valid:", err.code);
337
+ process.exit(64);
338
+ }
339
+ if (err instanceof AgentAuthError) {
340
+ console.error("agent token rejected — was the agent revoked?");
341
+ process.exit(65);
342
+ }
343
+ if (err instanceof NetworkError) {
344
+ console.error("could not reach Opstage:", err.message);
345
+ process.exit(75);
346
+ }
347
+ throw err;
348
+ }
349
+ ```
350
+
351
+ ### Structured logger
352
+
353
+ > Added in `0.2.0`. Existing zero-config callers (no `logger` / no
354
+ > `structuredLogger`) keep using the global `console` exactly as before.
355
+
356
+ By default the SDK calls `console.debug` / `.info` / `.warn` / `.error`. For
357
+ JSON-line logging, pino, OpenTelemetry, or any in-house collector, pass a
358
+ `structuredLogger`:
359
+
360
+ ```ts
361
+ import { CapsuleAgent, type StructuredLogger } from "@xtrape/capsule-agent-node";
362
+
363
+ const collector: StructuredLogger = {
364
+ log({ level, message, fields }) {
365
+ process.stdout.write(JSON.stringify({ t: Date.now(), level, message, ...fields }) + "\n");
366
+ },
367
+ };
368
+
369
+ const agent = new CapsuleAgent({
370
+ // ...
371
+ structuredLogger: collector,
372
+ });
373
+ ```
374
+
375
+ Each record arrives as `{ level: "debug" | "info" | "warn" | "error",
376
+ message: string, fields?: Record<string, unknown> }`. The SDK normalizes the
377
+ optional context before emitting:
378
+
379
+ - `Error` instances become `{ error: { name, message, stack } }` so they
380
+ survive `JSON.stringify`.
381
+ - Primitives become `{ value: <primitive> }`.
382
+ - Plain objects pass through unchanged.
383
+
384
+ In all cases the redactor runs over `fields` first, so tokens and
385
+ secret-bearing keys never reach the collector. If both `structuredLogger`
386
+ and `logger` are configured, `structuredLogger` wins.
387
+
294
388
  ## Version Compatibility
295
389
 
296
390
  | Package | Compatible with |
@@ -300,6 +394,52 @@ Core methods:
300
394
  Use matching minor versions across CE, Agent SDK, and Contracts during Public
301
395
  Review and Public Preview. The wire protocol may still change before `v1.0`.
302
396
 
397
+ ## Troubleshooting
398
+
399
+ ### Registration fails with `401 / REGISTRATION_TOKEN_INVALID`
400
+
401
+ The token is single-use and short-lived.
402
+
403
+ - Check the token has not already been consumed by an earlier successful start
404
+ (registration tokens flip to `USED` on first use).
405
+ - Check the token has not expired. Operators set `expiresInSeconds` when
406
+ creating the token; the default in CE is short.
407
+ - Check the token has not been revoked from the Opstage console.
408
+ - Re-create a fresh token in the console and start the agent with it.
409
+
410
+ ### Agent reports `ECONNREFUSED` / cannot reach backend
411
+
412
+ - Confirm `OPSTAGE_BACKEND_URL` resolves from inside the container or host
413
+ where the agent runs. The default of `http://localhost:8080` only works if
414
+ the agent runs on the same host as Opstage.
415
+ - If you sit behind a reverse proxy, point `backendUrl` at the proxy URL —
416
+ not the internal Opstage container address.
417
+ - Outbound HTTPS through corporate proxies: respect `HTTPS_PROXY` /
418
+ `NO_PROXY` env vars (`undici` honors them via `setGlobalDispatcher`).
419
+
420
+ ### Agent token file is rejected on second start (`401 / UNAUTHORIZED`)
421
+
422
+ - File permissions: the agent process must be able to read
423
+ `tokenStore.file`. Run `chmod 600` (owner read/write) and ensure the
424
+ process owner matches.
425
+ - File contents: the SDK writes `<agentId>:<agentToken>` plaintext. If the
426
+ file exists but contains anything else, delete it and re-register with a
427
+ fresh registration token.
428
+ - Token revocation: an operator may have revoked the agent or the agent
429
+ token from the console. Inspect the agent's status in Opstage; if it shows
430
+ `REVOKED` or `DISABLED`, that's the cause.
431
+
432
+ ### Heartbeats succeed but action results never arrive
433
+
434
+ - Confirm the action `name` registered with `agent.action({ name: "..." })`
435
+ matches the `actionName` the backend dispatches in the command. The SDK
436
+ reports `ACTION_HANDLER_NOT_FOUND` if they differ.
437
+ - Confirm `start()` is awaited and not called repeatedly — multiple
438
+ `CapsuleAgent` instances on the same host with the same agent code will
439
+ fight over heartbeats and command polls.
440
+ - Check `commandPollMs` / `commandPollIntervalSeconds` is not absurdly
441
+ large; default is 5s.
442
+
303
443
  ## Documentation
304
444
 
305
445
  - Site: https://xtrape-com.github.io/xtrape-capsule-site/
package/dist/index.cjs CHANGED
@@ -22,21 +22,53 @@ var index_exports = {};
22
22
  __export(index_exports, {
23
23
  AgentApiClient: () => AgentApiClient,
24
24
  AgentApiError: () => AgentApiError,
25
+ AgentAuthError: () => AgentAuthError,
25
26
  CapsuleAgent: () => CapsuleAgent,
26
- FileTokenStore: () => FileTokenStore
27
+ FileTokenStore: () => FileTokenStore,
28
+ NetworkError: () => NetworkError,
29
+ RegistrationError: () => RegistrationError
27
30
  });
28
31
  module.exports = __toCommonJS(index_exports);
29
32
 
30
33
  // src/client/errors.ts
31
34
  var AgentApiError = class extends Error {
32
- constructor(status, message, body) {
35
+ constructor(status, message, body, code) {
33
36
  super(message);
34
37
  this.status = status;
35
38
  this.body = body;
39
+ this.code = code;
40
+ this.name = "AgentApiError";
36
41
  }
37
42
  status;
38
43
  body;
44
+ code;
39
45
  };
46
+ var RegistrationError = class extends AgentApiError {
47
+ constructor(message, status, body, code) {
48
+ super(status, message, body, code);
49
+ this.name = "RegistrationError";
50
+ }
51
+ };
52
+ var AgentAuthError = class extends AgentApiError {
53
+ constructor(message, status, body, code) {
54
+ super(status, message, body, code);
55
+ this.name = "AgentAuthError";
56
+ }
57
+ };
58
+ var NetworkError = class extends AgentApiError {
59
+ constructor(message, cause) {
60
+ super(0, message, void 0, "NETWORK_ERROR");
61
+ this.name = "NetworkError";
62
+ if (cause !== void 0) {
63
+ this.cause = cause;
64
+ }
65
+ }
66
+ };
67
+ function classifyAgentApiError(status, message, body, code, path) {
68
+ if (status === 401 && path.endsWith("/register")) return new RegistrationError(message, status, body, code);
69
+ if (status === 401 || status === 403) return new AgentAuthError(message, status, body, code);
70
+ return new AgentApiError(status, message, body, code);
71
+ }
40
72
 
41
73
  // src/client/agent-api-client.ts
42
74
  var AgentApiClient = class {
@@ -51,10 +83,24 @@ var AgentApiClient = class {
51
83
  const headers = new Headers(init.headers);
52
84
  headers.set("content-type", "application/json");
53
85
  if (init.token) headers.set("authorization", `Bearer ${init.token}`);
54
- const res = await fetch(this.url(path), { ...init, headers });
86
+ let res;
87
+ try {
88
+ res = await fetch(this.url(path), { ...init, headers });
89
+ } catch (cause) {
90
+ const reason = cause instanceof Error ? cause.message : String(cause);
91
+ throw new NetworkError(`Failed to reach ${path}: ${reason}`, cause);
92
+ }
55
93
  const text = await res.text();
56
- const body = text ? JSON.parse(text) : void 0;
57
- if (!res.ok) throw new AgentApiError(res.status, body?.error?.message ?? res.statusText, body);
94
+ let body;
95
+ try {
96
+ body = text ? JSON.parse(text) : void 0;
97
+ } catch {
98
+ body = { error: { message: res.statusText } };
99
+ }
100
+ if (!res.ok) {
101
+ const err = body?.error;
102
+ throw classifyAgentApiError(res.status, err?.message ?? res.statusText, body, err?.code, path);
103
+ }
58
104
  return body?.data ?? body;
59
105
  }
60
106
  register(req) {
@@ -78,13 +124,17 @@ var AgentApiClient = class {
78
124
  var import_promises = require("fs/promises");
79
125
  var import_node_path = require("path");
80
126
  var FileTokenStore = class {
81
- constructor(filePath = "./data/agent-token.json") {
127
+ constructor(filePath = "./data/agent-token.txt") {
82
128
  this.filePath = filePath;
83
129
  }
84
130
  filePath;
85
131
  async load() {
86
132
  try {
87
- const parsed = JSON.parse(await (0, import_promises.readFile)(this.filePath, "utf8"));
133
+ const raw = await (0, import_promises.readFile)(this.filePath, "utf8");
134
+ const trimmed = raw.trim();
135
+ if (!trimmed) return null;
136
+ if (!trimmed.startsWith("{")) return trimmed;
137
+ const parsed = JSON.parse(trimmed);
88
138
  if (typeof parsed.agentId === "string" && typeof parsed.agentToken === "string") return `${parsed.agentId}:${parsed.agentToken}`;
89
139
  return typeof parsed.agentToken === "string" ? parsed.agentToken : null;
90
140
  } catch (e) {
@@ -93,9 +143,8 @@ var FileTokenStore = class {
93
143
  }
94
144
  }
95
145
  async save(token) {
96
- const [agentId, agentToken] = token.includes(":") ? token.split(":", 2) : [void 0, token];
97
146
  await (0, import_promises.mkdir)((0, import_node_path.dirname)(this.filePath), { recursive: true });
98
- await (0, import_promises.writeFile)(this.filePath, JSON.stringify({ ...agentId ? { agentId } : {}, agentToken, savedAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2), { mode: 384 });
147
+ await (0, import_promises.writeFile)(this.filePath, token, { mode: 384 });
99
148
  try {
100
149
  await (0, import_promises.chmod)(this.filePath, 384);
101
150
  } catch {
@@ -107,26 +156,47 @@ var FileTokenStore = class {
107
156
  };
108
157
 
109
158
  // src/security/redaction.ts
159
+ var SENSITIVE_KEY = /token|secret|password|cookie|authorization|api[-_]?key\b|generatedKey|rawKey|plainTextKey/i;
160
+ var TOKEN_PATTERN = /opstage_[a-z]+_[A-Za-z0-9_-]+/g;
110
161
  function redact(value) {
111
- if (typeof value === "string") return value.replace(/opstage_(reg|agent)_[A-Za-z0-9_-]+/g, "opstage_$1_[REDACTED]");
162
+ if (typeof value === "string") {
163
+ return value.replace(TOKEN_PATTERN, (match) => {
164
+ const prefixEnd = match.lastIndexOf("_");
165
+ return `${match.slice(0, prefixEnd + 1)}[REDACTED]`;
166
+ });
167
+ }
112
168
  if (!value || typeof value !== "object") return value;
113
169
  if (Array.isArray(value)) return value.map(redact);
114
- return Object.fromEntries(Object.entries(value).map(([k, v]) => [/token|secret|password|authorization/i.test(k) ? [k, "[REDACTED]"] : [k, redact(v)]]));
170
+ return Object.fromEntries(
171
+ Object.entries(value).map(
172
+ ([k, v]) => SENSITIVE_KEY.test(k) ? [k, "[REDACTED]"] : [k, redact(v)]
173
+ )
174
+ );
115
175
  }
116
176
 
117
177
  // src/capsule-agent.ts
118
178
  var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
179
+ function emitLog(consoleSink, structured, level, message, fields) {
180
+ if (structured) {
181
+ structured.log({ level, message, fields });
182
+ return;
183
+ }
184
+ if (fields === void 0) consoleSink[level](message);
185
+ else consoleSink[level](message, fields);
186
+ }
119
187
  var CapsuleAgent = class {
120
188
  constructor(options) {
121
189
  this.options = options;
122
190
  this.client = new AgentApiClient(options.backendUrl);
123
191
  this.store = new FileTokenStore(options.tokenStore?.file);
124
192
  this.logger = options.logger ?? console;
193
+ this.structuredLogger = options.structuredLogger;
125
194
  }
126
195
  options;
127
196
  client;
128
197
  store;
129
198
  logger;
199
+ structuredLogger;
130
200
  agentId;
131
201
  token;
132
202
  stopped = true;
@@ -274,20 +344,36 @@ var CapsuleAgent = class {
274
344
  return await fn();
275
345
  } catch (e) {
276
346
  last = e;
277
- if (e instanceof AgentApiError && e.status < 500) throw e;
347
+ if (e instanceof NetworkError) {
348
+ } else if (e instanceof AgentApiError && e.status >= 500) {
349
+ } else {
350
+ throw e;
351
+ }
278
352
  await sleep(Math.min(1e3 * 2 ** i, 15e3));
279
353
  }
280
354
  }
281
355
  throw last;
282
356
  }
283
357
  log(level, msg, obj) {
284
- this.logger[level](msg, obj ? redact(obj) : void 0);
358
+ const fields = obj === void 0 ? void 0 : this.normalizeFields(obj);
359
+ const redacted = fields ? redact(fields) : void 0;
360
+ emitLog(this.logger, this.structuredLogger, level, msg, redacted);
361
+ }
362
+ normalizeFields(obj) {
363
+ if (obj instanceof Error) {
364
+ return { error: { message: obj.message, name: obj.name, stack: obj.stack } };
365
+ }
366
+ if (typeof obj === "object" && obj !== null) return obj;
367
+ return { value: obj };
285
368
  }
286
369
  };
287
370
  // Annotate the CommonJS export names for ESM import in node:
288
371
  0 && (module.exports = {
289
372
  AgentApiClient,
290
373
  AgentApiError,
374
+ AgentAuthError,
291
375
  CapsuleAgent,
292
- FileTokenStore
376
+ FileTokenStore,
377
+ NetworkError,
378
+ RegistrationError
293
379
  });
package/dist/index.d.cts CHANGED
@@ -1,5 +1,12 @@
1
1
  import { HealthReportInput, ConfigItemInput, ActionDefinitionInput, ReportedService, RegisterAgentRequest, AgentHeartbeatRequest, ServiceReportRequest, ReportCommandResultRequest } from '@xtrape/capsule-contracts-node';
2
2
 
3
+ type RuntimeKind = "nodejs" | "java" | "python" | "go" | "other";
4
+ type ActionPrepareResult = {
5
+ action?: Record<string, unknown>;
6
+ inputSchema?: Record<string, unknown>;
7
+ initialPayload?: Record<string, unknown>;
8
+ currentState?: Record<string, unknown>;
9
+ };
3
10
  type AgentMode = "embedded";
4
11
  type ActionHandler = (payload: Record<string, unknown>) => Promise<{
5
12
  success?: boolean;
@@ -7,15 +14,37 @@ type ActionHandler = (payload: Record<string, unknown>) => Promise<{
7
14
  data?: Record<string, unknown>;
8
15
  error?: Record<string, unknown>;
9
16
  } | Record<string, unknown>>;
10
- type ActionPrepareHandler = () => Promise<{
11
- action?: Record<string, unknown>;
12
- initialPayload?: Record<string, unknown>;
13
- currentState?: Record<string, unknown>;
14
- } | Record<string, unknown>> | {
15
- action?: Record<string, unknown>;
16
- initialPayload?: Record<string, unknown>;
17
- currentState?: Record<string, unknown>;
18
- } | Record<string, unknown>;
17
+ type ActionPrepareHandler = () => Promise<ActionPrepareResult | Record<string, unknown>> | ActionPrepareResult | Record<string, unknown>;
18
+ /**
19
+ * Log level emitted by the SDK. Aligns with the four standard `Console`
20
+ * methods so a plain `console` works as a logger with no adapter.
21
+ */
22
+ type AgentLogLevel = "debug" | "info" | "warn" | "error";
23
+ /**
24
+ * Structured log record passed to a `StructuredLogger`. The SDK populates
25
+ * `level` and `message`; `fields` carries an already-redacted Record of
26
+ * runtime context (agent id, error code, retry count, etc.).
27
+ */
28
+ interface AgentLogRecord {
29
+ level: AgentLogLevel;
30
+ message: string;
31
+ fields?: Record<string, unknown>;
32
+ }
33
+ /**
34
+ * A structured log sink. Useful when the host wants to forward SDK events
35
+ * into a JSON-line logger, pino, OpenTelemetry, or a custom collector
36
+ * without losing the per-field context to `console.warn`'s positional
37
+ * argument list.
38
+ */
39
+ interface StructuredLogger {
40
+ log(record: AgentLogRecord): void;
41
+ }
42
+ /**
43
+ * Console-shaped logger (subset of Node's `Console`). The SDK defaults to
44
+ * the global `console` when neither option is set; if both are provided,
45
+ * `structuredLogger` wins.
46
+ */
47
+ type ConsoleLogger = Pick<Console, "debug" | "info" | "warn" | "error">;
19
48
  interface CapsuleAgentOptions {
20
49
  backendUrl: string;
21
50
  registrationToken?: string;
@@ -25,14 +54,14 @@ interface CapsuleAgentOptions {
25
54
  agent?: {
26
55
  code: string;
27
56
  name?: string;
28
- runtime?: string;
57
+ runtime?: RuntimeKind;
29
58
  };
30
59
  service: {
31
60
  code: string;
32
61
  name: string;
33
62
  description?: string;
34
63
  version: string;
35
- runtime: "nodejs" | "java" | "python" | "go" | "other";
64
+ runtime: RuntimeKind;
36
65
  manifest?: Record<string, unknown>;
37
66
  };
38
67
  intervals?: {
@@ -43,7 +72,14 @@ interface CapsuleAgentOptions {
43
72
  heartbeatIntervalSeconds?: number;
44
73
  commandPollIntervalSeconds?: number;
45
74
  autoStartLoops?: boolean;
46
- logger?: Pick<Console, "debug" | "info" | "warn" | "error">;
75
+ /** Console-shaped sink; default is the global `console`. */
76
+ logger?: ConsoleLogger;
77
+ /**
78
+ * Structured sink. If provided, takes precedence over `logger`. The SDK
79
+ * still applies its own redaction before calling `log()`, so the host
80
+ * can route records as-is to a JSON stream.
81
+ */
82
+ structuredLogger?: StructuredLogger;
47
83
  failOnStartError?: boolean;
48
84
  }
49
85
  type HealthProvider = () => Promise<HealthReportInput> | HealthReportInput;
@@ -64,6 +100,7 @@ declare class CapsuleAgent {
64
100
  private readonly client;
65
101
  private readonly store;
66
102
  private readonly logger;
103
+ private readonly structuredLogger?;
67
104
  private agentId?;
68
105
  private token?;
69
106
  private stopped;
@@ -90,6 +127,7 @@ declare class CapsuleAgent {
90
127
  private withTimeout;
91
128
  private retry;
92
129
  private log;
130
+ private normalizeFields;
93
131
  }
94
132
 
95
133
  declare class FileTokenStore implements TokenStore {
@@ -100,6 +138,47 @@ declare class FileTokenStore implements TokenStore {
100
138
  clear(): Promise<void>;
101
139
  }
102
140
 
141
+ /**
142
+ * Base class for any failure that crosses the Agent ↔ Backend wire. Use
143
+ * `instanceof AgentApiError` to catch the family, and the more specific
144
+ * subclasses below when the caller wants to branch on the failure mode.
145
+ *
146
+ * `status === 0` is reserved for transport failures that never reached the
147
+ * backend (DNS failure, ECONNREFUSED, network timeout, etc.); use
148
+ * `NetworkError` for those.
149
+ */
150
+ declare class AgentApiError extends Error {
151
+ readonly status: number;
152
+ readonly body?: unknown | undefined;
153
+ readonly code?: string | undefined;
154
+ constructor(status: number, message: string, body?: unknown | undefined, code?: string | undefined);
155
+ }
156
+ /**
157
+ * The backend rejected the registration token (expired, revoked, already
158
+ * used, malformed). The agent cannot proceed without operator action: a
159
+ * fresh registration token must be created in Opstage.
160
+ */
161
+ declare class RegistrationError extends AgentApiError {
162
+ constructor(message: string, status: number, body?: unknown, code?: string);
163
+ }
164
+ /**
165
+ * The backend rejected the persisted agent token (UNAUTHORIZED, agent
166
+ * REVOKED, agent DISABLED). The agent must be re-registered with a fresh
167
+ * registration token before it can resume.
168
+ */
169
+ declare class AgentAuthError extends AgentApiError {
170
+ constructor(message: string, status: number, body?: unknown, code?: string);
171
+ }
172
+ /**
173
+ * Transport-level failure: the request did not reach the backend, or the
174
+ * backend's response was not a parseable HTTP envelope. The SDK retries
175
+ * these with exponential backoff; surface to the caller only after the
176
+ * retry budget is exhausted.
177
+ */
178
+ declare class NetworkError extends AgentApiError {
179
+ constructor(message: string, cause?: unknown);
180
+ }
181
+
103
182
  declare class AgentApiClient {
104
183
  private readonly backendUrl;
105
184
  constructor(backendUrl: string);
@@ -114,7 +193,7 @@ declare class AgentApiClient {
114
193
  heartbeat(agentId: string, token: string, req: AgentHeartbeatRequest): Promise<unknown>;
115
194
  reportServices(agentId: string, token: string, req: ServiceReportRequest): Promise<unknown>;
116
195
  pollCommands(agentId: string, token: string): Promise<{
117
- type: "ACTION_PREPARE" | "ACTION_EXECUTE" | "ACTION";
196
+ type: "ACTION_PREPARE" | "ACTION_EXECUTE";
118
197
  id: string;
119
198
  createdAt: string;
120
199
  status: "PENDING" | "RUNNING" | "SUCCEEDED" | "FAILED" | "EXPIRED" | "CANCELLED";
@@ -130,10 +209,4 @@ declare class AgentApiClient {
130
209
  reportResult(agentId: string, token: string, commandId: string, req: ReportCommandResultRequest): Promise<unknown>;
131
210
  }
132
211
 
133
- declare class AgentApiError extends Error {
134
- readonly status: number;
135
- readonly body?: unknown | undefined;
136
- constructor(status: number, message: string, body?: unknown | undefined);
137
- }
138
-
139
- export { type ActionHandler, type ActionPrepareHandler, AgentApiClient, AgentApiError, type AgentMode, CapsuleAgent, type CapsuleAgentOptions, type ConfigProvider, FileTokenStore, type HealthProvider, type RegisteredAction, type ServiceSnapshot, type TokenStore };
212
+ export { type ActionHandler, type ActionPrepareHandler, type ActionPrepareResult, AgentApiClient, AgentApiError, AgentAuthError, type AgentLogLevel, type AgentLogRecord, type AgentMode, CapsuleAgent, type CapsuleAgentOptions, type ConfigProvider, type ConsoleLogger, FileTokenStore, type HealthProvider, NetworkError, type RegisteredAction, RegistrationError, type RuntimeKind, type ServiceSnapshot, type StructuredLogger, type TokenStore };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,12 @@
1
1
  import { HealthReportInput, ConfigItemInput, ActionDefinitionInput, ReportedService, RegisterAgentRequest, AgentHeartbeatRequest, ServiceReportRequest, ReportCommandResultRequest } from '@xtrape/capsule-contracts-node';
2
2
 
3
+ type RuntimeKind = "nodejs" | "java" | "python" | "go" | "other";
4
+ type ActionPrepareResult = {
5
+ action?: Record<string, unknown>;
6
+ inputSchema?: Record<string, unknown>;
7
+ initialPayload?: Record<string, unknown>;
8
+ currentState?: Record<string, unknown>;
9
+ };
3
10
  type AgentMode = "embedded";
4
11
  type ActionHandler = (payload: Record<string, unknown>) => Promise<{
5
12
  success?: boolean;
@@ -7,15 +14,37 @@ type ActionHandler = (payload: Record<string, unknown>) => Promise<{
7
14
  data?: Record<string, unknown>;
8
15
  error?: Record<string, unknown>;
9
16
  } | Record<string, unknown>>;
10
- type ActionPrepareHandler = () => Promise<{
11
- action?: Record<string, unknown>;
12
- initialPayload?: Record<string, unknown>;
13
- currentState?: Record<string, unknown>;
14
- } | Record<string, unknown>> | {
15
- action?: Record<string, unknown>;
16
- initialPayload?: Record<string, unknown>;
17
- currentState?: Record<string, unknown>;
18
- } | Record<string, unknown>;
17
+ type ActionPrepareHandler = () => Promise<ActionPrepareResult | Record<string, unknown>> | ActionPrepareResult | Record<string, unknown>;
18
+ /**
19
+ * Log level emitted by the SDK. Aligns with the four standard `Console`
20
+ * methods so a plain `console` works as a logger with no adapter.
21
+ */
22
+ type AgentLogLevel = "debug" | "info" | "warn" | "error";
23
+ /**
24
+ * Structured log record passed to a `StructuredLogger`. The SDK populates
25
+ * `level` and `message`; `fields` carries an already-redacted Record of
26
+ * runtime context (agent id, error code, retry count, etc.).
27
+ */
28
+ interface AgentLogRecord {
29
+ level: AgentLogLevel;
30
+ message: string;
31
+ fields?: Record<string, unknown>;
32
+ }
33
+ /**
34
+ * A structured log sink. Useful when the host wants to forward SDK events
35
+ * into a JSON-line logger, pino, OpenTelemetry, or a custom collector
36
+ * without losing the per-field context to `console.warn`'s positional
37
+ * argument list.
38
+ */
39
+ interface StructuredLogger {
40
+ log(record: AgentLogRecord): void;
41
+ }
42
+ /**
43
+ * Console-shaped logger (subset of Node's `Console`). The SDK defaults to
44
+ * the global `console` when neither option is set; if both are provided,
45
+ * `structuredLogger` wins.
46
+ */
47
+ type ConsoleLogger = Pick<Console, "debug" | "info" | "warn" | "error">;
19
48
  interface CapsuleAgentOptions {
20
49
  backendUrl: string;
21
50
  registrationToken?: string;
@@ -25,14 +54,14 @@ interface CapsuleAgentOptions {
25
54
  agent?: {
26
55
  code: string;
27
56
  name?: string;
28
- runtime?: string;
57
+ runtime?: RuntimeKind;
29
58
  };
30
59
  service: {
31
60
  code: string;
32
61
  name: string;
33
62
  description?: string;
34
63
  version: string;
35
- runtime: "nodejs" | "java" | "python" | "go" | "other";
64
+ runtime: RuntimeKind;
36
65
  manifest?: Record<string, unknown>;
37
66
  };
38
67
  intervals?: {
@@ -43,7 +72,14 @@ interface CapsuleAgentOptions {
43
72
  heartbeatIntervalSeconds?: number;
44
73
  commandPollIntervalSeconds?: number;
45
74
  autoStartLoops?: boolean;
46
- logger?: Pick<Console, "debug" | "info" | "warn" | "error">;
75
+ /** Console-shaped sink; default is the global `console`. */
76
+ logger?: ConsoleLogger;
77
+ /**
78
+ * Structured sink. If provided, takes precedence over `logger`. The SDK
79
+ * still applies its own redaction before calling `log()`, so the host
80
+ * can route records as-is to a JSON stream.
81
+ */
82
+ structuredLogger?: StructuredLogger;
47
83
  failOnStartError?: boolean;
48
84
  }
49
85
  type HealthProvider = () => Promise<HealthReportInput> | HealthReportInput;
@@ -64,6 +100,7 @@ declare class CapsuleAgent {
64
100
  private readonly client;
65
101
  private readonly store;
66
102
  private readonly logger;
103
+ private readonly structuredLogger?;
67
104
  private agentId?;
68
105
  private token?;
69
106
  private stopped;
@@ -90,6 +127,7 @@ declare class CapsuleAgent {
90
127
  private withTimeout;
91
128
  private retry;
92
129
  private log;
130
+ private normalizeFields;
93
131
  }
94
132
 
95
133
  declare class FileTokenStore implements TokenStore {
@@ -100,6 +138,47 @@ declare class FileTokenStore implements TokenStore {
100
138
  clear(): Promise<void>;
101
139
  }
102
140
 
141
+ /**
142
+ * Base class for any failure that crosses the Agent ↔ Backend wire. Use
143
+ * `instanceof AgentApiError` to catch the family, and the more specific
144
+ * subclasses below when the caller wants to branch on the failure mode.
145
+ *
146
+ * `status === 0` is reserved for transport failures that never reached the
147
+ * backend (DNS failure, ECONNREFUSED, network timeout, etc.); use
148
+ * `NetworkError` for those.
149
+ */
150
+ declare class AgentApiError extends Error {
151
+ readonly status: number;
152
+ readonly body?: unknown | undefined;
153
+ readonly code?: string | undefined;
154
+ constructor(status: number, message: string, body?: unknown | undefined, code?: string | undefined);
155
+ }
156
+ /**
157
+ * The backend rejected the registration token (expired, revoked, already
158
+ * used, malformed). The agent cannot proceed without operator action: a
159
+ * fresh registration token must be created in Opstage.
160
+ */
161
+ declare class RegistrationError extends AgentApiError {
162
+ constructor(message: string, status: number, body?: unknown, code?: string);
163
+ }
164
+ /**
165
+ * The backend rejected the persisted agent token (UNAUTHORIZED, agent
166
+ * REVOKED, agent DISABLED). The agent must be re-registered with a fresh
167
+ * registration token before it can resume.
168
+ */
169
+ declare class AgentAuthError extends AgentApiError {
170
+ constructor(message: string, status: number, body?: unknown, code?: string);
171
+ }
172
+ /**
173
+ * Transport-level failure: the request did not reach the backend, or the
174
+ * backend's response was not a parseable HTTP envelope. The SDK retries
175
+ * these with exponential backoff; surface to the caller only after the
176
+ * retry budget is exhausted.
177
+ */
178
+ declare class NetworkError extends AgentApiError {
179
+ constructor(message: string, cause?: unknown);
180
+ }
181
+
103
182
  declare class AgentApiClient {
104
183
  private readonly backendUrl;
105
184
  constructor(backendUrl: string);
@@ -114,7 +193,7 @@ declare class AgentApiClient {
114
193
  heartbeat(agentId: string, token: string, req: AgentHeartbeatRequest): Promise<unknown>;
115
194
  reportServices(agentId: string, token: string, req: ServiceReportRequest): Promise<unknown>;
116
195
  pollCommands(agentId: string, token: string): Promise<{
117
- type: "ACTION_PREPARE" | "ACTION_EXECUTE" | "ACTION";
196
+ type: "ACTION_PREPARE" | "ACTION_EXECUTE";
118
197
  id: string;
119
198
  createdAt: string;
120
199
  status: "PENDING" | "RUNNING" | "SUCCEEDED" | "FAILED" | "EXPIRED" | "CANCELLED";
@@ -130,10 +209,4 @@ declare class AgentApiClient {
130
209
  reportResult(agentId: string, token: string, commandId: string, req: ReportCommandResultRequest): Promise<unknown>;
131
210
  }
132
211
 
133
- declare class AgentApiError extends Error {
134
- readonly status: number;
135
- readonly body?: unknown | undefined;
136
- constructor(status: number, message: string, body?: unknown | undefined);
137
- }
138
-
139
- export { type ActionHandler, type ActionPrepareHandler, AgentApiClient, AgentApiError, type AgentMode, CapsuleAgent, type CapsuleAgentOptions, type ConfigProvider, FileTokenStore, type HealthProvider, type RegisteredAction, type ServiceSnapshot, type TokenStore };
212
+ export { type ActionHandler, type ActionPrepareHandler, type ActionPrepareResult, AgentApiClient, AgentApiError, AgentAuthError, type AgentLogLevel, type AgentLogRecord, type AgentMode, CapsuleAgent, type CapsuleAgentOptions, type ConfigProvider, type ConsoleLogger, FileTokenStore, type HealthProvider, NetworkError, type RegisteredAction, RegistrationError, type RuntimeKind, type ServiceSnapshot, type StructuredLogger, type TokenStore };
package/dist/index.js CHANGED
@@ -1,13 +1,42 @@
1
1
  // src/client/errors.ts
2
2
  var AgentApiError = class extends Error {
3
- constructor(status, message, body) {
3
+ constructor(status, message, body, code) {
4
4
  super(message);
5
5
  this.status = status;
6
6
  this.body = body;
7
+ this.code = code;
8
+ this.name = "AgentApiError";
7
9
  }
8
10
  status;
9
11
  body;
12
+ code;
10
13
  };
14
+ var RegistrationError = class extends AgentApiError {
15
+ constructor(message, status, body, code) {
16
+ super(status, message, body, code);
17
+ this.name = "RegistrationError";
18
+ }
19
+ };
20
+ var AgentAuthError = class extends AgentApiError {
21
+ constructor(message, status, body, code) {
22
+ super(status, message, body, code);
23
+ this.name = "AgentAuthError";
24
+ }
25
+ };
26
+ var NetworkError = class extends AgentApiError {
27
+ constructor(message, cause) {
28
+ super(0, message, void 0, "NETWORK_ERROR");
29
+ this.name = "NetworkError";
30
+ if (cause !== void 0) {
31
+ this.cause = cause;
32
+ }
33
+ }
34
+ };
35
+ function classifyAgentApiError(status, message, body, code, path) {
36
+ if (status === 401 && path.endsWith("/register")) return new RegistrationError(message, status, body, code);
37
+ if (status === 401 || status === 403) return new AgentAuthError(message, status, body, code);
38
+ return new AgentApiError(status, message, body, code);
39
+ }
11
40
 
12
41
  // src/client/agent-api-client.ts
13
42
  var AgentApiClient = class {
@@ -22,10 +51,24 @@ var AgentApiClient = class {
22
51
  const headers = new Headers(init.headers);
23
52
  headers.set("content-type", "application/json");
24
53
  if (init.token) headers.set("authorization", `Bearer ${init.token}`);
25
- const res = await fetch(this.url(path), { ...init, headers });
54
+ let res;
55
+ try {
56
+ res = await fetch(this.url(path), { ...init, headers });
57
+ } catch (cause) {
58
+ const reason = cause instanceof Error ? cause.message : String(cause);
59
+ throw new NetworkError(`Failed to reach ${path}: ${reason}`, cause);
60
+ }
26
61
  const text = await res.text();
27
- const body = text ? JSON.parse(text) : void 0;
28
- if (!res.ok) throw new AgentApiError(res.status, body?.error?.message ?? res.statusText, body);
62
+ let body;
63
+ try {
64
+ body = text ? JSON.parse(text) : void 0;
65
+ } catch {
66
+ body = { error: { message: res.statusText } };
67
+ }
68
+ if (!res.ok) {
69
+ const err = body?.error;
70
+ throw classifyAgentApiError(res.status, err?.message ?? res.statusText, body, err?.code, path);
71
+ }
29
72
  return body?.data ?? body;
30
73
  }
31
74
  register(req) {
@@ -49,13 +92,17 @@ var AgentApiClient = class {
49
92
  import { mkdir, readFile, rm, writeFile, chmod } from "fs/promises";
50
93
  import { dirname } from "path";
51
94
  var FileTokenStore = class {
52
- constructor(filePath = "./data/agent-token.json") {
95
+ constructor(filePath = "./data/agent-token.txt") {
53
96
  this.filePath = filePath;
54
97
  }
55
98
  filePath;
56
99
  async load() {
57
100
  try {
58
- const parsed = JSON.parse(await readFile(this.filePath, "utf8"));
101
+ const raw = await readFile(this.filePath, "utf8");
102
+ const trimmed = raw.trim();
103
+ if (!trimmed) return null;
104
+ if (!trimmed.startsWith("{")) return trimmed;
105
+ const parsed = JSON.parse(trimmed);
59
106
  if (typeof parsed.agentId === "string" && typeof parsed.agentToken === "string") return `${parsed.agentId}:${parsed.agentToken}`;
60
107
  return typeof parsed.agentToken === "string" ? parsed.agentToken : null;
61
108
  } catch (e) {
@@ -64,9 +111,8 @@ var FileTokenStore = class {
64
111
  }
65
112
  }
66
113
  async save(token) {
67
- const [agentId, agentToken] = token.includes(":") ? token.split(":", 2) : [void 0, token];
68
114
  await mkdir(dirname(this.filePath), { recursive: true });
69
- await writeFile(this.filePath, JSON.stringify({ ...agentId ? { agentId } : {}, agentToken, savedAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2), { mode: 384 });
115
+ await writeFile(this.filePath, token, { mode: 384 });
70
116
  try {
71
117
  await chmod(this.filePath, 384);
72
118
  } catch {
@@ -78,26 +124,47 @@ var FileTokenStore = class {
78
124
  };
79
125
 
80
126
  // src/security/redaction.ts
127
+ var SENSITIVE_KEY = /token|secret|password|cookie|authorization|api[-_]?key\b|generatedKey|rawKey|plainTextKey/i;
128
+ var TOKEN_PATTERN = /opstage_[a-z]+_[A-Za-z0-9_-]+/g;
81
129
  function redact(value) {
82
- if (typeof value === "string") return value.replace(/opstage_(reg|agent)_[A-Za-z0-9_-]+/g, "opstage_$1_[REDACTED]");
130
+ if (typeof value === "string") {
131
+ return value.replace(TOKEN_PATTERN, (match) => {
132
+ const prefixEnd = match.lastIndexOf("_");
133
+ return `${match.slice(0, prefixEnd + 1)}[REDACTED]`;
134
+ });
135
+ }
83
136
  if (!value || typeof value !== "object") return value;
84
137
  if (Array.isArray(value)) return value.map(redact);
85
- return Object.fromEntries(Object.entries(value).map(([k, v]) => [/token|secret|password|authorization/i.test(k) ? [k, "[REDACTED]"] : [k, redact(v)]]));
138
+ return Object.fromEntries(
139
+ Object.entries(value).map(
140
+ ([k, v]) => SENSITIVE_KEY.test(k) ? [k, "[REDACTED]"] : [k, redact(v)]
141
+ )
142
+ );
86
143
  }
87
144
 
88
145
  // src/capsule-agent.ts
89
146
  var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
147
+ function emitLog(consoleSink, structured, level, message, fields) {
148
+ if (structured) {
149
+ structured.log({ level, message, fields });
150
+ return;
151
+ }
152
+ if (fields === void 0) consoleSink[level](message);
153
+ else consoleSink[level](message, fields);
154
+ }
90
155
  var CapsuleAgent = class {
91
156
  constructor(options) {
92
157
  this.options = options;
93
158
  this.client = new AgentApiClient(options.backendUrl);
94
159
  this.store = new FileTokenStore(options.tokenStore?.file);
95
160
  this.logger = options.logger ?? console;
161
+ this.structuredLogger = options.structuredLogger;
96
162
  }
97
163
  options;
98
164
  client;
99
165
  store;
100
166
  logger;
167
+ structuredLogger;
101
168
  agentId;
102
169
  token;
103
170
  stopped = true;
@@ -245,19 +312,35 @@ var CapsuleAgent = class {
245
312
  return await fn();
246
313
  } catch (e) {
247
314
  last = e;
248
- if (e instanceof AgentApiError && e.status < 500) throw e;
315
+ if (e instanceof NetworkError) {
316
+ } else if (e instanceof AgentApiError && e.status >= 500) {
317
+ } else {
318
+ throw e;
319
+ }
249
320
  await sleep(Math.min(1e3 * 2 ** i, 15e3));
250
321
  }
251
322
  }
252
323
  throw last;
253
324
  }
254
325
  log(level, msg, obj) {
255
- this.logger[level](msg, obj ? redact(obj) : void 0);
326
+ const fields = obj === void 0 ? void 0 : this.normalizeFields(obj);
327
+ const redacted = fields ? redact(fields) : void 0;
328
+ emitLog(this.logger, this.structuredLogger, level, msg, redacted);
329
+ }
330
+ normalizeFields(obj) {
331
+ if (obj instanceof Error) {
332
+ return { error: { message: obj.message, name: obj.name, stack: obj.stack } };
333
+ }
334
+ if (typeof obj === "object" && obj !== null) return obj;
335
+ return { value: obj };
256
336
  }
257
337
  };
258
338
  export {
259
339
  AgentApiClient,
260
340
  AgentApiError,
341
+ AgentAuthError,
261
342
  CapsuleAgent,
262
- FileTokenStore
343
+ FileTokenStore,
344
+ NetworkError,
345
+ RegistrationError
263
346
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xtrape/capsule-agent-node",
3
- "version": "0.1.0-public-review.0",
3
+ "version": "0.2.0",
4
4
  "description": "Node.js Agent SDK for embedding Capsule Services into the Opstage governance loop.",
5
5
  "keywords": [
6
6
  "xtrape",
@@ -35,15 +35,8 @@
35
35
  "LICENSE",
36
36
  "NOTICE"
37
37
  ],
38
- "scripts": {
39
- "build": "tsup src/index.ts --format esm,cjs --dts",
40
- "prepack": "pnpm build",
41
- "test": "vitest run",
42
- "typecheck": "tsc --noEmit",
43
- "lint": "tsc --noEmit"
44
- },
45
38
  "dependencies": {
46
- "@xtrape/capsule-contracts-node": "^0.1.0-public-review.0"
39
+ "@xtrape/capsule-contracts-node": "^0.2.0"
47
40
  },
48
41
  "devDependencies": {
49
42
  "tsup": "^8.3.5",
@@ -58,5 +51,11 @@
58
51
  "registry": "https://registry.npmjs.org/",
59
52
  "access": "public"
60
53
  },
61
- "license": "Apache-2.0"
62
- }
54
+ "license": "Apache-2.0",
55
+ "scripts": {
56
+ "build": "tsup src/index.ts --format esm,cjs --dts",
57
+ "test": "vitest run",
58
+ "typecheck": "tsc --noEmit",
59
+ "lint": "tsc --noEmit"
60
+ }
61
+ }