@bridge_gpt/mcp-server 0.2.66 → 0.2.68
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 +421 -83
- package/build/agent-capabilities/probes.js +15 -5
- package/build/agent-launchers/claude-executor-adapter.js +70 -16
- package/build/agent-launchers/executor-adapter.js +7 -4
- package/build/agents.generated.js +22 -2
- package/build/bitbucket-workspace-remote.js +136 -0
- package/build/bootstrap-project-identity.js +24 -0
- package/build/bounded-wait.js +67 -10
- package/build/bridge-permissions.js +829 -0
- package/build/claude-settings.js +18 -0
- package/build/cli-help.js +21 -0
- package/build/command-assets-doctor.js +24 -70
- package/build/command-provisioning.js +29 -5
- package/build/commands.generated.js +12 -12
- package/build/conductor/bridge-api-client.js +857 -1
- package/build/conductor/bring-up-facts.js +1 -1
- package/build/conductor/command-drift-production.js +92 -0
- package/build/conductor/command-drift.js +89 -0
- package/build/conductor/doctor.js +72 -29
- package/build/conductor/epic-branch-ci-inspection.js +1375 -0
- package/build/conductor/epic-implementer-cli.js +98 -28
- package/build/conductor/errors.js +4 -3
- package/build/conductor/gate-discovery.js +890 -0
- package/build/conductor/guided-repair.js +6 -2
- package/build/conductor/hook-runtime-qualification.js +1 -1
- package/build/conductor/install-doctor.js +15 -3
- package/build/conductor/json-output.js +63 -0
- 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 +113 -12
- package/build/conductor/readiness-sections.js +410 -9
- package/build/conductor/readiness.js +388 -33
- package/build/conductor/recovery-cli.js +310 -12
- package/build/conductor/recovery-operations.js +167 -3
- package/build/conductor/repair-contract.js +54 -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/connect-bitbucket-api.js +41 -6
- package/build/connect-bitbucket.js +106 -33
- package/build/credential-store.js +207 -47
- package/build/docs.generated.js +62 -3
- package/build/doctor.js +92 -1
- package/build/drive-epic-key.js +6 -5
- package/build/drive-epic.js +129 -61
- package/build/epic-coherence-input.js +2 -1
- package/build/epic-coherence-input.test-fixtures.js +4 -3
- package/build/epic-implementer-bin.js +1379 -28
- package/build/epic-implementer-bundle-cli.js +2 -2
- package/build/epic-workarounds-path.js +39 -0
- package/build/executor/cli.js +33 -2
- package/build/executor/completion-contract.js +66 -10
- package/build/executor/completion-supervision.js +4 -0
- package/build/executor/deps.js +4 -1
- package/build/executor/env.js +19 -0
- package/build/executor/gate-qualification-job.js +499 -0
- package/build/executor/guarded-fast-forward-push.js +129 -0
- package/build/executor/heartbeat.js +1 -1
- package/build/executor/http-client.js +20 -0
- package/build/executor/integration-job.js +122 -8
- package/build/executor/job-errors.js +33 -1
- package/build/executor/job-runner.js +813 -87
- package/build/executor/likely-tests.js +290 -0
- package/build/executor/live-worker-registry.js +1 -1
- package/build/executor/observation.js +37 -3
- package/build/executor/packaged-recipe.js +106 -0
- package/build/executor/permissions.js +115 -28
- package/build/executor/platform.js +1 -1
- package/build/executor/preflight.js +112 -26
- package/build/executor/process-containment.js +36 -12
- package/build/executor/process.js +15 -7
- package/build/executor/prompt-spec.js +33 -1
- package/build/executor/recently-failed-tests-file.js +71 -0
- package/build/executor/reconcile-overlap-job.js +41 -23
- package/build/executor/required-verification.js +1 -1
- package/build/executor/required-work-ledger.js +111 -18
- package/build/executor/resume-pre-spawn.js +2 -0
- package/build/executor/retirement.js +69 -0
- package/build/executor/runner.js +122 -13
- package/build/executor/service-unit.js +7 -1
- package/build/executor/test-clock.js +15 -0
- package/build/executor/types.js +32 -0
- package/build/executor/viewer-tabs.js +58 -8
- package/build/executor/worker-command.js +23 -7
- package/build/executor/worker-diagnostics.js +37 -0
- package/build/executor/worker-finalization.js +113 -34
- package/build/executor/worker-guard-hook.js +316 -58
- package/build/executor/worker-log.js +4 -3
- package/build/executor/worker-verification-guidance.js +37 -0
- package/build/executor/worktree.js +166 -8
- package/build/first-build-advisory.js +97 -0
- package/build/generated-assets-advisory.js +14 -0
- package/build/generated-assets-catalog.js +66 -0
- package/build/generated-assets-context.js +46 -0
- package/build/generated-assets-dependencies.js +26 -0
- package/build/generated-assets-doctor.js +50 -0
- package/build/generated-assets-health.js +65 -0
- package/build/generated-assets-host.js +17 -0
- package/build/generated-assets-inventory.js +17 -0
- package/build/generated-assets-paths.js +60 -0
- package/build/generated-assets-publisher.js +133 -0
- package/build/generated-assets-remedy.js +25 -0
- package/build/generated-assets-repair.js +72 -0
- package/build/generated-assets-session.js +71 -0
- package/build/generated-assets-test-support.js +35 -0
- package/build/index.js +1224 -1054
- package/build/init.js +54 -177
- package/build/install-bridge-conductor-gate.js +1372 -0
- package/build/install-bridge-conductor.js +296 -41
- package/build/install-bridge.js +547 -40
- package/build/mcp-install-state.js +50 -4
- package/build/merge-workflow-install.js +3 -4
- package/build/phase-context-git-probe.js +68 -0
- package/build/phase-result-artifacts.js +173 -5
- package/build/pipeline-utils.js +6 -2
- package/build/pipelines.generated.js +34 -160
- package/build/plan-execution-ledger.js +2 -32
- 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 +136 -2
- package/build/plane/member-roster.js +94 -19
- package/build/plane/preflight.js +83 -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 +143 -8
- package/build/plane/supervisor.js +586 -179
- package/build/plane/types.js +54 -2
- package/build/printed-remedy-commands.js +52 -0
- package/build/readiness-check.js +1 -1
- package/build/readme.generated.js +1 -1
- package/build/repair-assets.js +93 -0
- package/build/review-generation.js +22 -0
- package/build/run-unit-tests-launcher.js +130 -49
- package/build/secret-redaction.js +33 -0
- package/build/setup-epic.js +152 -22
- 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/tool-wrapper.js +39 -43
- 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-prereqs.js +70 -16
- package/build/start-tickets.js +208 -36
- package/build/tool-error-envelope.js +61 -0
- package/build/tool-surface-gating.js +36 -0
- package/build/upgrade-cli.js +55 -1
- package/build/version.generated.js +3 -3
- package/build/visual-diff.js +22 -1
- package/build/worker-guard-hook-bin.js +4 -6
- package/build/worker-guard-hook-main.js +47 -0
- package/build/worktree-core.js +145 -6
- package/docs/EPIC-IMPLEMENTER-INSTALL.md +867 -0
- package/docs/EPIC-IMPLEMENTER.md +728 -0
- package/docs/install/mcp-tool-integrations.md +24 -15
- package/docs/install/sfcc-integration.md +12 -4
- package/package.json +3 -3
- package/pipelines/greenfield-setup.json +4 -128
- package/pipelines/implement-ticket.json +3 -2
- 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
package/build/setup-epic.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `setup-epic` — one-command bootstrap for an Epic
|
|
2
|
+
* `setup-epic` — one-command bootstrap for an Epic Implementer run.
|
|
3
3
|
*
|
|
4
4
|
* Creates the epic run, stores the plan DAG, and approves it. Before this
|
|
5
|
-
* existed the only path was hand-rolled HTTP with a hand-computed plan hash
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* existed the only path was hand-rolled HTTP with a hand-computed plan hash —
|
|
6
|
+
* there was no tool to call.
|
|
7
|
+
*
|
|
8
|
+
* This is a separate run-creation workflow, not a `/plan-epic` pipeline step:
|
|
9
|
+
* `/plan-epic` authors the local sidecar and stops. An operator creates the child
|
|
10
|
+
* tickets, finalizes the plan, and then launches through the gated `drive-epic`
|
|
11
|
+
* entry point, which is what reaches this command.
|
|
8
12
|
*
|
|
9
13
|
* Ordering is load-bearing:
|
|
10
14
|
*
|
|
@@ -85,8 +89,9 @@ import { formatEpicLaunchChannelIdentity, getPinnedEpicLaunchPolicy, readEpicLau
|
|
|
85
89
|
// that module never imports from `setup-epic.ts`). Reused here rather than
|
|
86
90
|
// reimplemented so the mapping stays single-defined.
|
|
87
91
|
import { mapLegacyMergeExecutionModeToPolicyMode, resolveMergeExecutionMode, } from "./merge-pull-request.js";
|
|
88
|
-
import { MERGE_EXECUTION_INELIGIBLE, MERGE_EXECUTION_MODE_GITHUB_ACTIONS, MERGE_WORKFLOW_REMEDY_COMMAND, } from "./conductor/merge-execution-contract.js";
|
|
92
|
+
import { MERGE_EXECUTION_INELIGIBLE, MERGE_EXECUTION_MODE_GITHUB_ACTIONS, MERGE_WORKFLOW_CODEOWNER_INSTRUCTION, MERGE_WORKFLOW_REMEDY_COMMAND, } from "./conductor/merge-execution-contract.js";
|
|
89
93
|
import { classifyMergeExecutionEligibility } from "./conductor/merge-execution-eligibility.js";
|
|
94
|
+
import { repositoryWorkflowRemedy } from "./conductor/repository-workflow-remedy.js";
|
|
90
95
|
import { resolveTrackedPaths } from "./git-reachability.js";
|
|
91
96
|
import { validateBranchName } from "./base-ref.js";
|
|
92
97
|
// Reused from the implement-epic client on purpose: adding a second wrapper under
|
|
@@ -100,6 +105,7 @@ import { validateBranchName } from "./base-ref.js";
|
|
|
100
105
|
import { getConfigFieldBaseBranch, getIndexScopeLifecycle, getParseStatus, } from "./epic-shared/bridge-client.js";
|
|
101
106
|
import { boundScopeLifecycleLabel, createExecFileRunCommand, ensureCommitResolvableLocally, performExactIndexScopeCut, pollIndexScopeLifecycle, readRemoteBranchHead, runGit, SCOPE_BOOTSTRAP_POLL_INTERVAL_MS, SCOPE_LIFECYCLE_LABELS, } from "./epic-shared/cut-protocol.js";
|
|
102
107
|
import { hashPlan } from "./conductor/plan.js";
|
|
108
|
+
import { PLACEHOLDER_NODE_KEY_RE } from "./conductor-bundle-artifacts.js";
|
|
103
109
|
import { BRING_UP_REFUSAL_MESSAGES, firstBringUpRefusal, } from "./conductor/bring-up-facts.js";
|
|
104
110
|
import { MCP_PACKAGE_NAME } from "./mcp-identity.js";
|
|
105
111
|
// BAPI-872: local plane-manifest binding, so `plane down` can later stop the
|
|
@@ -164,6 +170,7 @@ export const SETUP_EPIC_REVIEW_POLICY_SOURCES = [
|
|
|
164
170
|
"verdict_protocol",
|
|
165
171
|
"native_review_decision",
|
|
166
172
|
"none",
|
|
173
|
+
"repository_policy",
|
|
167
174
|
];
|
|
168
175
|
export function createDefaultSetupEpicDeps() {
|
|
169
176
|
return {
|
|
@@ -190,7 +197,7 @@ export function getSetupEpicUsage() {
|
|
|
190
197
|
return [
|
|
191
198
|
"Usage: mcp-server setup-epic --epic-key <KEY> --plan-file <path> [options]",
|
|
192
199
|
"",
|
|
193
|
-
"Bootstraps an Epic
|
|
200
|
+
"Bootstraps an Epic Implementer run: creates the run, stores the plan DAG,",
|
|
194
201
|
"and approves it. Idempotent — re-running reuses an existing live run.",
|
|
195
202
|
"",
|
|
196
203
|
"Branch strategy (default): a plan with TWO OR MORE nodes runs on a dedicated",
|
|
@@ -210,11 +217,12 @@ export function getSetupEpicUsage() {
|
|
|
210
217
|
"that selects no epic branch (--into-base, or a single-node plan) is unchanged",
|
|
211
218
|
"and composes no posture at all.",
|
|
212
219
|
"",
|
|
213
|
-
"Unattended runs have
|
|
214
|
-
"run is created: repository consent (ON by default; the repository owner can",
|
|
220
|
+
"Unattended runs have THREE repository prerequisites, refused by name before",
|
|
221
|
+
"any run is created: repository consent (ON by default; the repository owner can",
|
|
215
222
|
"switch 'Allow unattended Epic Implementer runs' off and on in the Bridge admin",
|
|
216
|
-
"panel's Setup page)
|
|
217
|
-
"
|
|
223
|
+
"panel's Setup page); an escalation channel, either a verified notify webhook or",
|
|
224
|
+
"the account's email address; and confirmed review and CI workflows on the",
|
|
225
|
+
"default branch. `drive-epic` readiness lists all three with their current state.",
|
|
218
226
|
"",
|
|
219
227
|
"Required:",
|
|
220
228
|
" --epic-key <KEY> Jira epic key (e.g. BAPI-405)",
|
|
@@ -253,6 +261,10 @@ export function getSetupEpicUsage() {
|
|
|
253
261
|
" --review-policy <src> PER-RUN review policy source, one of:",
|
|
254
262
|
` ${SETUP_EPIC_REVIEW_POLICY_SOURCES.join(", ")}.`,
|
|
255
263
|
" Composed into policy_json.review_policy on create.",
|
|
264
|
+
" Omitted, setup-epic sends no review policy: the server",
|
|
265
|
+
" stamps repository_policy at first approval for a new",
|
|
266
|
+
" feature-branch run with no repository default. AI review",
|
|
267
|
+
" is an explicit opt-in (--review-policy verdict_protocol).",
|
|
256
268
|
" This flag sets the policy for THIS run only and never",
|
|
257
269
|
" changes a repository default. To persist one, pass",
|
|
258
270
|
" --review-policy to `install-bridge conductor`, which",
|
|
@@ -279,7 +291,7 @@ export function getSetupEpicUsage() {
|
|
|
279
291
|
" 10799; remediate / ci_fix 3599; rebase / smoke 2699;",
|
|
280
292
|
" merge 1799); the server rejects a higher value with a",
|
|
281
293
|
" 422 naming the offending field. Full table:",
|
|
282
|
-
" docs/
|
|
294
|
+
" docs/epic-implementer/operator-runbook.md §4.",
|
|
283
295
|
" --replace-policy Authorize replacing a LIVE run's stored policy with the",
|
|
284
296
|
" policy file, as a complete replacement. Without it, a",
|
|
285
297
|
" divergent policy on a reused run is refused with a",
|
|
@@ -297,8 +309,8 @@ export function getSetupEpicUsage() {
|
|
|
297
309
|
" never a substitute for authoritative validation.",
|
|
298
310
|
"",
|
|
299
311
|
"After setup, the server-side reconciler picks the run up within ~30s.",
|
|
300
|
-
"To execute claimed jobs on this machine, run:",
|
|
301
|
-
` npx -y ${MCP_PACKAGE_NAME} executor --repo <name>`,
|
|
312
|
+
"To execute this run's claimed jobs on this machine, run:",
|
|
313
|
+
` npx -y ${MCP_PACKAGE_NAME} executor --repo <name> --epic-run-id <run-id>`,
|
|
302
314
|
].join("\n");
|
|
303
315
|
}
|
|
304
316
|
function takeValue(argv, i, flag) {
|
|
@@ -988,6 +1000,20 @@ export function validateEpicPlanSidecar(parsed) {
|
|
|
988
1000
|
`to get that scheduling protection (use [] when no files are predicted).`);
|
|
989
1001
|
}
|
|
990
1002
|
}
|
|
1003
|
+
// BAPI-1366: refuse unfinalized `TBD-N` node keys before any graph, access, or
|
|
1004
|
+
// network work. `/plan-epic` authors placeholders; finalization replaces them.
|
|
1005
|
+
// This runs here, not in the server lane, so an updated client refuses even
|
|
1006
|
+
// against an older server and in `--dry-run --local-only`.
|
|
1007
|
+
const placeholders = [...keys].filter((key) => PLACEHOLDER_NODE_KEY_RE.test(key)).sort();
|
|
1008
|
+
if (placeholders.length > 0) {
|
|
1009
|
+
return {
|
|
1010
|
+
ok: false,
|
|
1011
|
+
error: `Plan node keys ${placeholders.join(", ")} are placeholders, not tickets. ` +
|
|
1012
|
+
`Create the child tickets and run \`emit-epic-implementer-bundle finalize\` ` +
|
|
1013
|
+
`to replace every placeholder, then run this again (this invocation created ` +
|
|
1014
|
+
`no run, cut no branch, and incurred no charge).`,
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
991
1017
|
// Build one adjacency in a consistent predecessor -> successor direction, the
|
|
992
1018
|
// same way the server does. `depends_on` points at the predecessor; an edge's
|
|
993
1019
|
// `from` IS the predecessor.
|
|
@@ -1242,6 +1268,72 @@ export function policyDeclaresNotifyWebhook(policy) {
|
|
|
1242
1268
|
const url = notify.webhook_url;
|
|
1243
1269
|
return typeof url === "string" && url.trim() !== "";
|
|
1244
1270
|
}
|
|
1271
|
+
/**
|
|
1272
|
+
* BAPI-1397 (F1) — does this policy set `notify.local_sink` together with a
|
|
1273
|
+
* `notify.webhook_url`?
|
|
1274
|
+
*
|
|
1275
|
+
* Mirrors the server's `NotifyPolicy._reject_local_sink_and_webhook_together`
|
|
1276
|
+
* exactly, and nothing more: `local_sink: true` with a PRESENT, non-null
|
|
1277
|
+
* `webhook_url` conflicts; `local_sink: true` with the URL absent or `null` is
|
|
1278
|
+
* fine; `local_sink: false` (or absent) never conflicts. Every other policy rule
|
|
1279
|
+
* stays the server's to judge. Checked locally so a policy the server would
|
|
1280
|
+
* reject is refused before any plane starts or any request is sent.
|
|
1281
|
+
*/
|
|
1282
|
+
export function policyNotifyDestinationsConflict(policy) {
|
|
1283
|
+
if (policy === undefined || policy === null)
|
|
1284
|
+
return false;
|
|
1285
|
+
const notify = policy.notify;
|
|
1286
|
+
if (typeof notify !== "object" || notify === null || Array.isArray(notify))
|
|
1287
|
+
return false;
|
|
1288
|
+
const block = notify;
|
|
1289
|
+
return block.local_sink === true && block.webhook_url !== undefined && block.webhook_url !== null;
|
|
1290
|
+
}
|
|
1291
|
+
/**
|
|
1292
|
+
* Name the selected policy for a launch line or a refusal: its resolved path and
|
|
1293
|
+
* source, or — for a stored or default policy that has no file — a deliberate
|
|
1294
|
+
* no-file label. Only the path is named; the file's content never is.
|
|
1295
|
+
*/
|
|
1296
|
+
export function describeSelectedPolicy(policyPath, source) {
|
|
1297
|
+
return policyPath !== undefined
|
|
1298
|
+
? `'${policyPath}' (${source} policy file)`
|
|
1299
|
+
: `the ${source} run policy (no policy file)`;
|
|
1300
|
+
}
|
|
1301
|
+
/**
|
|
1302
|
+
* The refusal for a policy whose notify destinations conflict. Names the file and
|
|
1303
|
+
* the two keys only — never the webhook URL or any other policy content.
|
|
1304
|
+
*/
|
|
1305
|
+
export function notifyDestinationsConflictMessage(policyPath, source) {
|
|
1306
|
+
return (`The run policy ${describeSelectedPolicy(policyPath, source)} sets notify.local_sink ` +
|
|
1307
|
+
"together with notify.webhook_url. The server permits only one delivery destination, so " +
|
|
1308
|
+
"it would reject this policy. Remove notify.local_sink or notify.webhook_url and re-run. " +
|
|
1309
|
+
"No plane was started and no request was sent.");
|
|
1310
|
+
}
|
|
1311
|
+
/** Group-read and other-read permission bits. */
|
|
1312
|
+
const GROUP_OR_OTHER_READ_BITS = 0o044;
|
|
1313
|
+
/**
|
|
1314
|
+
* BAPI-1397 (F1) — warn when a policy file that carries a `notify.webhook_url` is
|
|
1315
|
+
* readable by other users. A warning only, never a refusal: the launch continues.
|
|
1316
|
+
*
|
|
1317
|
+
* Returns the warning text, or `null` when there is nothing to say — including
|
|
1318
|
+
* when the file's mode cannot be read (a failed `stat` is silently skipped). The
|
|
1319
|
+
* text names the path only; it never carries the URL, the policy body, or any
|
|
1320
|
+
* filesystem error text.
|
|
1321
|
+
*/
|
|
1322
|
+
export async function worldReadableWebhookPolicyWarning(policyPath, policy, stat) {
|
|
1323
|
+
if (!policyDeclaresNotifyWebhook(policy))
|
|
1324
|
+
return null;
|
|
1325
|
+
let mode;
|
|
1326
|
+
try {
|
|
1327
|
+
mode = (await stat(policyPath)).mode;
|
|
1328
|
+
}
|
|
1329
|
+
catch {
|
|
1330
|
+
return null;
|
|
1331
|
+
}
|
|
1332
|
+
if (typeof mode !== "number" || (mode & GROUP_OR_OTHER_READ_BITS) === 0)
|
|
1333
|
+
return null;
|
|
1334
|
+
return (`[warn] Policy file '${policyPath}' holds a notify webhook URL and other users on this ` +
|
|
1335
|
+
`machine can read it. Restrict it with: chmod 600 '${policyPath}'`);
|
|
1336
|
+
}
|
|
1245
1337
|
export function evaluateSetupEpicUnattendedPrerequisites(readiness, requestedPolicy) {
|
|
1246
1338
|
const unattended = readiness?.unattended ?? null;
|
|
1247
1339
|
if (unattended === null) {
|
|
@@ -1293,12 +1385,13 @@ export function evaluateSetupEpicUnattendedPrerequisites(readiness, requestedPol
|
|
|
1293
1385
|
};
|
|
1294
1386
|
}
|
|
1295
1387
|
if (!unattended.repository_readiness_confirmed) {
|
|
1388
|
+
// BAPI-1359: the shared formatter names the specific missing item; the reason
|
|
1389
|
+
// code and the trailing sentence are unchanged.
|
|
1296
1390
|
return {
|
|
1297
1391
|
reason: "repository_readiness_unconfirmed",
|
|
1298
1392
|
message: "the unattended default authorizes automatic merge, which requires the review and " +
|
|
1299
|
-
"
|
|
1300
|
-
|
|
1301
|
-
"attended run. No run was created.",
|
|
1393
|
+
"epic-implementer-ci workflows to be CONFIRMED on the default branch, and they are not. " +
|
|
1394
|
+
`${repositoryWorkflowRemedy(readiness)} No run was created.`,
|
|
1302
1395
|
};
|
|
1303
1396
|
}
|
|
1304
1397
|
return null;
|
|
@@ -1371,8 +1464,10 @@ export function evaluateSetupEpicMergeExecutionPrerequisites(readiness, requeste
|
|
|
1371
1464
|
reason: MERGE_EXECUTION_INELIGIBLE,
|
|
1372
1465
|
message: "this run declares merge_execution_mode: github_actions, and the Bridge API " +
|
|
1373
1466
|
"conductor readiness report could not be read, so the repository's merge " +
|
|
1374
|
-
"capability could not be confirmed. Retry once the server is reachable
|
|
1375
|
-
|
|
1467
|
+
"capability could not be confirmed. Retry once the server is reachable. If " +
|
|
1468
|
+
"readiness then reports the merge workflow missing or out of date, resolve it " +
|
|
1469
|
+
`with \`${MERGE_WORKFLOW_REMEDY_COMMAND}\`. ${MERGE_WORKFLOW_CODEOWNER_INSTRUCTION} ` +
|
|
1470
|
+
NO_RUN_WAS_CREATED,
|
|
1376
1471
|
};
|
|
1377
1472
|
}
|
|
1378
1473
|
const verdict = classifyMergeExecutionEligibility(readiness);
|
|
@@ -1385,7 +1480,8 @@ export function evaluateSetupEpicMergeExecutionPrerequisites(readiness, requeste
|
|
|
1385
1480
|
reason: MERGE_EXECUTION_INELIGIBLE,
|
|
1386
1481
|
message: "this run declares merge_execution_mode: github_actions, and this repository's " +
|
|
1387
1482
|
`merge capability ${category}: ${verdict.detail}. Resolve it with ` +
|
|
1388
|
-
`\`${MERGE_WORKFLOW_REMEDY_COMMAND}\`, then re-run.
|
|
1483
|
+
`\`${MERGE_WORKFLOW_REMEDY_COMMAND}\`, then re-run. ` +
|
|
1484
|
+
`${MERGE_WORKFLOW_CODEOWNER_INSTRUCTION} ${NO_RUN_WAS_CREATED}`,
|
|
1389
1485
|
};
|
|
1390
1486
|
}
|
|
1391
1487
|
/**
|
|
@@ -1949,6 +2045,21 @@ async function executeSetupEpic(argv, overrides, runIdSink, handoff) {
|
|
|
1949
2045
|
return 1;
|
|
1950
2046
|
}
|
|
1951
2047
|
const localHash = hashPlan(plan);
|
|
2048
|
+
// --- Notify destination conflict, explicit file (BAPI-1397, F1) ----------
|
|
2049
|
+
// An explicit `--policy-file` is always the selected policy, so a conflict the
|
|
2050
|
+
// server would reject is refused HERE — before credentials are resolved and
|
|
2051
|
+
// before any request. A file that cannot be read or parsed is left to the
|
|
2052
|
+
// policy read below, which reports it in its own words, unchanged.
|
|
2053
|
+
const pinnedLaunchPolicy = handoff ? getPinnedEpicLaunchPolicy(handoff.selection) : undefined;
|
|
2054
|
+
if (opts.policyFile !== undefined) {
|
|
2055
|
+
const earlyPolicy = pinnedLaunchPolicy !== undefined
|
|
2056
|
+
? { ok: true, policy: pinnedLaunchPolicy }
|
|
2057
|
+
: await readSetupEpicPolicyFile(opts.policyFile, deps.readFile);
|
|
2058
|
+
if (earlyPolicy.ok && policyNotifyDestinationsConflict(earlyPolicy.policy)) {
|
|
2059
|
+
deps.errorLog(notifyDestinationsConflictMessage(opts.policyFile, "explicit"));
|
|
2060
|
+
return 1;
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
1952
2063
|
// Resolve the selected target and live-run identity before choosing a policy.
|
|
1953
2064
|
// The offline local-only path keeps its no-network contract.
|
|
1954
2065
|
let access;
|
|
@@ -2067,8 +2178,12 @@ async function executeSetupEpic(argv, overrides, runIdSink, handoff) {
|
|
|
2067
2178
|
const selectedPolicyPath = opts.policyFile ?? (existingRunId === null ? selectedProfilePolicyPath : undefined);
|
|
2068
2179
|
const selectedPolicySource = existingRunId !== null
|
|
2069
2180
|
? "stored" : opts.policyFile !== undefined ? "explicit" : selectedProfilePolicyPath !== undefined ? "profile" : "default";
|
|
2181
|
+
// BAPI-1397 (F1): the source NAMED next to a policy path in operator output. An
|
|
2182
|
+
// explicit file is always "explicit" here, even when the effective-policy facts
|
|
2183
|
+
// above report a reused run's policy as "stored".
|
|
2184
|
+
const policyLabelSource = opts.policyFile !== undefined ? "explicit" : selectedPolicySource;
|
|
2070
2185
|
if (selectedPolicyPath !== undefined) {
|
|
2071
|
-
const pinned =
|
|
2186
|
+
const pinned = pinnedLaunchPolicy;
|
|
2072
2187
|
if (pinned !== undefined) {
|
|
2073
2188
|
filePolicy = pinned;
|
|
2074
2189
|
}
|
|
@@ -2080,6 +2195,17 @@ async function executeSetupEpic(argv, overrides, runIdSink, handoff) {
|
|
|
2080
2195
|
}
|
|
2081
2196
|
filePolicy = policyRead.policy;
|
|
2082
2197
|
}
|
|
2198
|
+
// BAPI-1397 (F1): the same server rule, for a profile-selected file. Still
|
|
2199
|
+
// before any write or plane bring-up; the refusal names the resolved file.
|
|
2200
|
+
if (policyNotifyDestinationsConflict(filePolicy)) {
|
|
2201
|
+
deps.errorLog(notifyDestinationsConflictMessage(selectedPolicyPath, policyLabelSource));
|
|
2202
|
+
return 1;
|
|
2203
|
+
}
|
|
2204
|
+
// BAPI-1397 (F1): a webhook URL in a file other users can read is a warning,
|
|
2205
|
+
// never a refusal. Only the path is named.
|
|
2206
|
+
const readableWarning = await worldReadableWebhookPolicyWarning(selectedPolicyPath, filePolicy, deps.stat);
|
|
2207
|
+
if (readableWarning !== null)
|
|
2208
|
+
deps.errorLog(readableWarning);
|
|
2083
2209
|
}
|
|
2084
2210
|
const policyDeclaresBranch = filePolicy !== undefined &&
|
|
2085
2211
|
["base_branch", "baseBranch"].some((key) => key in filePolicy && filePolicy[key] !== undefined && filePolicy[key] !== null);
|
|
@@ -2245,7 +2371,10 @@ async function executeSetupEpic(argv, overrides, runIdSink, handoff) {
|
|
|
2245
2371
|
"human-gated) — pass --attended to opt out"
|
|
2246
2372
|
: "";
|
|
2247
2373
|
const shape = inherited === "" ? "complete run policy" : "run policy";
|
|
2248
|
-
|
|
2374
|
+
// BAPI-1397 (F1): the RESOLVED path and source — `opts.policyFile` is
|
|
2375
|
+
// undefined for a profile-selected or composed policy.
|
|
2376
|
+
say(`Policy: ${describeSelectedPolicy(selectedPolicyPath, policyLabelSource)} ` +
|
|
2377
|
+
`(${shape}, applied at creation${inherited})`);
|
|
2249
2378
|
}
|
|
2250
2379
|
if (effectiveFeatureBranch !== undefined && featureBranch === undefined) {
|
|
2251
2380
|
say(`Feature: ${effectiveFeatureBranch} (from the policy file; cut on origin with local ` +
|
|
@@ -2833,7 +2962,8 @@ async function executeSetupEpic(argv, overrides, runIdSink, handoff) {
|
|
|
2833
2962
|
// Re-sanitizing here would only destroy the field name that makes the
|
|
2834
2963
|
// server's field-named 422 actionable. The property is pinned by the
|
|
2835
2964
|
// regression test below rather than left to this comment.
|
|
2836
|
-
deps.errorLog(`The run policy
|
|
2965
|
+
deps.errorLog(`The run policy ${describeSelectedPolicy(selectedPolicyPath, policyLabelSource)} ` +
|
|
2966
|
+
`was rejected by the server.\n` +
|
|
2837
2967
|
`${errorDetail(err)}\n` +
|
|
2838
2968
|
`No run was created and no automation-start charge occurred. Fix the ` +
|
|
2839
2969
|
`named field in the policy file and re-run.`);
|
package/build/sfcc/config.js
CHANGED
|
@@ -9,6 +9,15 @@
|
|
|
9
9
|
export const SFCC_VERSIONS = ["sfra", "pwakit", "sitegenesis", "storefrontnext", "hybrid"];
|
|
10
10
|
/** Pinned OCAPI Data API version used for all requests (configurable at call-time). */
|
|
11
11
|
export const DEFAULT_OCAPI_VERSION = "v25_6";
|
|
12
|
+
/**
|
|
13
|
+
* The `_v` value of the OCAPI Settings JSON an operator pastes in Business
|
|
14
|
+
* Manager. This is the settings-STRUCTURE version, not the OCAPI API version
|
|
15
|
+
* above; Business Manager rejects an API version here.
|
|
16
|
+
*
|
|
17
|
+
* UNVERIFIED: "23.2" is provisional until owner check OR-1 saves the emitted
|
|
18
|
+
* grant in Business Manager. If OR-1 shows a different value, change it here.
|
|
19
|
+
*/
|
|
20
|
+
export const OCAPI_SETTINGS_STRUCTURE_VERSION = "23.2";
|
|
12
21
|
/** Account Manager OAuth2 token endpoint host. */
|
|
13
22
|
export const AM_HOST = "account.demandware.com";
|
|
14
23
|
/** AM OAuth2 token endpoint URL. */
|
|
@@ -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
|
+
}
|