@bridge_gpt/mcp-server 0.2.65 → 0.2.67
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 +269 -82
- package/build/agent-capabilities/probes.js +15 -5
- package/build/agent-launchers/claude-executor-adapter.js +55 -34
- package/build/agent-launchers/executor-adapter.js +34 -4
- package/build/agents.generated.js +1 -1
- package/build/bounded-wait.js +67 -10
- package/build/bridge-config.js +104 -0
- package/build/codex-bridge-profile.js +10 -1
- package/build/commands.generated.js +12 -12
- package/build/conductor/bridge-api-client.js +372 -10
- package/build/conductor/bring-up-facts.js +1 -1
- package/build/conductor/doctor.js +3 -3
- package/build/conductor/epic-child-continuation.js +61 -34
- package/build/conductor/epic-implementer-cli.js +34 -6
- package/build/conductor/errors.js +4 -3
- package/build/conductor/guided-repair.js +327 -0
- package/build/conductor/hook-runtime-qualification.js +1 -1
- package/build/conductor/merge-execution-contract.js +27 -8
- package/build/conductor/merge-execution-eligibility.js +3 -2
- package/build/conductor/pr-ci-producer.js +1 -1
- package/build/conductor/readiness-cli.js +46 -9
- package/build/conductor/readiness-sections.js +41 -6
- package/build/conductor/readiness.js +204 -30
- package/build/conductor/recovery-cli.js +703 -20
- package/build/conductor/recovery-operations.js +392 -3
- package/build/conductor/repair-contract.js +112 -3
- package/build/conductor/repository-workflow-remedy.js +125 -0
- package/build/conductor/tools.js +2 -2
- package/build/conductor-bundle-artifacts.js +8 -0
- package/build/credential-store.js +5 -4
- package/build/diagnostic-text.js +36 -0
- package/build/docs.generated.js +8 -3
- package/build/doctor.js +70 -10
- package/build/drive-epic-key.js +6 -5
- package/build/drive-epic.js +479 -131
- package/build/epic-coherence-input.js +2 -1
- package/build/epic-implementer-bin.js +30 -26
- package/build/epic-implementer-bundle-cli.js +2 -2
- package/build/epic-launch-diagnostic.js +31 -0
- package/build/epic-launch-profile.js +44 -14
- package/build/executor/cli.js +3 -2
- package/build/executor/completion-contract.js +48 -5
- package/build/executor/completion-supervision.js +9 -0
- package/build/executor/correction-dispatch.js +60 -0
- package/build/executor/env.js +25 -0
- package/build/executor/heartbeat.js +1 -1
- package/build/executor/http-client.js +35 -0
- package/build/executor/integration-job.js +6 -1
- package/build/executor/job-errors.js +38 -4
- package/build/executor/job-log-registry.js +340 -73
- package/build/executor/job-runner.js +485 -74
- package/build/executor/job-types.js +8 -13
- package/build/executor/observation.js +6 -1
- package/build/executor/preflight.js +136 -10
- package/build/executor/prompt-spec.js +113 -9
- package/build/executor/required-work-ledger.js +6 -1
- package/build/executor/resume-pre-spawn.js +2 -0
- package/build/executor/runner.js +31 -2
- package/build/executor/service-unit.js +7 -1
- package/build/executor/stale-artifacts.js +5 -4
- package/build/executor/types.js +1 -0
- package/build/executor/watch-cli.js +35 -8
- package/build/executor/worker-command.js +43 -43
- package/build/executor/worker-finalization.js +161 -43
- package/build/executor/worker-guard-hook.js +152 -12
- package/build/executor/worker-log.js +137 -70
- package/build/executor/worktree-gc.js +30 -6
- package/build/executor/worktree.js +82 -1
- package/build/index.js +482 -660
- package/build/init.js +107 -4
- package/build/install-bridge-conductor.js +8 -6
- package/build/install-bridge.js +287 -23
- package/build/install-doctor.js +172 -30
- package/build/install-reexec.js +69 -7
- package/build/macos-terminal-permission-guidance.js +16 -0
- package/build/model-thresholds-tool.js +185 -0
- package/build/phase-context-git-probe.js +68 -0
- package/build/phase-result-artifacts.js +173 -5
- package/build/pipelines.generated.js +19 -25
- package/build/plane/alembic-head.js +12 -3
- package/build/plane/base-worktree-guard.js +147 -0
- package/build/plane/cli.js +217 -17
- package/build/plane/code-snapshot.js +468 -0
- package/build/plane/defaults.js +48 -3
- package/build/plane/endpoint.js +41 -0
- package/build/plane/manifest.js +120 -1
- package/build/plane/member-roster.js +94 -19
- package/build/plane/preflight.js +30 -2
- package/build/plane/process-cwd.js +67 -0
- package/build/plane/restart.js +542 -0
- package/build/plane/shutdown.js +31 -0
- package/build/plane/status.js +145 -6
- package/build/plane/supervisor.js +588 -177
- package/build/plane/types.js +54 -2
- package/build/plane/worker-exit-code.js +2 -0
- package/build/printed-remedy-commands.js +37 -0
- package/build/project-root.js +99 -0
- package/build/readiness-check.js +1 -1
- package/build/readme.generated.js +1 -1
- package/build/review-generation.js +22 -0
- package/build/review-tickets.js +156 -13
- package/build/setup-epic.js +224 -127
- package/build/sfcc/config.js +9 -0
- package/build/sfcc/credentials.js +100 -38
- package/build/sfcc/hostname.js +67 -0
- package/build/sfcc/permissions.js +17 -13
- package/build/sfcc/register.js +1 -1
- package/build/sfcc/setup-status.js +2 -0
- package/build/sfcc/tool-wrapper.js +41 -43
- package/build/sfcc/tools-configured.js +96 -0
- package/build/sfcc/write-grants.js +17 -11
- package/build/sfcc/write-result.js +4 -3
- package/build/sfcc/writes-site-preference.js +20 -8
- package/build/start-tickets.js +332 -79
- package/build/tool-error-envelope.js +61 -0
- package/build/upgrade-cli.js +109 -24
- package/build/version.generated.js +3 -3
- package/build/visual-diff.js +22 -1
- package/build/windows-npm-entry.js +103 -0
- package/build/worker-guard-hook-bin.js +3 -5
- package/build/worker-guard-hook-main.js +47 -0
- package/build/worktree-core.js +136 -9
- package/docs/EPIC-IMPLEMENTER-INSTALL.md +849 -0
- package/docs/EPIC-IMPLEMENTER.md +662 -0
- package/docs/install/mcp-tool-integrations.md +25 -15
- package/docs/install/sfcc-integration.md +21 -8
- package/package.json +1 -1
- package/pipelines/implement-ticket.json +1 -1
- package/pipelines/plan-epic.json +0 -5
- package/pipelines/review-ticket.json +1 -1
- package/docs/CONDUCTOR.md +0 -605
- package/pipelines/idea-to-pr.json +0 -49
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* SFCC credential resolution — dw.json only (D-2: no Bridge credential store).
|
|
3
3
|
*
|
|
4
|
-
* Precedence: explicit tool arg
|
|
4
|
+
* Precedence: explicit tool arg (matched against dw.json, then environment
|
|
5
|
+
* variables) → environment variable → dw.json.
|
|
5
6
|
* Multi-sandbox detection fails loudly with actionable guidance; no silent
|
|
6
7
|
* first-pick. Secret values (client_secret, tokens) are never included in
|
|
7
8
|
* any returned or thrown string — only source and target are reported.
|
|
8
9
|
*/
|
|
9
10
|
import { ensureGitInfoExcluded } from "../git-ignore-utils.js";
|
|
11
|
+
import { canonicalizeSfccHostname, formatSafeHost } from "./hostname.js";
|
|
10
12
|
import { readFile, writeFile, mkdir } from "fs/promises";
|
|
11
13
|
import path from "path";
|
|
12
14
|
// ---------------------------------------------------------------------------
|
|
@@ -20,13 +22,66 @@ const DW_JSON = "dw.json";
|
|
|
20
22
|
// ---------------------------------------------------------------------------
|
|
21
23
|
// Helpers
|
|
22
24
|
// ---------------------------------------------------------------------------
|
|
23
|
-
/** Redact-safe
|
|
25
|
+
/** Redact-safe full hostname, bounded; a non-hostname becomes a fixed label. */
|
|
24
26
|
function safeHostLabel(hostname) {
|
|
25
|
-
return hostname
|
|
27
|
+
return formatSafeHost(hostname);
|
|
26
28
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
/** A dw.json config's credential fields, or null when any is missing. */
|
|
30
|
+
function readConfigCredentials(cfg) {
|
|
31
|
+
const hostname = String(cfg.hostname ?? cfg.host ?? "");
|
|
32
|
+
const clientId = String(cfg["client-id"] ?? cfg.clientId ?? cfg.client_id ?? "");
|
|
33
|
+
const clientSecret = String(cfg["client-secret"] ?? cfg.clientSecret ?? cfg.client_secret ?? "");
|
|
34
|
+
if (!hostname || !clientId || !clientSecret)
|
|
35
|
+
return null;
|
|
36
|
+
return { hostname, clientId, clientSecret };
|
|
37
|
+
}
|
|
38
|
+
/** Every config in a parsed dw.json: the `configs` array, plus a top-level config. */
|
|
39
|
+
function allConfigs(dwJson) {
|
|
40
|
+
const configs = Array.isArray(dwJson.configs) ? dwJson.configs : [];
|
|
41
|
+
const list = configs.filter((c) => typeof c === "object" && c !== null);
|
|
42
|
+
if (dwJson.hostname !== undefined || dwJson.host !== undefined)
|
|
43
|
+
list.push(dwJson);
|
|
44
|
+
return list;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Read dw.json once, keeping the git-exclude side effect.
|
|
48
|
+
*
|
|
49
|
+
* "absent" means the file genuinely is not there (or could not be read at all);
|
|
50
|
+
* "invalid" means it is present but is not a JSON object. The gate treats only
|
|
51
|
+
* "absent" as "this repository is not set up for SFCC".
|
|
52
|
+
*/
|
|
53
|
+
async function loadDwJson(deps) {
|
|
54
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
55
|
+
const rf = deps.readFile ?? ((p) => readFile(p, "utf-8"));
|
|
56
|
+
const wf = deps.writeFile ?? ((p, data) => writeFile(p, data, "utf-8"));
|
|
57
|
+
const mk = deps.mkdir ?? ((p, opts) => mkdir(p, opts));
|
|
58
|
+
// Ensure dw.json is excluded from git tracking.
|
|
59
|
+
try {
|
|
60
|
+
await ensureGitInfoExcluded(cwd, DW_JSON, { readFile: rf, writeFile: wf, mkdir: mk });
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// Non-fatal — continue even if we can't update .git/info/exclude.
|
|
64
|
+
}
|
|
65
|
+
let raw;
|
|
66
|
+
try {
|
|
67
|
+
raw = await rf(path.join(cwd, DW_JSON));
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return { state: "absent" };
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
const parsed = JSON.parse(raw);
|
|
74
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
75
|
+
return { state: "invalid" };
|
|
76
|
+
}
|
|
77
|
+
return { state: "ok", dwJson: parsed };
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return { state: "invalid" };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const DW_JSON_ABSENT_MESSAGE = "Could not read dw.json. Create a dw.json file in your project root " +
|
|
84
|
+
"with your SFCC sandbox credentials (hostname, client-id, client-secret).";
|
|
30
85
|
/**
|
|
31
86
|
* Resolve SFCC credentials with strict precedence and redacted error messages.
|
|
32
87
|
*
|
|
@@ -37,14 +92,34 @@ function safeHostLabel(hostname) {
|
|
|
37
92
|
export async function resolveSfccCredentials(explicitHostname, env = process.env, deps = {}) {
|
|
38
93
|
// --- Tier 1: explicit tool argument ---
|
|
39
94
|
if (explicitHostname) {
|
|
95
|
+
// A dw.json config for this exact host wins; environment credentials are
|
|
96
|
+
// the fallback when the file is absent, unusable, or has no usable match.
|
|
97
|
+
const explicitCanonical = canonicalizeSfccHostname(explicitHostname);
|
|
98
|
+
const loaded = await loadDwJson(deps);
|
|
99
|
+
if (loaded.state === "ok" && explicitCanonical !== null) {
|
|
100
|
+
for (const cfg of allConfigs(loaded.dwJson)) {
|
|
101
|
+
const cfgHost = canonicalizeSfccHostname(String(cfg.hostname ?? cfg.host ?? ""));
|
|
102
|
+
if (cfgHost !== explicitCanonical)
|
|
103
|
+
continue;
|
|
104
|
+
const found = readConfigCredentials(cfg);
|
|
105
|
+
if (found) {
|
|
106
|
+
return {
|
|
107
|
+
ok: true,
|
|
108
|
+
credentials: { ...found, source: "dw.json (explicit instance match)" },
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
40
113
|
const clientId = env[ENV_CLIENT_ID];
|
|
41
114
|
const clientSecret = env[ENV_CLIENT_SECRET];
|
|
42
115
|
if (!clientId || !clientSecret) {
|
|
43
116
|
return {
|
|
44
117
|
ok: false,
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
`
|
|
118
|
+
kind: "explicit-instance-unresolved",
|
|
119
|
+
error: `Explicit instance '${safeHostLabel(explicitHostname)}' could not be resolved: ` +
|
|
120
|
+
`no complete dw.json config matches it, and ${ENV_CLIENT_ID} and/or ` +
|
|
121
|
+
`${ENV_CLIENT_SECRET} are not set in environment. Add a matching config ` +
|
|
122
|
+
`to dw.json or set them, then retry.`,
|
|
48
123
|
};
|
|
49
124
|
}
|
|
50
125
|
return {
|
|
@@ -73,30 +148,19 @@ export async function resolveSfccCredentials(explicitHostname, env = process.env
|
|
|
73
148
|
};
|
|
74
149
|
}
|
|
75
150
|
// --- Tier 3: dw.json ---
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const mk = deps.mkdir ?? ((p, opts) => mkdir(p, opts));
|
|
80
|
-
// Ensure dw.json is excluded from git tracking.
|
|
81
|
-
try {
|
|
82
|
-
await ensureGitInfoExcluded(cwd, DW_JSON, { readFile: rf, writeFile: wf, mkdir: mk });
|
|
83
|
-
}
|
|
84
|
-
catch {
|
|
85
|
-
// Non-fatal — continue even if we can't update .git/info/exclude.
|
|
151
|
+
const loaded = await loadDwJson(deps);
|
|
152
|
+
if (loaded.state === "absent") {
|
|
153
|
+
return { ok: false, kind: "no-dw-json", error: DW_JSON_ABSENT_MESSAGE };
|
|
86
154
|
}
|
|
87
|
-
|
|
88
|
-
let dwJson;
|
|
89
|
-
try {
|
|
90
|
-
const raw = await rf(dwJsonPath);
|
|
91
|
-
dwJson = JSON.parse(raw);
|
|
92
|
-
}
|
|
93
|
-
catch {
|
|
155
|
+
if (loaded.state === "invalid") {
|
|
94
156
|
return {
|
|
95
157
|
ok: false,
|
|
96
|
-
|
|
97
|
-
|
|
158
|
+
kind: "dw-json-invalid",
|
|
159
|
+
error: "dw.json is present but is not a valid JSON object. Fix its syntax; it needs " +
|
|
160
|
+
"hostname, client-id, and client-secret.",
|
|
98
161
|
};
|
|
99
162
|
}
|
|
163
|
+
const dwJson = loaded.dwJson;
|
|
100
164
|
// Detect multiple sandbox configurations.
|
|
101
165
|
// dw.json may carry a top-level 'configs' array (multi-sandbox format) or just a single config.
|
|
102
166
|
const configs = Array.isArray(dwJson.configs)
|
|
@@ -104,22 +168,22 @@ export async function resolveSfccCredentials(explicitHostname, env = process.env
|
|
|
104
168
|
: null;
|
|
105
169
|
if (configs && configs.length > 1) {
|
|
106
170
|
const instances = configs
|
|
107
|
-
.map((c) => safeHostLabel(String(c.hostname ?? c.host ?? "
|
|
171
|
+
.map((c) => safeHostLabel(String(c.hostname ?? c.host ?? "")))
|
|
108
172
|
.join(", ");
|
|
109
173
|
return {
|
|
110
174
|
ok: false,
|
|
175
|
+
kind: "multi-sandbox",
|
|
111
176
|
error: `dw.json contains multiple sandboxes (${instances}). ` +
|
|
112
|
-
`Pass
|
|
177
|
+
`Pass one of these as 'instance' to select it: ${instances}.`,
|
|
113
178
|
};
|
|
114
179
|
}
|
|
115
180
|
// Extract single-config credentials — support both top-level and configs[0] shapes.
|
|
116
181
|
const cfg = configs && configs.length === 1 ? configs[0] : dwJson;
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
const clientSecret = String(cfg["client-secret"] ?? cfg.clientSecret ?? cfg.client_secret ?? "");
|
|
120
|
-
if (!hostname || !clientId || !clientSecret) {
|
|
182
|
+
const found = readConfigCredentials(cfg);
|
|
183
|
+
if (!found) {
|
|
121
184
|
return {
|
|
122
185
|
ok: false,
|
|
186
|
+
kind: "dw-json-invalid",
|
|
123
187
|
error: "dw.json is present but missing required fields (hostname, client-id, client-secret). " +
|
|
124
188
|
"Ensure all three fields are set.",
|
|
125
189
|
};
|
|
@@ -127,10 +191,8 @@ export async function resolveSfccCredentials(explicitHostname, env = process.env
|
|
|
127
191
|
return {
|
|
128
192
|
ok: true,
|
|
129
193
|
credentials: {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
clientSecret,
|
|
133
|
-
source: `dw.json (instance: ${safeHostLabel(hostname)})`,
|
|
194
|
+
...found,
|
|
195
|
+
source: `dw.json (instance: ${safeHostLabel(found.hostname)})`,
|
|
134
196
|
},
|
|
135
197
|
};
|
|
136
198
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared SFCC hostname helpers.
|
|
3
|
+
*
|
|
4
|
+
* `canonicalizeSfccHostname` lives here so the target boundary
|
|
5
|
+
* (`tool-wrapper.ts`), the credential resolver, and `check_permissions` all
|
|
6
|
+
* canonicalize and display hosts the same way. `tool-wrapper.ts` re-exports it
|
|
7
|
+
* so its public import surface is unchanged.
|
|
8
|
+
*/
|
|
9
|
+
/** Longest hostname echoed in a diagnostic. */
|
|
10
|
+
export const MAX_DIAGNOSTIC_HOST_LENGTH = 120;
|
|
11
|
+
/** Shown in place of any value that is not a bare hostname. */
|
|
12
|
+
export const INVALID_HOST_LABEL = "<invalid host>";
|
|
13
|
+
/** One DNS label: 1–63 chars, alphanumeric, internal hyphens only. */
|
|
14
|
+
const DNS_LABEL = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
15
|
+
/**
|
|
16
|
+
* Reduce a raw hostname to a canonical comparable form, or `null` if it is not
|
|
17
|
+
* a bare hostname at all.
|
|
18
|
+
*
|
|
19
|
+
* Only DNS-equivalent normalization is applied — lowercasing and stripping a
|
|
20
|
+
* single trailing root dot. Everything else is a rejection rather than a
|
|
21
|
+
* repair: a value carrying a scheme, port, path, query, credentials, or
|
|
22
|
+
* whitespace is not a hostname, and "cleaning it up" would mean guessing which
|
|
23
|
+
* part the operator meant. Guessing is how a boundary gets bypassed, so this
|
|
24
|
+
* returns `null` and the caller fails closed.
|
|
25
|
+
*/
|
|
26
|
+
export function canonicalizeSfccHostname(raw) {
|
|
27
|
+
if (typeof raw !== "string")
|
|
28
|
+
return null;
|
|
29
|
+
let value = raw.trim().toLowerCase();
|
|
30
|
+
if (value.endsWith("."))
|
|
31
|
+
value = value.slice(0, -1);
|
|
32
|
+
if (value === "" || value.length > 253)
|
|
33
|
+
return null;
|
|
34
|
+
// Anything that is not strictly `label(.label)*` — scheme, port, path, query,
|
|
35
|
+
// userinfo, whitespace, IPv6 brackets, wildcards.
|
|
36
|
+
if (!/^[a-z0-9.-]+$/.test(value))
|
|
37
|
+
return null;
|
|
38
|
+
if (value.includes(".."))
|
|
39
|
+
return null;
|
|
40
|
+
const labels = value.split(".");
|
|
41
|
+
if (labels.length < 2)
|
|
42
|
+
return null;
|
|
43
|
+
if (!labels.every((label) => DNS_LABEL.test(label)))
|
|
44
|
+
return null;
|
|
45
|
+
// A bare IPv4 address is not a sandbox hostname and must not be canonicalized
|
|
46
|
+
// into one; the suffix check in the boundary would reject it anyway, but
|
|
47
|
+
// rejecting here keeps "is this a hostname?" and "is this host approved?"
|
|
48
|
+
// separate concerns.
|
|
49
|
+
if (/^\d+(\.\d+)*$/.test(value))
|
|
50
|
+
return null;
|
|
51
|
+
return value;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Render a host for a diagnostic message.
|
|
55
|
+
*
|
|
56
|
+
* The value is canonicalized first. A value that is not a bare hostname is
|
|
57
|
+
* replaced by a fixed label, so arbitrary input (which may carry a secret) is
|
|
58
|
+
* never echoed. A valid hostname is shown in full, bounded to 120 characters.
|
|
59
|
+
*/
|
|
60
|
+
export function formatSafeHost(raw) {
|
|
61
|
+
const canonical = canonicalizeSfccHostname(raw);
|
|
62
|
+
if (canonical === null)
|
|
63
|
+
return INVALID_HOST_LABEL;
|
|
64
|
+
return canonical.length > MAX_DIAGNOSTIC_HOST_LENGTH
|
|
65
|
+
? `${canonical.slice(0, MAX_DIAGNOSTIC_HOST_LENGTH)}…`
|
|
66
|
+
: canonical;
|
|
67
|
+
}
|
|
@@ -8,14 +8,15 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { ocapiGet } from "./client.js";
|
|
10
10
|
import { normalizeOcapiPage } from "./ocapi-shape.js";
|
|
11
|
-
import { DEFAULT_OCAPI_VERSION } from "./config.js";
|
|
11
|
+
import { DEFAULT_OCAPI_VERSION, OCAPI_SETTINGS_STRUCTURE_VERSION } from "./config.js";
|
|
12
|
+
import { formatSafeHost } from "./hostname.js";
|
|
12
13
|
import { formatOcapiWriteGrantJson } from "./write-grants.js";
|
|
13
14
|
// ---------------------------------------------------------------------------
|
|
14
15
|
// OCAPI Settings JSON template
|
|
15
16
|
// Operators paste this in: Administration > Site Development > Open Commerce API Settings → Data API tab.
|
|
16
17
|
// ---------------------------------------------------------------------------
|
|
17
|
-
const OCAPI_SETTINGS_READ_SEARCH = (
|
|
18
|
-
_v:
|
|
18
|
+
const OCAPI_SETTINGS_READ_SEARCH = () => JSON.stringify({
|
|
19
|
+
_v: OCAPI_SETTINGS_STRUCTURE_VERSION,
|
|
19
20
|
clients: [
|
|
20
21
|
{
|
|
21
22
|
client_id: "<YOUR_CLIENT_ID>",
|
|
@@ -52,8 +53,9 @@ const OCAPI_SETTINGS_READ_SEARCH = (ocapiVersion) => JSON.stringify({
|
|
|
52
53
|
// JSON printed here is byte-identical to what future write handlers echo on a
|
|
53
54
|
// 403. This now covers all required write resource families:
|
|
54
55
|
// /system_object_definitions, /system_object_definitions/**,
|
|
55
|
-
// /custom_object_definitions/**, and /site_preferences
|
|
56
|
-
|
|
56
|
+
// /custom_object_definitions/**, and the site-scoped /sites/*/site_preferences/**
|
|
57
|
+
// (the org-level /site_preferences/** above stays read/search only).
|
|
58
|
+
const OCAPI_SETTINGS_WRITE_IMPORT = () => formatOcapiWriteGrantJson();
|
|
57
59
|
// ---------------------------------------------------------------------------
|
|
58
60
|
// Tool implementation
|
|
59
61
|
// ---------------------------------------------------------------------------
|
|
@@ -75,7 +77,7 @@ export async function checkPermissionsTool(credentials, ocapiVersion = DEFAULT_O
|
|
|
75
77
|
{
|
|
76
78
|
type: "text",
|
|
77
79
|
text: `OCAPI probe failed: ${msg}\n\n` +
|
|
78
|
-
ocapiSettingsInstructions(
|
|
80
|
+
ocapiSettingsInstructions(),
|
|
79
81
|
},
|
|
80
82
|
],
|
|
81
83
|
};
|
|
@@ -86,8 +88,10 @@ export async function checkPermissionsTool(credentials, ocapiVersion = DEFAULT_O
|
|
|
86
88
|
content: [
|
|
87
89
|
{
|
|
88
90
|
type: "text",
|
|
89
|
-
text: `✓ OCAPI access confirmed
|
|
90
|
-
`
|
|
91
|
+
text: `✓ OCAPI read access confirmed (GET /system_object_definitions). ` +
|
|
92
|
+
`Write access is not probed; the sfcc-write tools need the mutation grant — ` +
|
|
93
|
+
`run check_permissions after a 403 or see SFCC-SETUP-CHECKLIST.md.\n` +
|
|
94
|
+
` Instance: ${formatSafeHost(credentials.hostname)}\n` +
|
|
91
95
|
` OCAPI version: ${ocapiVersion}\n` +
|
|
92
96
|
` GET /system_object_definitions: ${formatProbeCount(normalized)}`,
|
|
93
97
|
},
|
|
@@ -99,8 +103,8 @@ export async function checkPermissionsTool(credentials, ocapiVersion = DEFAULT_O
|
|
|
99
103
|
content: [
|
|
100
104
|
{
|
|
101
105
|
type: "text",
|
|
102
|
-
text: `HTTP ${result.status}: OCAPI access denied for instance ${credentials.hostname
|
|
103
|
-
ocapiSettingsInstructions(
|
|
106
|
+
text: `HTTP ${result.status}: OCAPI access denied for instance ${formatSafeHost(credentials.hostname)}.\n\n` +
|
|
107
|
+
ocapiSettingsInstructions(),
|
|
104
108
|
},
|
|
105
109
|
],
|
|
106
110
|
};
|
|
@@ -157,13 +161,13 @@ function formatProbeCount(normalized) {
|
|
|
157
161
|
* A label that understates a grant is worse than no label, because the operator
|
|
158
162
|
* stops reading the JSON once the heading has told them it is safe.
|
|
159
163
|
*/
|
|
160
|
-
function ocapiSettingsInstructions(
|
|
164
|
+
function ocapiSettingsInstructions() {
|
|
161
165
|
return (`To grant OCAPI access, paste the JSON below in Business Manager:\n` +
|
|
162
166
|
` Administration > Site Development > Open Commerce API Settings → Data API tab\n\n` +
|
|
163
167
|
`--- READ/SEARCH TOOL GRANTS (required for the sfcc read tools) ---\n` +
|
|
164
|
-
`${OCAPI_SETTINGS_READ_SEARCH(
|
|
168
|
+
`${OCAPI_SETTINGS_READ_SEARCH()}\n\n` +
|
|
165
169
|
`--- MUTATION GRANTS (required for the nine sfcc-write tools) ---\n` +
|
|
166
|
-
`${OCAPI_SETTINGS_WRITE_IMPORT(
|
|
170
|
+
`${OCAPI_SETTINGS_WRITE_IMPORT()}\n\n` +
|
|
167
171
|
`Replace <YOUR_CLIENT_ID> with the client_id from your dw.json.`);
|
|
168
172
|
}
|
|
169
173
|
/**
|
package/build/sfcc/register.js
CHANGED
|
@@ -89,7 +89,7 @@ export function registerSfccTools(registerTool, deps) {
|
|
|
89
89
|
const gatedCheckPermissions = withSfccGate(gateDeps, async (_args, credentials) => checkPermissionsTool(credentials));
|
|
90
90
|
registerSfccTool("check_permissions", {
|
|
91
91
|
description: "Probe SFCC OCAPI access via GET /system_object_definitions. " +
|
|
92
|
-
"On 200: reports OK and the detected OCAPI version. " +
|
|
92
|
+
"On 200: reports read access OK and the detected OCAPI version. " +
|
|
93
93
|
"On 401/403: prints the exact OCAPI Settings JSON to paste in Business Manager " +
|
|
94
94
|
"(read/search and mutation grants).",
|
|
95
95
|
inputSchema: z.object({
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* prevent the others from running.
|
|
7
7
|
*/
|
|
8
8
|
import { SFCC_VERSIONS, getSfccVersionConfig } from "./config.js";
|
|
9
|
+
import { publishSfccVersion } from "./tools-configured.js";
|
|
9
10
|
import { getAmToken } from "./client.js";
|
|
10
11
|
// ---------------------------------------------------------------------------
|
|
11
12
|
// Implementation
|
|
@@ -38,6 +39,7 @@ export async function sfccSetupStatusTool(deps) {
|
|
|
38
39
|
if (apiKeyOk && repoOk) {
|
|
39
40
|
try {
|
|
40
41
|
resolvedVersion = await getSfccVersionConfig(deps.buildGetUrl, deps.getGetHeaders, deps.repoName);
|
|
42
|
+
publishSfccVersion(resolvedVersion);
|
|
41
43
|
if (resolvedVersion === null) {
|
|
42
44
|
versionStatus = "✗ Not set (configure the 'version' field in Bridge API project settings)";
|
|
43
45
|
}
|
|
@@ -62,8 +62,13 @@
|
|
|
62
62
|
* no per-tool opt-in and no exemption flag.
|
|
63
63
|
*/
|
|
64
64
|
import { SFCC_VERSIONS, getSfccVersionConfig } from "./config.js";
|
|
65
|
+
import { publishSfccVersion } from "./tools-configured.js";
|
|
65
66
|
import { resolveSfccCredentials } from "./credentials.js";
|
|
66
67
|
import { formatSfccFailure } from "./sfcc-result.js";
|
|
68
|
+
import { canonicalizeSfccHostname, formatSafeHost } from "./hostname.js";
|
|
69
|
+
// `canonicalizeSfccHostname` moved to hostname.ts; re-exported so existing
|
|
70
|
+
// importers of this module are unaffected.
|
|
71
|
+
export { canonicalizeSfccHostname };
|
|
67
72
|
// ---------------------------------------------------------------------------
|
|
68
73
|
// Hostname policy
|
|
69
74
|
// ---------------------------------------------------------------------------
|
|
@@ -107,47 +112,8 @@ export const APPROVED_SANDBOX_HOST_FORMS = [
|
|
|
107
112
|
"<realm>-<nnn>.sandbox.dx.commercecloud.salesforce.com",
|
|
108
113
|
"<realm>-<nnn>.dx.commercecloud.salesforce.com",
|
|
109
114
|
];
|
|
110
|
-
/** One DNS label: 1–63 chars, alphanumeric, internal hyphens only. */
|
|
111
|
-
const DNS_LABEL = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
112
115
|
/** Form B's leading label: an SFCC realm id followed by a numeric instance. */
|
|
113
116
|
const REALM_INSTANCE_LABEL = /^[a-z0-9]+-\d{3}$/;
|
|
114
|
-
/**
|
|
115
|
-
* Reduce a raw hostname to a canonical comparable form, or `null` if it is not
|
|
116
|
-
* a bare hostname at all.
|
|
117
|
-
*
|
|
118
|
-
* Only DNS-equivalent normalization is applied — lowercasing and stripping a
|
|
119
|
-
* single trailing root dot. Everything else is a rejection rather than a
|
|
120
|
-
* repair: a value carrying a scheme, port, path, query, credentials, or
|
|
121
|
-
* whitespace is not a hostname, and "cleaning it up" would mean guessing which
|
|
122
|
-
* part the operator meant. Guessing is how a boundary gets bypassed, so this
|
|
123
|
-
* returns `null` and the caller fails closed.
|
|
124
|
-
*/
|
|
125
|
-
export function canonicalizeSfccHostname(raw) {
|
|
126
|
-
if (typeof raw !== "string")
|
|
127
|
-
return null;
|
|
128
|
-
let value = raw.trim().toLowerCase();
|
|
129
|
-
if (value.endsWith("."))
|
|
130
|
-
value = value.slice(0, -1);
|
|
131
|
-
if (value === "" || value.length > 253)
|
|
132
|
-
return null;
|
|
133
|
-
// Anything that is not strictly `label(.label)*` — scheme, port, path, query,
|
|
134
|
-
// userinfo, whitespace, IPv6 brackets, wildcards.
|
|
135
|
-
if (!/^[a-z0-9.-]+$/.test(value))
|
|
136
|
-
return null;
|
|
137
|
-
if (value.includes(".."))
|
|
138
|
-
return null;
|
|
139
|
-
const labels = value.split(".");
|
|
140
|
-
if (labels.length < 2)
|
|
141
|
-
return null;
|
|
142
|
-
if (!labels.every((label) => DNS_LABEL.test(label)))
|
|
143
|
-
return null;
|
|
144
|
-
// A bare IPv4 address is not a sandbox hostname and must not be canonicalized
|
|
145
|
-
// into one; the suffix check below would reject it anyway, but rejecting here
|
|
146
|
-
// keeps "is this a hostname?" and "is this host approved?" separate concerns.
|
|
147
|
-
if (/^\d+(\.\d+)*$/.test(value))
|
|
148
|
-
return null;
|
|
149
|
-
return value;
|
|
150
|
-
}
|
|
151
117
|
/**
|
|
152
118
|
* Decide whether a canonical hostname is an approved developer sandbox.
|
|
153
119
|
*
|
|
@@ -215,9 +181,9 @@ export function isApprovedSfccSandboxHost(canonical) {
|
|
|
215
181
|
* raw `dw.json` contents never enter this envelope.
|
|
216
182
|
*/
|
|
217
183
|
function targetNotSandbox(rejectedHostname) {
|
|
218
|
-
// A
|
|
219
|
-
//
|
|
220
|
-
const safeRejected = rejectedHostname
|
|
184
|
+
// A valid host is echoed in full but bounded; anything that is not a bare
|
|
185
|
+
// hostname is replaced by a fixed label so arbitrary input is never echoed.
|
|
186
|
+
const safeRejected = formatSafeHost(rejectedHostname);
|
|
221
187
|
return formatSfccFailure({
|
|
222
188
|
code: "TARGET_NOT_SANDBOX",
|
|
223
189
|
status: 403,
|
|
@@ -266,9 +232,19 @@ export function withSfccTargetBoundary(handler, deps = {}) {
|
|
|
266
232
|
// actually returned.
|
|
267
233
|
const rawInstance = typeof safeArgs.instance === "string" ? safeArgs.instance : undefined;
|
|
268
234
|
const explicitHostname = rawInstance && rawInstance.includes(".") ? rawInstance : undefined;
|
|
235
|
+
// Refuse an unsafe explicit host BEFORE any credential lookup. The check
|
|
236
|
+
// below on the resolved host stays: this one only adds a refusal earlier, so
|
|
237
|
+
// an unknown host never reaches the resolver, and a host that resolution
|
|
238
|
+
// selects is still validated after it.
|
|
239
|
+
if (explicitHostname !== undefined) {
|
|
240
|
+
const explicitCanonical = canonicalizeSfccHostname(explicitHostname);
|
|
241
|
+
if (explicitCanonical === null || !isApprovedSfccSandboxHost(explicitCanonical)) {
|
|
242
|
+
return targetNotSandbox(explicitHostname);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
269
245
|
const credResult = await resolve(explicitHostname);
|
|
270
246
|
if (!credResult.ok) {
|
|
271
|
-
return handler(safeArgs, { approved: false, error: credResult.error });
|
|
247
|
+
return handler(safeArgs, { approved: false, error: credResult.error, kind: credResult.kind });
|
|
272
248
|
}
|
|
273
249
|
const rawHostname = credResult.credentials.hostname;
|
|
274
250
|
const canonical = canonicalizeSfccHostname(rawHostname);
|
|
@@ -333,6 +309,22 @@ function notConfigured(failureClass, message) {
|
|
|
333
309
|
details: { failure_class: failureClass },
|
|
334
310
|
});
|
|
335
311
|
}
|
|
312
|
+
/**
|
|
313
|
+
* Build the failure for a credential problem on a repository that IS set up.
|
|
314
|
+
*
|
|
315
|
+
* The repo has a `dw.json` (or the caller named an explicit instance), so the
|
|
316
|
+
* caller can fix the input. That is why this carries no `NO_RETRY_INSTRUCTION`
|
|
317
|
+
* and is a 400, not the 503 "not configured" envelope.
|
|
318
|
+
*/
|
|
319
|
+
function credentialResolutionFailed(message) {
|
|
320
|
+
return formatSfccFailure({
|
|
321
|
+
code: "CREDENTIAL_RESOLUTION_FAILED",
|
|
322
|
+
status: 400,
|
|
323
|
+
message,
|
|
324
|
+
source: "gate",
|
|
325
|
+
details: { failure_class: "credential-resolution" },
|
|
326
|
+
});
|
|
327
|
+
}
|
|
336
328
|
// ---------------------------------------------------------------------------
|
|
337
329
|
// Gate
|
|
338
330
|
// ---------------------------------------------------------------------------
|
|
@@ -385,6 +377,7 @@ export function withSfccGate(deps, handler) {
|
|
|
385
377
|
}
|
|
386
378
|
// (a) Read /config-field/version
|
|
387
379
|
const version = await getSfccVersionConfig(deps.buildGetUrl, deps.getGetHeaders, deps.repoName);
|
|
380
|
+
publishSfccVersion(version);
|
|
388
381
|
if (version === null) {
|
|
389
382
|
return notConfigured("bridge-auth", "Could not read the SFCC version from Bridge API (/config-field/version). " +
|
|
390
383
|
"Ensure your Bridge API key is set and the repo is configured. " +
|
|
@@ -400,6 +393,11 @@ export function withSfccGate(deps, handler) {
|
|
|
400
393
|
// reaches here — the boundary already refused it — so the only remaining
|
|
401
394
|
// case is credential resolution having failed outright.
|
|
402
395
|
if (!target.approved) {
|
|
396
|
+
// Only a genuinely absent dw.json means "this repo is not set up for
|
|
397
|
+
// SFCC". Every other resolver failure is fixable by the caller.
|
|
398
|
+
if (target.kind !== undefined && target.kind !== "no-dw-json") {
|
|
399
|
+
return credentialResolutionFailed(target.error);
|
|
400
|
+
}
|
|
403
401
|
return notConfigured("missing-dw-json", `SFCC credential resolution failed: ${target.error} ` +
|
|
404
402
|
"Ensure a dw.json file exists in your project root with " +
|
|
405
403
|
"hostname, client-id, and client-secret fields.");
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/** Process-local, secret-free SFCC setup verdict for authenticated Bridge requests. */
|
|
2
|
+
import { access } from "fs/promises";
|
|
3
|
+
import { constants } from "fs";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import { resolveSfccCredentials } from "./credentials.js";
|
|
6
|
+
import { getSfccVersionConfig, SFCC_VERSIONS } from "./config.js";
|
|
7
|
+
const DIAGNOSTIC = "SFCC tools header omitted: version lookup did not establish SFCC setup; check setup status.";
|
|
8
|
+
/** Existence and readability are enough to establish the local gate. */
|
|
9
|
+
export async function hasRootDwJson() {
|
|
10
|
+
try {
|
|
11
|
+
await access(path.join(process.cwd(), "dw.json"), constants.R_OK);
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** Isolated state is injectable for offline tests; the MCP uses one instance. */
|
|
19
|
+
export function createSfccToolsHeaderState(deps) {
|
|
20
|
+
let credentialVerdict;
|
|
21
|
+
let knownVersion;
|
|
22
|
+
let lookupStarted = false;
|
|
23
|
+
let diagnosticPrinted = false;
|
|
24
|
+
function getCredentialVerdict() {
|
|
25
|
+
credentialVerdict ??= Promise.resolve().then(() => deps.resolveCredentials(deps.env, {
|
|
26
|
+
...deps.credentialDeps,
|
|
27
|
+
writeFile: async () => { },
|
|
28
|
+
mkdir: async () => { },
|
|
29
|
+
})).then((result) => result.ok ? "yes" : "no")
|
|
30
|
+
.catch(() => "no");
|
|
31
|
+
return credentialVerdict;
|
|
32
|
+
}
|
|
33
|
+
function publishVersion(version) {
|
|
34
|
+
if (version !== null && knownVersion === undefined)
|
|
35
|
+
knownVersion = version;
|
|
36
|
+
if (version !== null && SFCC_VERSIONS.includes(version))
|
|
37
|
+
knownVersion = version;
|
|
38
|
+
}
|
|
39
|
+
function reportUnresolved() {
|
|
40
|
+
if (diagnosticPrinted)
|
|
41
|
+
return;
|
|
42
|
+
diagnosticPrinted = true;
|
|
43
|
+
deps.diagnostic(DIAGNOSTIC);
|
|
44
|
+
}
|
|
45
|
+
async function withHeader(headers, options) {
|
|
46
|
+
if (options.skipSfccToolsHeader)
|
|
47
|
+
return headers;
|
|
48
|
+
try {
|
|
49
|
+
const localFile = await deps.hasDwJson();
|
|
50
|
+
if (knownVersion === undefined && !lookupStarted && options.repoName) {
|
|
51
|
+
lookupStarted = true;
|
|
52
|
+
void deps.getVersion(options.buildGetUrl, options.getLookupHeaders, options.repoName)
|
|
53
|
+
.then((version) => {
|
|
54
|
+
if (version !== null)
|
|
55
|
+
publishVersion(version);
|
|
56
|
+
if (knownVersion === undefined) {
|
|
57
|
+
knownVersion = null;
|
|
58
|
+
if (!localFile)
|
|
59
|
+
reportUnresolved();
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
.catch(() => {
|
|
63
|
+
if (knownVersion === undefined) {
|
|
64
|
+
knownVersion = null;
|
|
65
|
+
if (!localFile)
|
|
66
|
+
reportUnresolved();
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
const isSfcc = knownVersion !== undefined && knownVersion !== null
|
|
71
|
+
&& SFCC_VERSIONS.includes(knownVersion);
|
|
72
|
+
if (!localFile && !isSfcc) {
|
|
73
|
+
if (!options.repoName)
|
|
74
|
+
reportUnresolved();
|
|
75
|
+
return headers;
|
|
76
|
+
}
|
|
77
|
+
headers["X-Bridge-SFCC-Tools"] = await getCredentialVerdict();
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// Optional enrichment cannot interrupt authenticated requests.
|
|
81
|
+
}
|
|
82
|
+
return headers;
|
|
83
|
+
}
|
|
84
|
+
return { getCredentialVerdict, publishVersion, withHeader };
|
|
85
|
+
}
|
|
86
|
+
const processState = createSfccToolsHeaderState({
|
|
87
|
+
hasDwJson: hasRootDwJson,
|
|
88
|
+
resolveCredentials: (env, credentialDeps) => resolveSfccCredentials(undefined, env, credentialDeps),
|
|
89
|
+
credentialDeps: {},
|
|
90
|
+
env: process.env,
|
|
91
|
+
getVersion: getSfccVersionConfig,
|
|
92
|
+
diagnostic: (message) => console.error(message),
|
|
93
|
+
});
|
|
94
|
+
export const getSfccCredentialVerdict = processState.getCredentialVerdict;
|
|
95
|
+
export const publishSfccVersion = processState.publishVersion;
|
|
96
|
+
export const withSfccToolsHeader = processState.withHeader;
|