@oxygen-agent/cli 1.287.12 → 1.310.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.
Files changed (45) hide show
  1. package/README.md +1 -1
  2. package/dist/cli-values.d.ts +18 -0
  3. package/dist/cli-values.js +66 -0
  4. package/dist/credentials.d.ts +22 -2
  5. package/dist/credentials.js +80 -17
  6. package/dist/help.js +1 -1
  7. package/dist/http-client.d.ts +2 -0
  8. package/dist/http-client.js +68 -30
  9. package/dist/index.js +789 -243
  10. package/dist/knowledge-mirror.d.ts +10 -0
  11. package/dist/knowledge-mirror.js +18 -0
  12. package/dist/run-wait.js +2 -26
  13. package/dist/runtime.d.ts +63 -4
  14. package/dist/runtime.js +113 -3
  15. package/node_modules/@oxygen/shared/dist/deprecation-registry.js +2 -18
  16. package/node_modules/@oxygen/shared/dist/error-redaction.d.ts +80 -0
  17. package/node_modules/@oxygen/shared/dist/error-redaction.js +223 -0
  18. package/node_modules/@oxygen/shared/dist/file-import.js +9 -27
  19. package/node_modules/@oxygen/shared/dist/identifiers.d.ts +23 -0
  20. package/node_modules/@oxygen/shared/dist/identifiers.js +48 -0
  21. package/node_modules/@oxygen/shared/dist/index.d.ts +7 -1
  22. package/node_modules/@oxygen/shared/dist/index.js +7 -1
  23. package/node_modules/@oxygen/shared/dist/knowledge-constants.d.ts +2 -0
  24. package/node_modules/@oxygen/shared/dist/knowledge-constants.js +4 -0
  25. package/node_modules/@oxygen/shared/dist/knowledge-seed-content.d.ts +24 -0
  26. package/node_modules/@oxygen/shared/dist/knowledge-seed-content.js +301 -0
  27. package/node_modules/@oxygen/shared/dist/linkedin-url.d.ts +19 -0
  28. package/node_modules/@oxygen/shared/dist/linkedin-url.js +105 -0
  29. package/node_modules/@oxygen/shared/dist/log.d.ts +3 -0
  30. package/node_modules/@oxygen/shared/dist/log.js +65 -6
  31. package/node_modules/@oxygen/shared/dist/redaction.d.ts +1 -0
  32. package/node_modules/@oxygen/shared/dist/redaction.js +15 -3
  33. package/node_modules/@oxygen/shared/dist/sequences.d.ts +11 -3
  34. package/node_modules/@oxygen/shared/dist/sequences.js +11 -2
  35. package/node_modules/@oxygen/shared/dist/timing.d.ts +10 -0
  36. package/node_modules/@oxygen/shared/dist/timing.js +12 -0
  37. package/node_modules/@oxygen/shared/dist/type-guards.d.ts +15 -0
  38. package/node_modules/@oxygen/shared/dist/type-guards.js +17 -0
  39. package/node_modules/@oxygen/shared/dist/version.d.ts +2 -1
  40. package/node_modules/@oxygen/shared/dist/version.js +33 -2
  41. package/node_modules/@oxygen/workflows/dist/index.d.ts +1 -1
  42. package/node_modules/@oxygen/workflows/dist/index.js +1 -0
  43. package/node_modules/@oxygen/workflows/dist/usage-estimate.d.ts +41 -0
  44. package/node_modules/@oxygen/workflows/dist/usage-estimate.js +203 -0
  45. package/package.json +1 -1
@@ -53,6 +53,16 @@ export declare function mirrorConflictsDir(dir: string): string;
53
53
  export declare function pageFilePath(dir: string, slug: string): string;
54
54
  /** Cheap "does a mirror exist here" check for post-write staleness hooks. */
55
55
  export declare function mirrorExists(dir: string): boolean;
56
+ /**
57
+ * Enumerate the locally mirrored org ids for one API host — the immediate
58
+ * subdirectory names under `<configDir>/knowledge/<apiHost>/`. Offline (a plain
59
+ * directory read); tolerant of a missing host directory (returns `[]`). Backs
60
+ * `knowledge status --all`, which reads each org's `MirrorState` in turn.
61
+ */
62
+ export declare function listLocalMirrors(input: {
63
+ configDir: string;
64
+ apiHost: string;
65
+ }): string[];
56
66
  export declare function emptyMirrorState(input: {
57
67
  apiHost: string;
58
68
  orgId: string;
@@ -58,6 +58,24 @@ export function pageFilePath(dir, slug) {
58
58
  export function mirrorExists(dir) {
59
59
  return existsSync(mirrorManifestPath(dir));
60
60
  }
61
+ /**
62
+ * Enumerate the locally mirrored org ids for one API host — the immediate
63
+ * subdirectory names under `<configDir>/knowledge/<apiHost>/`. Offline (a plain
64
+ * directory read); tolerant of a missing host directory (returns `[]`). Backs
65
+ * `knowledge status --all`, which reads each org's `MirrorState` in turn.
66
+ */
67
+ export function listLocalMirrors(input) {
68
+ const hostDir = join(input.configDir, "knowledge", safePathSegment(input.apiHost, "API host"));
69
+ try {
70
+ return readdirSync(hostDir, { withFileTypes: true })
71
+ .filter((entry) => entry.isDirectory())
72
+ .map((entry) => entry.name)
73
+ .sort((a, b) => a.localeCompare(b));
74
+ }
75
+ catch {
76
+ return [];
77
+ }
78
+ }
61
79
  function safePathSegment(value, label) {
62
80
  const cleaned = value.trim().toLowerCase().replace(/[^a-z0-9._-]/g, "_");
63
81
  if (!cleaned || /^\.+$/.test(cleaned)) {
package/dist/run-wait.js CHANGED
@@ -1,4 +1,5 @@
1
- import { OxygenError } from "@oxygen/shared";
1
+ import { OxygenError, sleep } from "@oxygen/shared";
2
+ import { readPositiveInt, readRecordString } from "./cli-values.js";
2
3
  export async function waitForCliRun(config) {
3
4
  const timeoutSeconds = readPositiveInt(config.requestedTimeoutSeconds)
4
5
  ?? config.defaultTimeoutSeconds;
@@ -30,28 +31,3 @@ export async function waitForCliRun(config) {
30
31
  await sleep(Math.min(intervalSeconds * 1000, remainingMs));
31
32
  }
32
33
  }
33
- // Local copies of index.ts's tiny readers keep this helper free of an
34
- // index.ts <-> run-wait.ts import cycle (the MCP run-wait.ts module likewise
35
- // defines its own sleep rather than importing from a tool file).
36
- function readPositiveInt(value) {
37
- const trimmed = value?.trim();
38
- if (!trimmed)
39
- return undefined;
40
- const parsed = Number(trimmed);
41
- if (!Number.isInteger(parsed) || parsed < 1) {
42
- throw new OxygenError("invalid_number", "Expected a positive integer.", {
43
- details: { value },
44
- exitCode: 1,
45
- });
46
- }
47
- return parsed;
48
- }
49
- function readRecordString(value, key) {
50
- if (!value || typeof value !== "object" || Array.isArray(value))
51
- return null;
52
- const entry = value[key];
53
- return typeof entry === "string" ? entry : null;
54
- }
55
- function sleep(ms) {
56
- return new Promise((resolve) => setTimeout(resolve, ms));
57
- }
package/dist/runtime.d.ts CHANGED
@@ -1,8 +1,48 @@
1
1
  declare const DEV_CLI_BINARY = "oxygen-dev";
2
2
  declare const PROD_CLI_BINARY = "oxygen";
3
+ /**
4
+ * Pins a terminal to a *host*, not to a profile label.
5
+ *
6
+ * `OXYGEN_PROFILE=default` was the guard prescribed after the last profile drift
7
+ * (OXY-4081), and it cannot work: `default` is a name, and `login` writes an
8
+ * `apiUrl` into whatever name is active — so the profile called `default` became
9
+ * dev and the pin followed it there (OXY-4109). Every credit balance, ticket, and
10
+ * whoami then came back from dev looking exactly like a healthy production answer.
11
+ *
12
+ * The invariant that a staff runbook actually needs is the host it dialed. Set
13
+ * `OXYGEN_REQUIRE_API_URL=https://oxygen-agent.com` and any command that resolves
14
+ * somewhere else refuses to run instead of quietly answering from the wrong
15
+ * environment.
16
+ */
17
+ export declare const REQUIRED_API_URL_ENV = "OXYGEN_REQUIRE_API_URL";
18
+ export type CliBinaryName = typeof DEV_CLI_BINARY | typeof PROD_CLI_BINARY;
19
+ /**
20
+ * Where a command actually sent its request, and what put it there.
21
+ *
22
+ * Every number in `cli_update_required: client 1.287.12, server 1.302.36,
23
+ * minimum_cli_version 1.298.0` is equally true of dev and of prod, so an error
24
+ * that omits the host is unreadable: a drifted profile made a prod-labelled
25
+ * binary report dev's floor, and the only available reading was "the shipped CLI
26
+ * is locked out of production" — a P0 that did not exist (OXY-4091). The host and
27
+ * the profile are in scope wherever we reject; they now travel with the rejection.
28
+ */
29
+ export type CliEndpoint = {
30
+ apiUrl: string | undefined;
31
+ /** Active credential profile, when one resolved it. */
32
+ profile?: string | null;
33
+ /** What put `apiUrl` in force. The fault differs, so the remedy differs. */
34
+ apiUrlSource?: "env" | "profile" | "default";
35
+ };
3
36
  export type CliUpdateGuidance = {
4
- binaryName: typeof DEV_CLI_BINARY | typeof PROD_CLI_BINARY;
5
- channel: "dev" | "npm";
37
+ binaryName: CliBinaryName;
38
+ /**
39
+ * dev — the `oxygen-dev` binary: rebuild it from the dev branch.
40
+ * profile — the npm `oxygen` binary aimed at a non-production API. The endpoint
41
+ * is the fault, not the binary: no CLI update can fix it, because npm
42
+ * only ever serves the version a non-prod floor is already rejecting.
43
+ * npm — the npm `oxygen` binary against production: `oxygen update`.
44
+ */
45
+ channel: "dev" | "profile" | "npm";
6
46
  warningInstruction: string;
7
47
  failureInstruction: string;
8
48
  details: {
@@ -10,7 +50,26 @@ export type CliUpdateGuidance = {
10
50
  cli_update_instruction?: string;
11
51
  };
12
52
  };
13
- export declare function resolveCliBinaryName(env?: NodeJS.ProcessEnv, argv?: readonly string[]): typeof DEV_CLI_BINARY | typeof PROD_CLI_BINARY;
14
- export declare function resolveCliUpdateGuidance(apiUrl: string | undefined, env?: NodeJS.ProcessEnv, argv?: readonly string[]): CliUpdateGuidance;
53
+ export declare function resolveCliBinaryName(env?: NodeJS.ProcessEnv, argv?: readonly string[]): CliBinaryName;
54
+ export declare function resolveCliUpdateGuidance(endpoint: CliEndpoint, env?: NodeJS.ProcessEnv, argv?: readonly string[]): CliUpdateGuidance;
55
+ /** Human-readable "which host, on whose behalf" — e.g. `https://dev.oxygen-agent.com (profile: dev)`. */
56
+ export declare function describeCliEndpoint(endpoint: CliEndpoint): string;
57
+ /** The same facts, machine-readable, for `error.details`. */
58
+ export declare function cliEndpointDetails(endpoint: CliEndpoint): Record<string, string>;
15
59
  export declare function isProdApiUrl(apiUrl: string): boolean;
60
+ /**
61
+ * Do two API URLs address the same deployment? Compared on origin, so a trailing
62
+ * slash or a path suffix cannot make prod and dev look like different hosts — or
63
+ * the same one.
64
+ */
65
+ export declare function sameApiOrigin(a: string, b: string): boolean;
66
+ /**
67
+ * Refuse the command when the resolved host is not the one the caller pinned.
68
+ *
69
+ * Called from the one place every request funnels through, so a pin covers reads
70
+ * and writes alike: the failure mode this exists for is a *read* that looks right
71
+ * (dev's credit balance answering a question about a production customer), not
72
+ * only a write landing in the wrong environment.
73
+ */
74
+ export declare function assertResolvedApiUrl(endpoint: CliEndpoint, env?: NodeJS.ProcessEnv): void;
16
75
  export {};
package/dist/runtime.js CHANGED
@@ -1,7 +1,23 @@
1
1
  import { basename } from "node:path";
2
+ import { OxygenError } from "@oxygen/shared";
2
3
  const PROD_API_HOSTNAME = "oxygen-agent.com";
3
4
  const DEV_CLI_BINARY = "oxygen-dev";
4
5
  const PROD_CLI_BINARY = "oxygen";
6
+ /**
7
+ * Pins a terminal to a *host*, not to a profile label.
8
+ *
9
+ * `OXYGEN_PROFILE=default` was the guard prescribed after the last profile drift
10
+ * (OXY-4081), and it cannot work: `default` is a name, and `login` writes an
11
+ * `apiUrl` into whatever name is active — so the profile called `default` became
12
+ * dev and the pin followed it there (OXY-4109). Every credit balance, ticket, and
13
+ * whoami then came back from dev looking exactly like a healthy production answer.
14
+ *
15
+ * The invariant that a staff runbook actually needs is the host it dialed. Set
16
+ * `OXYGEN_REQUIRE_API_URL=https://oxygen-agent.com` and any command that resolves
17
+ * somewhere else refuses to run instead of quietly answering from the wrong
18
+ * environment.
19
+ */
20
+ export const REQUIRED_API_URL_ENV = "OXYGEN_REQUIRE_API_URL";
5
21
  export function resolveCliBinaryName(env = process.env, argv = process.argv) {
6
22
  const explicit = normalizeBinaryName(env.OXYGEN_CLI_BINARY ?? env.OXYGEN_CLI_NAME);
7
23
  if (explicit)
@@ -11,10 +27,9 @@ export function resolveCliBinaryName(env = process.env, argv = process.argv) {
11
27
  return invoked;
12
28
  return PROD_CLI_BINARY;
13
29
  }
14
- export function resolveCliUpdateGuidance(apiUrl, env = process.env, argv = process.argv) {
30
+ export function resolveCliUpdateGuidance(endpoint, env = process.env, argv = process.argv) {
15
31
  const binaryName = resolveCliBinaryName(env, argv);
16
- const devLike = binaryName === DEV_CLI_BINARY || (apiUrl ? !isProdApiUrl(apiUrl) : false);
17
- if (devLike) {
32
+ if (binaryName === DEV_CLI_BINARY) {
18
33
  return {
19
34
  binaryName,
20
35
  channel: "dev",
@@ -25,6 +40,20 @@ export function resolveCliUpdateGuidance(apiUrl, env = process.env, argv = proce
25
40
  },
26
41
  };
27
42
  }
43
+ // The production binary talking to a non-production API. `oxygen update` is a
44
+ // dead end here — it reinstalls the same npm version the non-prod floor just
45
+ // rejected — so the guidance names the endpoint, and no update command is
46
+ // offered at all.
47
+ if (endpoint.apiUrl && !isProdApiUrl(endpoint.apiUrl)) {
48
+ const instruction = profileFaultInstruction(endpoint);
49
+ return {
50
+ binaryName,
51
+ channel: "profile",
52
+ warningInstruction: instruction,
53
+ failureInstruction: instruction,
54
+ details: { cli_update_instruction: instruction },
55
+ };
56
+ }
28
57
  return {
29
58
  binaryName,
30
59
  channel: "npm",
@@ -35,6 +64,43 @@ export function resolveCliUpdateGuidance(apiUrl, env = process.env, argv = proce
35
64
  },
36
65
  };
37
66
  }
67
+ function profileFaultInstruction(endpoint) {
68
+ if (endpoint.apiUrlSource === "env") {
69
+ return "`OXYGEN_API_URL` is pointing this `oxygen` binary at a non-production API — unset it to reach "
70
+ + "production, or use the `oxygen-dev` binary against a dev API.";
71
+ }
72
+ const profile = endpoint.profile?.trim();
73
+ if (profile) {
74
+ return `Profile \`${profile}\` is pointing this \`oxygen\` binary at a non-production API — run `
75
+ + "`oxygen profiles list`, then `oxygen profiles use <profile>` to switch back to production.";
76
+ }
77
+ return "This `oxygen` binary is pointed at a non-production API — run `oxygen profiles list` to see the "
78
+ + "active profile, then `oxygen profiles use <profile>` to switch back to production.";
79
+ }
80
+ /** Human-readable "which host, on whose behalf" — e.g. `https://dev.oxygen-agent.com (profile: dev)`. */
81
+ export function describeCliEndpoint(endpoint) {
82
+ if (!endpoint.apiUrl)
83
+ return "";
84
+ const qualifiers = [];
85
+ const profile = endpoint.profile?.trim();
86
+ if (profile)
87
+ qualifiers.push(`profile: ${profile}`);
88
+ if (endpoint.apiUrlSource === "env")
89
+ qualifiers.push("api url from OXYGEN_API_URL");
90
+ return qualifiers.length > 0 ? `${endpoint.apiUrl} (${qualifiers.join(", ")})` : endpoint.apiUrl;
91
+ }
92
+ /** The same facts, machine-readable, for `error.details`. */
93
+ export function cliEndpointDetails(endpoint) {
94
+ const details = {};
95
+ if (endpoint.apiUrl)
96
+ details.api_url = endpoint.apiUrl;
97
+ const profile = endpoint.profile?.trim();
98
+ if (profile)
99
+ details.profile = profile;
100
+ if (endpoint.apiUrlSource)
101
+ details.api_url_source = endpoint.apiUrlSource;
102
+ return details;
103
+ }
38
104
  function normalizeBinaryName(value) {
39
105
  const normalized = basename(value ?? "")
40
106
  .replace(/\.(?:cmd|ps1|bat|js)$/i, "")
@@ -54,3 +120,47 @@ export function isProdApiUrl(apiUrl) {
54
120
  return false;
55
121
  }
56
122
  }
123
+ /**
124
+ * Do two API URLs address the same deployment? Compared on origin, so a trailing
125
+ * slash or a path suffix cannot make prod and dev look like different hosts — or
126
+ * the same one.
127
+ */
128
+ export function sameApiOrigin(a, b) {
129
+ try {
130
+ return new URL(a).origin === new URL(b).origin;
131
+ }
132
+ catch {
133
+ return false;
134
+ }
135
+ }
136
+ /**
137
+ * Refuse the command when the resolved host is not the one the caller pinned.
138
+ *
139
+ * Called from the one place every request funnels through, so a pin covers reads
140
+ * and writes alike: the failure mode this exists for is a *read* that looks right
141
+ * (dev's credit balance answering a question about a production customer), not
142
+ * only a write landing in the wrong environment.
143
+ */
144
+ export function assertResolvedApiUrl(endpoint, env = process.env) {
145
+ const required = env[REQUIRED_API_URL_ENV]?.trim();
146
+ if (!required)
147
+ return;
148
+ let requiredOrigin;
149
+ try {
150
+ requiredOrigin = new URL(required).origin;
151
+ }
152
+ catch {
153
+ throw new OxygenError("invalid_required_api_url", `${REQUIRED_API_URL_ENV} must be an absolute URL (for example https://oxygen-agent.com). It is set to "${required}".`, { details: { required_api_url: required }, exitCode: 1 });
154
+ }
155
+ const apiUrl = endpoint.apiUrl;
156
+ if (apiUrl && sameApiOrigin(apiUrl, requiredOrigin))
157
+ return;
158
+ throw new OxygenError("api_url_mismatch", `${REQUIRED_API_URL_ENV} pins this command to ${requiredOrigin}, but it resolved to `
159
+ + `${describeCliEndpoint(endpoint) || "no API URL"}. Refusing to run against the wrong environment. `
160
+ + `Run \`oxygen profiles list\` to see which profile carries ${requiredOrigin}, then `
161
+ + "`oxygen profiles use <profile>` — or `oxygen login --api-url "
162
+ + `${requiredOrigin} --profile <name>\` if no profile holds it yet.`, {
163
+ details: { required_api_url: requiredOrigin, ...cliEndpointDetails(endpoint) },
164
+ exitCode: 1,
165
+ });
166
+ }
@@ -18,24 +18,8 @@
18
18
  // floor must sweep the "floor-bump" entries in the same change.
19
19
  export const FLOOR_BUMP_SENTINEL = "floor-bump";
20
20
  export const DEPRECATION_REGISTRY = [
21
- {
22
- surface: "oxygen_templates_* MCP alias tools",
23
- deprecated_in: "1.80.0",
24
- sunset_version: "1.290.0",
25
- note: "Deprecated alias tree of oxygen_prompts_* (packages/mcp-server/src/tools/" +
26
- "prompt-template-tools.ts, prefix \"oxygen_templates\"). Removal: drop the " +
27
- "alias prefix from the generated tools and the CLI `templates` alias " +
28
- "command tree, then update the pinned tool counts.",
29
- },
30
- {
31
- surface: "oxygen.linkedin-inbox widget alias",
32
- deprecated_in: "1.226.0",
33
- sunset_version: "1.290.0",
34
- note: "Legacy alias of oxygen.unibox kept so MCP clients that cached " +
35
- "ui://oxygen/linkedin-inbox keep resolving (packages/mcp-server/src/widgets/" +
36
- "definitions.ts). Removal: delete the alias widget definition and any " +
37
- "bindings that still point at it.",
38
- },
21
+ // v1.290.0 sunsets swept 2026-07-10: oxygen_templates_* MCP alias tools and
22
+ // the oxygen.linkedin-inbox widget alias were removed with their entries.
39
23
  {
40
24
  surface: "CLI deepLink/deep_link response keys",
41
25
  deprecated_in: "1.8.2",
@@ -0,0 +1,80 @@
1
+ export declare const MAX_ERROR_MESSAGE_LENGTH = 500;
2
+ /** Error code for a Postgres deadlock / serialization failure, made actionable. */
3
+ export declare const CONCURRENT_WRITE_CONFLICT_ERROR_CODE = "concurrent_write_conflict";
4
+ /** Why a customer-facing message was withheld. Drives the operator log. */
5
+ export type CustomerErrorRedactionReason = "sql_query" | "internal_http_path" | "database_error";
6
+ export type RedactedCustomerError = {
7
+ code: string;
8
+ message: string;
9
+ details?: unknown;
10
+ /**
11
+ * Non-null when the original message was withheld from the customer. The
12
+ * caller MUST log it (error level) so the detail survives for operators.
13
+ */
14
+ redaction: {
15
+ reason: CustomerErrorRedactionReason;
16
+ originalMessage: string;
17
+ } | null;
18
+ };
19
+ /**
20
+ * Classify + redact one error payload for customer-visible persistence.
21
+ *
22
+ * `error` is optional and only sharpens classification: with the raw error we can
23
+ * read the SQLSTATE (40P01/40001) instead of guessing from message text.
24
+ */
25
+ export declare function redactCustomerFacingError(input: {
26
+ code: string;
27
+ message: string;
28
+ details?: unknown;
29
+ error?: unknown;
30
+ }): RedactedCustomerError;
31
+ /**
32
+ * Redact a persisted error `details` object. Same structural caps and secret-key
33
+ * rules as the operation-event redactor, plus the customer-facing string scrub —
34
+ * a provider body nested in `details` carries the same SQL / internal-path /
35
+ * credential material the top-level message does.
36
+ */
37
+ export declare function redactCustomerFacingDetails(value: unknown): unknown;
38
+ /** True when the text is a SQL statement rather than prose. Never throws. */
39
+ export declare function isSqlLikeMessage(message: string): boolean;
40
+ /**
41
+ * Redact a metadata / details payload for an operation event: cap the structure,
42
+ * blank secret-named fields, truncate long strings. Extracted verbatim from
43
+ * apps/web/src/lib/observability.ts so the web and the worker share one
44
+ * implementation; behavior there is unchanged.
45
+ */
46
+ export declare function redactForOperationEvent(value: unknown, depth?: number): unknown;
47
+ /** Truncate with an ellipsis, preserving the operation-event writer's semantics. */
48
+ export declare function truncateEventString(value: string | null, maxLength: number): string | null;
49
+ /**
50
+ * The classified, already-redacted failure of a workflow STEP, carried on the
51
+ * in-flight error so the RUN that the step failed can persist the step's code
52
+ * instead of a generic catch-all.
53
+ *
54
+ * Why an annotation and not a wrapper: the worker's failure path routes on the
55
+ * error's identity (`instanceof OxygenError`, `.code`, `.cause.code` — lease
56
+ * lost, transient persistence, awaiting approval, checkpoint retry). Wrapping the
57
+ * error would silently re-route every one of those. Marking it leaves the object,
58
+ * and therefore all of that control flow, byte-identical.
59
+ */
60
+ export type WorkflowStepFailure = {
61
+ code: string;
62
+ message: string;
63
+ details?: unknown;
64
+ stepId: string;
65
+ stepRunId: string;
66
+ };
67
+ /** Mark an in-flight error with the step failure already persisted for it. */
68
+ export declare function attachWorkflowStepFailure<T>(error: T, failure: WorkflowStepFailure): T;
69
+ /** Read the step failure a marked error carries, if any. */
70
+ export declare function readWorkflowStepFailure(error: unknown): WorkflowStepFailure | null;
71
+ /**
72
+ * captureAutomationActions() is two things at once: the admission GATE (it throws
73
+ * this code at the monthly cap — a decision the customer must feel) and the
74
+ * metering WRITE (a control-DB insert that can fail for reasons that have nothing
75
+ * to do with the customer's work). Only the gate may fail a run; a failed write
76
+ * must be logged and stepped over. This predicate is the line between them, and
77
+ * it lives here so both worker call sites classify identically.
78
+ */
79
+ export declare const AUTOMATION_ACTIONS_EXCEEDED_ERROR_CODE = "automation_actions_exceeded";
80
+ export declare function isAutomationUsageQuotaError(error: unknown): boolean;
@@ -0,0 +1,223 @@
1
+ // The ONE redactor for error payloads we persist and show to a customer.
2
+ //
3
+ // Two callers, one implementation: the web API's operation-event writer
4
+ // (apps/web/src/lib/observability.ts) and the worker's durable-run failure
5
+ // writers (workflow_runs.last_error, workflow_step_runs.last_error,
6
+ // workflow_step_attempts.error). Before this module the worker had no redaction
7
+ // at all and wrote `error.message` verbatim, which put internal detail in front
8
+ // of customers. Measured against prod, 90 days, 476 failed workflow runs:
9
+ //
10
+ // • 29 runs whose customer-visible error WAS a Postgres query — the raw
11
+ // `Failed query: with existing_event as (select automation_usage_events...)`
12
+ // CTE from the automation-usage metering write.
13
+ // • 51 runs leaking an internal HTTP path plus a provider account id, e.g.
14
+ // `Cannot GET /api/v2/posts/<id>/comments?account_id=<provider account id>`.
15
+ // • 5 runs leaking raw Postgres `deadlock detected`.
16
+ //
17
+ // The doctrine: the customer gets a clean, actionable message; the ORIGINAL is
18
+ // preserved for operators via log() at error level (see the `redaction` field on
19
+ // the result — a caller that gets a non-null value MUST log it). Redaction is
20
+ // idempotent, never throws, and never widens a message.
21
+ import { redactSecretsInString } from "./redaction.js";
22
+ import { isRetryableConcurrencyError, redactSqlParameters } from "./sql-error.js";
23
+ // Whole fields whose NAME marks them secret. Unchanged from the operation-event
24
+ // writer this was extracted from; substring credentials (Bearer/sk-/DB URLs) are
25
+ // scrubbed separately by redactSecretsInString.
26
+ const SECRET_KEY_PATTERN = /(api[_-]?key|authorization|bearer|cookie|password|secret|token|ciphertext|connection[_-]?uri|database[_-]?url)/i;
27
+ export const MAX_ERROR_MESSAGE_LENGTH = 500;
28
+ const MAX_STRING_LENGTH = 1000;
29
+ const MAX_ARRAY_LENGTH = 20;
30
+ const MAX_OBJECT_KEYS = 40;
31
+ const MAX_REDACTION_DEPTH = 5;
32
+ /** Error code for a Postgres deadlock / serialization failure, made actionable. */
33
+ export const CONCURRENT_WRITE_CONFLICT_ERROR_CODE = "concurrent_write_conflict";
34
+ const CONCURRENT_WRITE_CONFLICT_MESSAGE = "This run conflicted with another write to the same records and was rolled back. Retry the run.";
35
+ const INTERNAL_DATABASE_ERROR_MESSAGE = "An internal database error interrupted this run. Retry the run; if it keeps failing, contact support with the run link.";
36
+ const PROVIDER_REQUEST_FAILED_MESSAGE = "A provider request failed with an unexpected response. Retry the run; if it keeps failing, contact support with the run link.";
37
+ const SQL_MARKER = "[redacted: internal query]";
38
+ const INTERNAL_PATH_MARKER = "[redacted: internal path]";
39
+ // drizzle always prefixes a failed statement with `Failed query:` — that single
40
+ // marker covers every observed prod leak.
41
+ const SQL_FAILED_QUERY_PATTERN = /failed query:/i;
42
+ // Belt-and-braces for a bare statement with no drizzle prefix. All three must
43
+ // hold, because a *leading SQL verb + a structural keyword* alone also describes
44
+ // ordinary product prose ("Select at least one row from the table."). The third
45
+ // clause demands a marker only machine-generated SQL carries — a quoted
46
+ // identifier, a positional placeholder, or a parenthesized column list — so
47
+ // prose can never trip it.
48
+ const SQL_VERB_PREFIX = /^\s*\(?\s*(?:select|with|insert|update|delete|merge)\s/i;
49
+ const SQL_STRUCTURE_KEYWORD = /\b(?:from|where|into|set|values)\b/i;
50
+ const SQL_MACHINE_MARKER = /"[a-z_][\w$]*"|\$\d+|\(\s*"?[a-z_][\w$]*"?\s*[,)]/i;
51
+ // An Express/provider 404 body: leaks the internal route AND its query string,
52
+ // which is where the provider account id rides.
53
+ const INTERNAL_HTTP_PATH_PATTERN = /\bcannot\s+(?:get|post|put|patch|delete|head|options)\s+\/\S*/gi;
54
+ // Raw Postgres concurrency text, for errors that reached us without a SQLSTATE
55
+ // (e.g. across the recipe sandbox boundary, where only {code, message} survives).
56
+ const PG_CONCURRENCY_MESSAGE = /\bdeadlock detected\b|\bcould not serialize access\b/i;
57
+ /**
58
+ * Classify + redact one error payload for customer-visible persistence.
59
+ *
60
+ * `error` is optional and only sharpens classification: with the raw error we can
61
+ * read the SQLSTATE (40P01/40001) instead of guessing from message text.
62
+ */
63
+ export function redactCustomerFacingError(input) {
64
+ const details = input.details === undefined
65
+ ? undefined
66
+ : redactCustomerFacingDetails(input.details);
67
+ const withDetails = details === undefined ? {} : { details };
68
+ // Strip drizzle's `params: [...]` tail before anything else: those are SQL
69
+ // parameter VALUES, i.e. customer row data, and they must not survive even
70
+ // into the operator log (OXY-46).
71
+ const message = redactSqlParameters(typeof input.message === "string" ? input.message : String(input.message ?? ""));
72
+ if (isRetryableConcurrencyError(input.error) || PG_CONCURRENCY_MESSAGE.test(message)) {
73
+ return {
74
+ code: CONCURRENT_WRITE_CONFLICT_ERROR_CODE,
75
+ message: CONCURRENT_WRITE_CONFLICT_MESSAGE,
76
+ ...withDetails,
77
+ redaction: { reason: "database_error", originalMessage: message },
78
+ };
79
+ }
80
+ if (isSqlLikeMessage(message)) {
81
+ return {
82
+ code: input.code,
83
+ message: INTERNAL_DATABASE_ERROR_MESSAGE,
84
+ ...withDetails,
85
+ redaction: { reason: "sql_query", originalMessage: message },
86
+ };
87
+ }
88
+ if (containsInternalHttpPath(message)) {
89
+ return {
90
+ code: input.code,
91
+ message: PROVIDER_REQUEST_FAILED_MESSAGE,
92
+ ...withDetails,
93
+ redaction: { reason: "internal_http_path", originalMessage: message },
94
+ };
95
+ }
96
+ return {
97
+ code: input.code,
98
+ message: truncate(redactSecretsInString(message), MAX_ERROR_MESSAGE_LENGTH),
99
+ ...withDetails,
100
+ redaction: null,
101
+ };
102
+ }
103
+ /**
104
+ * Redact a persisted error `details` object. Same structural caps and secret-key
105
+ * rules as the operation-event redactor, plus the customer-facing string scrub —
106
+ * a provider body nested in `details` carries the same SQL / internal-path /
107
+ * credential material the top-level message does.
108
+ */
109
+ export function redactCustomerFacingDetails(value) {
110
+ return redactStructured(value, scrubCustomerFacingString, 0);
111
+ }
112
+ /** True when the text is a SQL statement rather than prose. Never throws. */
113
+ export function isSqlLikeMessage(message) {
114
+ if (SQL_FAILED_QUERY_PATTERN.test(message))
115
+ return true;
116
+ return SQL_VERB_PREFIX.test(message)
117
+ && SQL_STRUCTURE_KEYWORD.test(message)
118
+ && SQL_MACHINE_MARKER.test(message);
119
+ }
120
+ function containsInternalHttpPath(message) {
121
+ INTERNAL_HTTP_PATH_PATTERN.lastIndex = 0;
122
+ return INTERNAL_HTTP_PATH_PATTERN.test(message);
123
+ }
124
+ // A string inside a details payload: drop SQL parameter values, replace a whole
125
+ // SQL statement, blank internal paths in place (the surrounding text may still be
126
+ // useful), scrub credential substrings, then cap.
127
+ function scrubCustomerFacingString(value) {
128
+ const withoutParams = redactSqlParameters(value);
129
+ if (isSqlLikeMessage(withoutParams))
130
+ return SQL_MARKER;
131
+ const withoutPaths = withoutParams.replace(INTERNAL_HTTP_PATH_PATTERN, INTERNAL_PATH_MARKER);
132
+ return truncate(redactSecretsInString(withoutPaths), MAX_STRING_LENGTH);
133
+ }
134
+ /**
135
+ * Redact a metadata / details payload for an operation event: cap the structure,
136
+ * blank secret-named fields, truncate long strings. Extracted verbatim from
137
+ * apps/web/src/lib/observability.ts so the web and the worker share one
138
+ * implementation; behavior there is unchanged.
139
+ */
140
+ export function redactForOperationEvent(value, depth = 0) {
141
+ return redactStructured(value, (entry) => truncate(entry, MAX_STRING_LENGTH), depth);
142
+ }
143
+ /** Truncate with an ellipsis, preserving the operation-event writer's semantics. */
144
+ export function truncateEventString(value, maxLength) {
145
+ return value === null ? null : truncate(value, maxLength);
146
+ }
147
+ // One structural walk, two string policies: the operation-event redactor only
148
+ // truncates, the customer-facing redactor also scrubs. Keeping a single walker
149
+ // means the caps (depth 5, 20 array entries, 40 object keys) can never drift
150
+ // apart between the two surfaces.
151
+ function redactStructured(value, redactString, depth) {
152
+ if (depth > MAX_REDACTION_DEPTH)
153
+ return "[truncated]";
154
+ if (value === null || value === undefined)
155
+ return value;
156
+ if (typeof value === "string")
157
+ return redactString(value);
158
+ if (typeof value === "number" || typeof value === "boolean")
159
+ return value;
160
+ if (Array.isArray(value)) {
161
+ return value.slice(0, MAX_ARRAY_LENGTH).map((entry) => redactStructured(entry, redactString, depth + 1));
162
+ }
163
+ if (!isRecord(value))
164
+ return String(value);
165
+ const entries = Object.entries(value).slice(0, MAX_OBJECT_KEYS);
166
+ return Object.fromEntries(entries.map(([key, entry]) => [
167
+ key,
168
+ SECRET_KEY_PATTERN.test(key) ? "[redacted]" : redactStructured(entry, redactString, depth + 1),
169
+ ]));
170
+ }
171
+ const WORKFLOW_STEP_FAILURE_KEY = Symbol.for("oxygen.workflow_step_failure");
172
+ /** Mark an in-flight error with the step failure already persisted for it. */
173
+ export function attachWorkflowStepFailure(error, failure) {
174
+ if (!error || typeof error !== "object")
175
+ return error;
176
+ try {
177
+ Object.defineProperty(error, WORKFLOW_STEP_FAILURE_KEY, {
178
+ value: failure,
179
+ enumerable: false,
180
+ configurable: true,
181
+ writable: true,
182
+ });
183
+ }
184
+ catch {
185
+ // A frozen error cannot carry the mark. The run then falls back to the
186
+ // generic code — worse, but never worse than losing the failure entirely.
187
+ }
188
+ return error;
189
+ }
190
+ /** Read the step failure a marked error carries, if any. */
191
+ export function readWorkflowStepFailure(error) {
192
+ if (!error || typeof error !== "object")
193
+ return null;
194
+ const value = error[WORKFLOW_STEP_FAILURE_KEY];
195
+ if (!isRecord(value))
196
+ return null;
197
+ return typeof value.code === "string" && typeof value.message === "string"
198
+ ? value
199
+ : null;
200
+ }
201
+ // ---------------------------------------------------------------------------
202
+ // Automation usage: decision vs. failure
203
+ // ---------------------------------------------------------------------------
204
+ /**
205
+ * captureAutomationActions() is two things at once: the admission GATE (it throws
206
+ * this code at the monthly cap — a decision the customer must feel) and the
207
+ * metering WRITE (a control-DB insert that can fail for reasons that have nothing
208
+ * to do with the customer's work). Only the gate may fail a run; a failed write
209
+ * must be logged and stepped over. This predicate is the line between them, and
210
+ * it lives here so both worker call sites classify identically.
211
+ */
212
+ export const AUTOMATION_ACTIONS_EXCEEDED_ERROR_CODE = "automation_actions_exceeded";
213
+ export function isAutomationUsageQuotaError(error) {
214
+ return Boolean(error)
215
+ && typeof error === "object"
216
+ && error.code === AUTOMATION_ACTIONS_EXCEEDED_ERROR_CODE;
217
+ }
218
+ function truncate(value, maxLength) {
219
+ return value.length > maxLength ? `${value.slice(0, maxLength - 3)}...` : value;
220
+ }
221
+ function isRecord(value) {
222
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
223
+ }