@oxygen-agent/cli 1.286.13 → 1.309.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 +1 -1
- package/dist/cli-values.d.ts +18 -0
- package/dist/cli-values.js +66 -0
- package/dist/credentials.d.ts +22 -2
- package/dist/credentials.js +80 -17
- package/dist/help.js +1 -1
- package/dist/http-client.d.ts +2 -0
- package/dist/http-client.js +68 -30
- package/dist/index.js +789 -243
- package/dist/knowledge-mirror.d.ts +10 -0
- package/dist/knowledge-mirror.js +18 -0
- package/dist/run-wait.js +2 -26
- package/dist/runtime.d.ts +63 -4
- package/dist/runtime.js +113 -3
- package/node_modules/@oxygen/shared/dist/deprecation-registry.js +2 -18
- package/node_modules/@oxygen/shared/dist/error-redaction.d.ts +80 -0
- package/node_modules/@oxygen/shared/dist/error-redaction.js +223 -0
- package/node_modules/@oxygen/shared/dist/file-import.js +9 -27
- package/node_modules/@oxygen/shared/dist/identifiers.d.ts +23 -0
- package/node_modules/@oxygen/shared/dist/identifiers.js +48 -0
- package/node_modules/@oxygen/shared/dist/index.d.ts +7 -1
- package/node_modules/@oxygen/shared/dist/index.js +7 -1
- package/node_modules/@oxygen/shared/dist/knowledge-constants.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/knowledge-constants.js +4 -0
- package/node_modules/@oxygen/shared/dist/knowledge-seed-content.d.ts +24 -0
- package/node_modules/@oxygen/shared/dist/knowledge-seed-content.js +301 -0
- package/node_modules/@oxygen/shared/dist/linkedin-url.d.ts +19 -0
- package/node_modules/@oxygen/shared/dist/linkedin-url.js +105 -0
- package/node_modules/@oxygen/shared/dist/log.d.ts +3 -0
- package/node_modules/@oxygen/shared/dist/log.js +65 -6
- package/node_modules/@oxygen/shared/dist/redaction.d.ts +1 -0
- package/node_modules/@oxygen/shared/dist/redaction.js +15 -3
- package/node_modules/@oxygen/shared/dist/sequences.d.ts +11 -3
- package/node_modules/@oxygen/shared/dist/sequences.js +11 -2
- package/node_modules/@oxygen/shared/dist/timing.d.ts +10 -0
- package/node_modules/@oxygen/shared/dist/timing.js +12 -0
- package/node_modules/@oxygen/shared/dist/type-guards.d.ts +15 -0
- package/node_modules/@oxygen/shared/dist/type-guards.js +17 -0
- package/node_modules/@oxygen/shared/dist/version.d.ts +2 -1
- package/node_modules/@oxygen/shared/dist/version.js +33 -2
- package/node_modules/@oxygen/workflows/dist/index.d.ts +1 -1
- package/node_modules/@oxygen/workflows/dist/index.js +1 -0
- package/node_modules/@oxygen/workflows/dist/usage-estimate.d.ts +41 -0
- package/node_modules/@oxygen/workflows/dist/usage-estimate.js +203 -0
- 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;
|
package/dist/knowledge-mirror.js
CHANGED
|
@@ -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:
|
|
5
|
-
|
|
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[]):
|
|
14
|
-
export declare function resolveCliUpdateGuidance(
|
|
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(
|
|
30
|
+
export function resolveCliUpdateGuidance(endpoint, env = process.env, argv = process.argv) {
|
|
15
31
|
const binaryName = resolveCliBinaryName(env, argv);
|
|
16
|
-
|
|
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
|
-
|
|
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
|
+
}
|