@bongos/core 1.18.18 → 1.19.2
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/.bongos-core.json +515 -195
- package/.claude/skills/builder-box/SKILL.md +1 -1
- package/.claude/skills/builder-claim/SKILL.md +1 -1
- package/.claude/skills/goal-create/SKILL.md +1 -1
- package/.claude/skills/idea-triage/SKILL.md +1 -1
- package/.claude/skills/planning-session/SKILL.md +5 -5
- package/.claude/skills/scan-before-install/SKILL.md +165 -0
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +63 -41
- package/clients/bongos-client/index.cjs +63 -41
- package/clients/bongos-client/index.d.ts +78 -44
- package/clients/bongos-client/index.mjs +63 -41
- package/docs/adr/0020-security-model-public-stub.md +1 -1
- package/docs/adr/0097-one-active-claim-per-session-and-worktree-binding.md +2 -0
- package/docs/adr/0144-devbox-rehome-onto-cloudbongos-plane.md +1 -1
- package/docs/adr/0151-governance-permissions-as-atom-ranks-as-roles.md +2 -1
- package/docs/adr/0155-adopt-private-repo-widen-oauth-scope.md +51 -0
- package/docs/adr/0156-user-set-display-name-wins.md +62 -0
- package/docs/adr/0157-archon-is-rank-and-identity-only.md +93 -0
- package/docs/adr/0158-choke-point-verdict-taxonomy.md +174 -0
- package/docs/adr/0159-merge-gate-reconciliation.md +88 -0
- package/docs/adr/0160-main-audit-repo-verifiable-caller.md +77 -0
- package/docs/adr/README.md +8 -0
- package/docs/api/openapi.json +1209 -233
- package/docs/api-reference.md +61 -46
- package/docs/architecture.md +1 -1
- package/docs/canonical-permissions.md +61 -16
- package/docs/design/vanilla-hall-ui-redesign-scope.md +1 -1
- package/docs/file-map.md +4 -1
- package/docs/onboarding/diagrams/assertions.json +3 -3
- package/docs/recipes/core-release-pipeline.md +179 -0
- package/docs/recipes/packaging-the-core.md +2 -0
- package/docs/recipes/parallel-goal-waves.md +1 -3
- package/docs/recipes/private-npm-distribution.md +2 -0
- package/docs/recipes/upgrading-the-core.md +2 -0
- package/migrations/core_197_display_name_source.sql +56 -0
- package/migrations/core_204_rekey_active_claim_per_session_worktree.sql +77 -0
- package/modules/autonomy/routes/autonomy.js +1 -1
- package/modules/builder-settings/db.js +7 -1
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +63 -41
- package/modules/dev-box/routes/box.js +11 -9
- package/modules/discord/auth.js +3 -1
- package/modules/discord/routes/discord.js +8 -2
- package/modules/discord/ship-broadcast.js +48 -1
- package/modules/governance/catalog.js +70 -20
- package/modules/governance/db.js +102 -0
- package/modules/governance/migrations/governance_006_version_create.sql +38 -0
- package/modules/governance/migrations/governance_007_delegate_archon_ops_to_metic.sql +72 -0
- package/modules/governance/routes/governance.js +235 -6
- package/modules/grading/grader-workers/index.js +18 -9
- package/modules/hall-ui/public/gate.js +6 -2
- package/modules/hall-ui/public/goals-page.js +6 -2
- package/modules/hall-ui/public/governance.css +100 -0
- package/modules/hall-ui/public/governance.html +77 -0
- package/modules/hall-ui/public/governance.js +353 -0
- package/modules/hall-ui/public/harbor.js +14 -8
- package/modules/hall-ui/public/instances-panel.js +3 -1
- package/modules/hall-ui/public/new-project-wizard.js +6 -0
- package/modules/hall-ui/public/people.html +51 -0
- package/modules/hall-ui/public/people.js +100 -0
- package/modules/hall-ui/public/profile.html +2 -1
- package/modules/hall-ui/public/profile.js +9 -3
- package/modules/hall-ui/public/sessions.js +4 -2
- package/modules/hall-ui/public/shell.js +28 -7
- package/modules/hall-ui/public/task-detail.js +6 -3
- package/modules/hall-ui/public/watch.js +17 -9
- package/modules/ideas/blockers.js +44 -7
- package/modules/ideas/routes/blockers.js +12 -7
- package/modules/ideas/routes/inbox.js +2 -2
- package/modules/lifecycle/db.js +84 -17
- package/modules/lifecycle/github-push.js +37 -10
- package/modules/lifecycle/publish-reconciler.js +128 -31
- package/modules/lifecycle/routes/claims.js +10 -4
- package/modules/lifecycle/routes/dependencies.js +2 -2
- package/modules/lifecycle/routes/done-when.js +4 -4
- package/modules/lifecycle/routes/gate-approvals.js +9 -3
- package/modules/lifecycle/routes/goals.js +5 -5
- package/modules/lifecycle/routes/tasks.js +16 -16
- package/modules/lifecycle/routes/versions.js +13 -2
- package/modules/lifecycle/ship-card.js +38 -7
- package/modules/memory/routes/memory.js +2 -2
- package/modules/onboarding/routes/access-requests.js +3 -3
- package/modules/platform-identity/routes/projects.js +10 -6
- package/modules/platform-identity/routes/scouting.js +2 -2
- package/modules/provisioning/migrations/provisioning_004_onboard_mode.sql +30 -0
- package/modules/provisioning/provisioning.js +24 -6
- package/modules/provisioning/routes/provisioning.js +24 -6
- package/modules/security/artifact-scan/aggregate.js +133 -0
- package/modules/security/artifact-scan/detectors/capabilities.js +53 -0
- package/modules/security/artifact-scan/detectors/config-obfuscation.js +41 -0
- package/modules/security/artifact-scan/detectors/credentials.js +112 -0
- package/modules/security/artifact-scan/detectors/dependencies.js +115 -0
- package/modules/security/artifact-scan/detectors/hooks.js +116 -0
- package/modules/security/artifact-scan/detectors/injection.js +118 -0
- package/modules/security/artifact-scan/detectors/mcp.js +102 -0
- package/modules/security/artifact-scan/detectors/network.js +67 -0
- package/modules/security/artifact-scan/detectors/npm-lifecycle.js +67 -0
- package/modules/security/artifact-scan/detectors/permissions.js +91 -0
- package/modules/security/artifact-scan/detectors/shared.js +219 -0
- package/modules/security/artifact-scan/fence-local.js +99 -0
- package/modules/security/artifact-scan/fetch.js +330 -0
- package/modules/security/artifact-scan/floor.js +319 -0
- package/modules/security/artifact-scan/limits.js +66 -0
- package/modules/security/artifact-scan/manifest.js +203 -0
- package/modules/security/artifact-scan/mirror.js +158 -0
- package/modules/security/artifact-scan/quarantine.js +149 -0
- package/modules/security/artifact-scan/rebuild-spec.js +120 -0
- package/modules/security/artifact-scan/report.js +155 -0
- package/modules/security/artifact-scan/sanitize.js +317 -0
- package/modules/security/migrations/security_001_tool_scans.sql +145 -0
- package/modules/security/module.json +2 -2
- package/modules/security/routes/reports.js +11 -7
- package/modules/security/routes/scans.js +115 -0
- package/modules/security/scan-store.js +214 -0
- package/modules/security/tests/aggregate.mjs +283 -0
- package/modules/security/tests/artifact_scan.mjs +591 -0
- package/modules/security/tests/floor.mjs +549 -0
- package/modules/security/tests/mirror.mjs +350 -0
- package/modules/security/tests/sanitize_rebuild.mjs +499 -0
- package/modules/security/tests/scan_store.mjs +250 -0
- package/modules/security/tests/tool_scans_migration.mjs +116 -0
- package/modules/sessions/routes/sessions.js +6 -5
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/create.html +13 -3
- package/scripts/gds/abandon.js +1 -1
- package/scripts/gds/audit-authorship.sh +6 -0
- package/scripts/gds/box.js +1 -1
- package/scripts/gds/claim.js +15 -0
- package/scripts/gds/cli-lib.js +16 -4
- package/scripts/gds/gating.js +28 -13
- package/scripts/gds/gen-api-docs.js +1 -1
- package/scripts/gds/goal.js +234 -0
- package/scripts/gds/init.js +70 -15
- package/scripts/gds/main-audit.js +80 -11
- package/scripts/gds/mirror-redact.js +6 -0
- package/scripts/gds/newcomer-floor.js +2 -2
- package/scripts/gds/provision.js +230 -94
- package/scripts/gds/run-unit-tests.js +9 -1
- package/scripts/gds/sandbox-stage.js +66 -9
- package/scripts/gds/search-ingest-md.js +1 -0
- package/scripts/gds/sequence.js +67 -8
- package/scripts/gds/ship.js +183 -50
- package/scripts/hall-preview/server.js +1 -1
- package/scripts/migrate.sh +99 -14
- package/src/bongos/auth.js +129 -27
- package/src/bongos/db-kernel.js +66 -5
- package/src/bongos/db.js +6 -7
- package/src/bongos/github-repos.js +19 -7
- package/src/bongos/goal-scope-check.js +1 -1
- package/src/bongos/module-scope-map.js +3 -1
- package/src/bongos/permission-path-check.js +1 -1
- package/src/bongos/route-rank-check.js +73 -30
- package/src/bongos/routes/audit-log.js +4 -2
- package/src/bongos/routes/auth.js +8 -4
- package/src/bongos/routes/backup.js +7 -4
- package/src/bongos/routes/builders.js +23 -11
- package/src/bongos/routes/github.js +15 -4
- package/src/bongos/routes/llm-cache.js +5 -2
- package/src/bongos/routes/modules.js +9 -5
- package/src/bongos/routes/security.js +21 -12
- package/src/bongos/serve-internal.js +22 -7
- package/src/module-api.js +86 -1
- package/src/platform-server.js +14 -0
- package/tests/api_client.mjs +18 -2
- package/tests/api_path_404.mjs +3 -0
- package/tests/art_stage.mjs +2 -1
- package/tests/atlas.mjs +32 -4
- package/tests/auth_admission_gate.mjs +17 -5
- package/tests/auth_error_brand.mjs +41 -21
- package/tests/auth_resolve_failure.mjs +155 -0
- package/tests/blocker_link.mjs +88 -0
- package/tests/bongos_downloads.mjs +2 -1
- package/tests/box_dns_repoint.mjs +2 -0
- package/tests/box_ship_permissions.mjs +2 -0
- package/tests/box_terminal_ssrf.mjs +23 -17
- package/tests/claim_error_surface.mjs +23 -0
- package/tests/claude_materialize.mjs +4 -0
- package/tests/cli_box_scope.mjs +40 -0
- package/tests/cloud_bongos_pack.mjs +34 -4
- package/tests/conductor.mjs +2 -1
- package/tests/criterion_authoring_gate.mjs +28 -5
- package/tests/currency_label.mjs +10 -1
- package/tests/deploy_instance.mjs +2 -0
- package/tests/devbox_downloads.mjs +2 -1
- package/tests/display_name.mjs +95 -6
- package/tests/eviction_policy.mjs +2 -1
- package/tests/file_map.mjs +10 -0
- package/tests/first_admin_bootstrap.mjs +10 -2
- package/tests/first_run_engine_check.mjs +2 -1
- package/tests/fitness.mjs +26 -6
- package/tests/gate_approvals.mjs +15 -2
- package/tests/github_push_land_proof.mjs +50 -4
- package/tests/github_repos_helpers.mjs +30 -5
- package/tests/github_token_store.mjs +23 -0
- package/tests/goal_abuse_matrix.mjs +13 -3
- package/tests/goal_cli.mjs +89 -0
- package/tests/governance_abuse_matrix.mjs +386 -0
- package/tests/governance_fold.mjs +156 -0
- package/tests/governance_fold_bench.mjs +50 -0
- package/tests/governance_live_proof.mjs +201 -0
- package/tests/governance_page_gates.mjs +21 -9
- package/tests/governance_parity.mjs +11 -2
- package/tests/governance_require_permission.mjs +125 -1
- package/tests/governance_routes.mjs +334 -0
- package/tests/grader.mjs +7 -3
- package/tests/hall_nav.mjs +14 -4
- package/tests/helpers.mjs +73 -0
- package/tests/hook_core_resolve.mjs +2 -1
- package/tests/host_topology_skips.mjs +97 -0
- package/tests/infra_exec_paths.mjs +14 -4
- package/tests/init.mjs +128 -0
- package/tests/known_failing_ratchet.mjs +89 -0
- package/tests/lifecycle-card.mjs +2 -1
- package/tests/main_audit.mjs +72 -1
- package/tests/memory_isolation.mjs +56 -9
- package/tests/migrate_root_resolution.mjs +124 -1
- package/tests/mirror_redact.mjs +19 -0
- package/tests/module_api.mjs +3 -1
- package/tests/module_loader.mjs +4 -0
- package/tests/modules.mjs +35 -7
- package/tests/modules_route.mjs +29 -5
- package/tests/onboarding_observability.mjs +49 -11
- package/tests/onboarding_route_signals.mjs +1 -0
- package/tests/one_active_claim_per_session.mjs +80 -0
- package/tests/pre_compact.mjs +2 -1
- package/tests/preview_server.mjs +2 -1
- package/tests/profile_route.mjs +1 -0
- package/tests/provision.mjs +170 -0
- package/tests/provisioning_onboard_mode.mjs +180 -0
- package/tests/publish_manifest.mjs +16 -3
- package/tests/publish_reconciler.mjs +181 -10
- package/tests/rank_tier_single_source.mjs +205 -0
- package/tests/recall_hints.mjs +2 -1
- package/tests/repo_map.mjs +4 -0
- package/tests/route_permission_check.mjs +14 -5
- package/tests/rubric_fixtures.mjs +3 -0
- package/tests/run_routine_two_root.mjs +2 -0
- package/tests/sandbox_stage.mjs +69 -7
- package/tests/search_ingest.mjs +7 -0
- package/tests/security_docs.mjs +28 -6
- package/tests/sequence.mjs +64 -0
- package/tests/session-cards.mjs +2 -1
- package/tests/session_start_freshness.mjs +2 -1
- package/tests/ship_broadcast_server.mjs +63 -1
- package/tests/ship_card.mjs +66 -0
- package/tests/ship_ci_deploy.mjs +66 -1
- package/tests/ship_smoke_gate.mjs +76 -0
- package/tests/versions_route.mjs +15 -0
- package/tests/workflow_exec_paths.mjs +16 -4
- package/tests/worktree_guard.mjs +2 -1
package/tests/module_loader.mjs
CHANGED
|
@@ -315,6 +315,10 @@ test('the demo proof module mounts + serves through the loader (BV1.R52 / task 1
|
|
|
315
315
|
// BV1.R52 acceptance: the net-new scaffolded module is discovered, is enabled
|
|
316
316
|
// by config/modules.json, and its route factory builds a real router with the
|
|
317
317
|
// health read route + the rank-gated ping write route — all with NO core edit.
|
|
318
|
+
// modules/demo/ is content-excluded from the published core (ADR 0098), exactly as
|
|
319
|
+
// the roster comment above already notes — so on a core checkout there is nothing
|
|
320
|
+
// to mount and this acceptance proof does not apply (task 1002470).
|
|
321
|
+
if (!fs.existsSync(path.join(ROOT, 'modules', 'demo'))) return;
|
|
318
322
|
loader._reset();
|
|
319
323
|
const { isModuleEnabled } = require(path.join(ROOT, 'src', 'modules.js'));
|
|
320
324
|
assert.equal(isModuleEnabled('demo'), true, 'config/modules.json enables demo');
|
package/tests/modules.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// tests/modules.mjs — the feature-module selection resolver (task 1193 / V4.R54, ADR 0062 §4).
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import { test } from 'node:test';
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
4
5
|
import modulesMod from '../src/modules.js';
|
|
5
6
|
|
|
6
7
|
const {
|
|
@@ -17,6 +18,14 @@ const OTB = { modules: { game: true, 'art-pipeline': true, discord: true, 'dev-b
|
|
|
17
18
|
// loadModules() does in production. Membership across the move is preserved here.
|
|
18
19
|
const EFFECTIVE = modulesMod.effectiveRegistry();
|
|
19
20
|
|
|
21
|
+
// Sample feature-module keys for the PURE resolver tests below. `game` and
|
|
22
|
+
// `art-pipeline` left the core at the CB→OTB split, so the effective registry no
|
|
23
|
+
// longer knows them and passing them made resolveModules throw `cannot enable
|
|
24
|
+
// unknown module` — the tests were asserting a topology, not the resolver (task
|
|
25
|
+
// 1002470). Synthesized here so the precedence/merge/freeze semantics stay under
|
|
26
|
+
// test on any instance, whatever it happens to have installed.
|
|
27
|
+
const SAMPLE_REG = { ...EFFECTIVE, game: { default: false }, 'art-pipeline': { default: false } };
|
|
28
|
+
|
|
20
29
|
test('vanilla (no config) resolves every module OFF — fail-to-vanilla, never fail-to-OTB', () => {
|
|
21
30
|
const m = resolveModules({});
|
|
22
31
|
for (const k of KNOWN_MODULES) assert.equal(m[k], false, `${k} should default off`);
|
|
@@ -28,7 +37,7 @@ test('every registry module has an explicit default of false', () => {
|
|
|
28
37
|
});
|
|
29
38
|
|
|
30
39
|
test('instance config turns modules on', () => {
|
|
31
|
-
const m = resolveModules({ neutral: VANILLA, instance: OTB, registry:
|
|
40
|
+
const m = resolveModules({ neutral: VANILLA, instance: OTB, registry: SAMPLE_REG });
|
|
32
41
|
assert.equal(m.game, true);
|
|
33
42
|
assert.equal(m['dev-box'], true);
|
|
34
43
|
assert.equal(isModuleEnabled('discord', m), true);
|
|
@@ -41,19 +50,31 @@ test('instance config turns modules on', () => {
|
|
|
41
50
|
// gate + builder economy + idea inbox + red-team intake + prefs + onboarding +
|
|
42
51
|
// session corpus + the MAS autonomy machinery + the status dashboard + the
|
|
43
52
|
// builders' hall ship to every instance — see each module.json).
|
|
44
|
-
|
|
53
|
+
// Asserted as a PROPERTY rather than a frozen 16-key list (which named modules the
|
|
54
|
+
// core no longer carries — task 1002470).
|
|
55
|
+
// Everything the instance switched on resolves on...
|
|
56
|
+
for (const k of Object.keys(OTB.modules)) assert.equal(m[k], true, `instance-enabled '${k}' must resolve on`);
|
|
57
|
+
// ...every `default: true` core-domain module resolves on WITHOUT being listed...
|
|
58
|
+
const coreDefaults = Object.keys(SAMPLE_REG).filter((k) => SAMPLE_REG[k].default);
|
|
59
|
+
assert.ok(coreDefaults.length >= 5, `expected a real core-domain roster, found ${coreDefaults.length}`);
|
|
60
|
+
for (const k of coreDefaults) assert.equal(m[k], true, `default-on core module '${k}' must resolve on unlisted`);
|
|
61
|
+
// ...and a registry key that is neither listed nor default-on stays off.
|
|
62
|
+
for (const k of Object.keys(SAMPLE_REG)) {
|
|
63
|
+
if (SAMPLE_REG[k].default || k in OTB.modules) continue;
|
|
64
|
+
assert.equal(m[k], false, `unlisted opt-in module '${k}' must stay off`);
|
|
65
|
+
}
|
|
45
66
|
});
|
|
46
67
|
|
|
47
68
|
test('instance config overrides the neutral starter per-flag (deep merge of the modules map)', () => {
|
|
48
69
|
const neutral = { modules: { game: false, discord: false, 'dev-box': false, 'art-pipeline': false } };
|
|
49
70
|
const instance = { modules: { discord: true } }; // only flip one
|
|
50
|
-
const m = resolveModules({ neutral, instance, registry:
|
|
71
|
+
const m = resolveModules({ neutral, instance, registry: SAMPLE_REG });
|
|
51
72
|
assert.equal(m.discord, true);
|
|
52
73
|
assert.equal(m.game, false); // neutral value survives
|
|
53
74
|
});
|
|
54
75
|
|
|
55
76
|
test('env override beats instance config (precedence), with prefix + snake-casing', () => {
|
|
56
|
-
const m = resolveModules({ instance: OTB, env: { OTB_MODULE_DEV_BOX: '0' }, envPrefix: 'OTB', registry:
|
|
77
|
+
const m = resolveModules({ instance: OTB, env: { OTB_MODULE_DEV_BOX: '0' }, envPrefix: 'OTB', registry: SAMPLE_REG });
|
|
57
78
|
assert.equal(m['dev-box'], false); // env off beat instance on
|
|
58
79
|
assert.equal(m.game, true); // others untouched
|
|
59
80
|
});
|
|
@@ -111,19 +132,23 @@ test('disciplines: vanilla offers engineer + ideator (core); art-pipeline adds a
|
|
|
111
132
|
// instance), so it is offered with no modules on; only 'artist' is module-gated.
|
|
112
133
|
// art-pipeline is now a DISCOVERED module (BV1.R48), so pass EFFECTIVE registry.
|
|
113
134
|
assert.deepEqual(enabledDisciplines(resolveModules({})), ['engineer', 'ideator']);
|
|
114
|
-
|
|
135
|
+
// The 'artist' leg reads art-pipeline's OWN module.json contributes, so it only
|
|
136
|
+
// means anything where that module is installed (task 1002470). The core half
|
|
137
|
+
// above — ideation ships to every instance — is asserted unconditionally.
|
|
138
|
+
if (!EFFECTIVE['art-pipeline']) return;
|
|
139
|
+
const m = resolveModules({ instance: { modules: { 'art-pipeline': true } }, registry: SAMPLE_REG });
|
|
115
140
|
assert.deepEqual(enabledDisciplines(m), ['engineer', 'ideator', 'artist']);
|
|
116
141
|
});
|
|
117
142
|
|
|
118
143
|
test('clientModules is a safe boolean projection covering every known module + disciplines', () => {
|
|
119
|
-
const c = clientModules(resolveModules({ instance: OTB, registry:
|
|
144
|
+
const c = clientModules(resolveModules({ instance: OTB, registry: SAMPLE_REG }));
|
|
120
145
|
for (const k of KNOWN_MODULES) assert.equal(typeof c.enabled[k], 'boolean');
|
|
121
146
|
assert.ok(Array.isArray(c.disciplines));
|
|
122
147
|
assert.ok(c.disciplines.includes('engineer'));
|
|
123
148
|
});
|
|
124
149
|
|
|
125
150
|
test('the resolved set is frozen (callers cannot mutate the singleton)', () => {
|
|
126
|
-
const m = resolveModules({ instance: OTB, registry:
|
|
151
|
+
const m = resolveModules({ instance: OTB, registry: SAMPLE_REG });
|
|
127
152
|
assert.throws(() => { m.game = false; }, TypeError);
|
|
128
153
|
});
|
|
129
154
|
|
|
@@ -131,6 +156,9 @@ test('the committed config files resolve: OTB enables all four, on disk', () =>
|
|
|
131
156
|
// loadModules reads config/modules.json over config/modules.neutral.json.
|
|
132
157
|
// No GDS_BRANDING_FILE → still the default OTB instance pack (regression guard
|
|
133
158
|
// for the GDS_MODULES_FILE selector below).
|
|
159
|
+
// config/modules.json is INSTANCE content — the neutral core ships only
|
|
160
|
+
// config/modules.neutral.json, so there is no instance pack to assert (task 1002470).
|
|
161
|
+
if (!existsSync(new URL('../config/modules.json', import.meta.url))) return;
|
|
134
162
|
const m = loadModules({ env: {} });
|
|
135
163
|
assert.equal(m.game, true);
|
|
136
164
|
assert.equal(m['art-pipeline'], true);
|
package/tests/modules_route.mjs
CHANGED
|
@@ -7,14 +7,21 @@ import { test } from 'node:test';
|
|
|
7
7
|
import { createRequire } from 'node:module';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { onDiskModuleKeys } from './helpers.mjs';
|
|
10
11
|
|
|
11
12
|
const require = createRequire(import.meta.url);
|
|
12
13
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
13
14
|
const { listModules } = require(path.join(ROOT, 'src', 'bongos', 'routes', 'modules.js'));
|
|
14
15
|
|
|
16
|
+
// The roster is compared against a direct disk read (helpers.mjs onDiskModuleKeys) rather
|
|
17
|
+
// than a hardcoded floor — that floor (>=18) went stale when modules left the core at the
|
|
18
|
+
// CB→OTB split, turning a real exhaustiveness check into a census (task 1002470).
|
|
19
|
+
|
|
15
20
|
test('listModules: every on-disk module is present, sorted, and well-formed', () => {
|
|
16
21
|
const rows = listModules();
|
|
17
|
-
|
|
22
|
+
const expected = onDiskModuleKeys();
|
|
23
|
+
assert.ok(expected.length >= 5, `expected a real on-disk module roster, found ${expected.length}`);
|
|
24
|
+
assert.deepEqual(rows.map((m) => m.key).sort(), expected, 'listModules must return exactly the on-disk modules');
|
|
18
25
|
for (const m of rows) {
|
|
19
26
|
assert.equal(typeof m.key, 'string');
|
|
20
27
|
assert.ok(['own', 'core'].includes(m.provenance), `${m.key}: provenance is own|core`);
|
|
@@ -24,15 +31,32 @@ test('listModules: every on-disk module is present, sorted, and well-formed', ()
|
|
|
24
31
|
assert.deepEqual(keys, [...keys].sort(), 'listModules is sorted by key');
|
|
25
32
|
});
|
|
26
33
|
|
|
27
|
-
|
|
34
|
+
// This used to assert a CENSUS ("today, every module is own-built") that was true only
|
|
35
|
+
// while the instance was OTB. On a neutral core, provisioning + platform-identity carry
|
|
36
|
+
// an origin naming another project and correctly classify 'core' — the census was stale,
|
|
37
|
+
// not the code (task 1002470). Assert the ADR 0107 §9 RULE instead, which holds on every
|
|
38
|
+
// instance: no origin => own, and an origin naming this project => own.
|
|
39
|
+
test('listModules: provenance follows the ADR 0107 §9 rule (no origin, or a self-naming origin, is own-built)', () => {
|
|
28
40
|
const rows = listModules();
|
|
29
|
-
|
|
41
|
+
let withOrigin = 0;
|
|
42
|
+
for (const m of rows) {
|
|
43
|
+
if (!m.origin) {
|
|
44
|
+
assert.equal(m.provenance, 'own', `${m.key} declares no origin, so it must classify own-built`);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
withOrigin += 1;
|
|
48
|
+
assert.ok(['own', 'core'].includes(m.provenance), `${m.key}: provenance must be own|core`);
|
|
49
|
+
}
|
|
50
|
+
// Non-vacuity: the no-origin branch must actually have been exercised.
|
|
51
|
+
assert.ok(rows.length > withOrigin, 'expected at least one module with no declared origin');
|
|
30
52
|
});
|
|
31
53
|
|
|
32
|
-
test('listModules: game module is
|
|
54
|
+
test('listModules: an installed game module is enabled and carries no origin', () => {
|
|
33
55
|
const rows = listModules();
|
|
34
56
|
const game = rows.find((m) => m.key === 'game');
|
|
35
|
-
|
|
57
|
+
// modules/game/ left the core at the CB→OTB split; where it IS installed the
|
|
58
|
+
// original contract still holds (task 1002470).
|
|
59
|
+
if (!game) return;
|
|
36
60
|
assert.equal(game.enabled, true);
|
|
37
61
|
assert.equal(game.origin, null);
|
|
38
62
|
});
|
|
@@ -43,6 +43,12 @@ async function t(name, fn) {
|
|
|
43
43
|
// src/bongos/routes, so we patch the resolved module before requiring the
|
|
44
44
|
// routes.
|
|
45
45
|
const authPath = require.resolve('../src/bongos/auth.js');
|
|
46
|
+
const catalog = require('../modules/governance/catalog.js');
|
|
47
|
+
// Mirrors the canonical ladder in modules/governance/catalog.js — omitting a rung
|
|
48
|
+
// would resolve its floor to Infinity and deny everyone, including archon. Kept at
|
|
49
|
+
// module scope (BV1.R104) so requireRank and requirePermission below share ONE
|
|
50
|
+
// ladder; R105's branch still had the stale 3-rung inline copy that dropped thetes.
|
|
51
|
+
const RANK_ORDER = { xenos: 1, thetes: 2, metic: 3, archon: 4 };
|
|
46
52
|
const stubAuth = {
|
|
47
53
|
requireBuilder: (req, _res, next) => {
|
|
48
54
|
// The test harness sets req._stub_builder beforehand.
|
|
@@ -52,8 +58,7 @@ const stubAuth = {
|
|
|
52
58
|
},
|
|
53
59
|
requireRank: (rank) => (req, res, next) => {
|
|
54
60
|
if (!req.builder) return res.status(401).json({ error: 'unauth' });
|
|
55
|
-
|
|
56
|
-
if ((order[req.builder.rank] || 0) < (order[rank] || 0)) {
|
|
61
|
+
if ((RANK_ORDER[req.builder.rank] || 0) < (RANK_ORDER[rank] || 0)) {
|
|
57
62
|
return res.status(403).json({
|
|
58
63
|
error: 'rank_forbidden',
|
|
59
64
|
required: rank,
|
|
@@ -62,6 +67,28 @@ const stubAuth = {
|
|
|
62
67
|
}
|
|
63
68
|
next();
|
|
64
69
|
},
|
|
70
|
+
// BV1.R104/R105 migrated these gates to requirePermission — core routes in R104,
|
|
71
|
+
// module routes (here: POST /claims/:id/release-on-behalf) in R105. Resolve each
|
|
72
|
+
// atom's floor from the REAL catalog and apply the same threshold as the rank stub
|
|
73
|
+
// above, so the stub admits exactly who the requireRank spelling did — the seeded
|
|
74
|
+
// rank-roles make the two gates equivalent (ADR 0151). An unknown atom denies
|
|
75
|
+
// (Infinity) — fail-closed, matching the live gate; a stub that admitted on an
|
|
76
|
+
// unknown key would hide precisely the typo this suite exists to catch.
|
|
77
|
+
requirePermission: (...perms) => (req, res, next) => {
|
|
78
|
+
if (!req.builder) return res.status(401).json({ error: 'unauth' });
|
|
79
|
+
const floor = perms.reduce(
|
|
80
|
+
(max, key) => Math.max(max, RANK_ORDER[catalog.byKey(key)?.floor] || Infinity),
|
|
81
|
+
0,
|
|
82
|
+
);
|
|
83
|
+
if ((RANK_ORDER[req.builder.rank] || 0) < floor) {
|
|
84
|
+
return res.status(403).json({
|
|
85
|
+
error: 'permission_forbidden',
|
|
86
|
+
required: perms,
|
|
87
|
+
held: [],
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
next();
|
|
91
|
+
},
|
|
65
92
|
};
|
|
66
93
|
require.cache[authPath] = {
|
|
67
94
|
id: authPath,
|
|
@@ -190,23 +217,28 @@ function send({ port, method, path, body, headers = {} }) {
|
|
|
190
217
|
|
|
191
218
|
const { server, port } = await listen(buildApp());
|
|
192
219
|
|
|
193
|
-
|
|
220
|
+
// BV1.R104 migrated this gate to requirePermission('builder.roster.read') — an
|
|
221
|
+
// archon-floored atom, so the SAME callers are refused; only the 403 code and the
|
|
222
|
+
// `required` shape changed (permission_forbidden + the atom list, not a rank name).
|
|
223
|
+
await t('GET /builders/roster as xenos → 403 permission_forbidden', async () => {
|
|
194
224
|
const r = await send({
|
|
195
225
|
port, method: 'GET', path: '/api/gds/builders/roster',
|
|
196
226
|
headers: { 'x-stub-builder-id': '7', 'x-stub-builder-rank': 'xenos' },
|
|
197
227
|
});
|
|
198
228
|
assert.equal(r.status, 403);
|
|
199
|
-
assert.equal(r.json?.error, '
|
|
200
|
-
assert.
|
|
229
|
+
assert.equal(r.json?.error, 'permission_forbidden');
|
|
230
|
+
assert.deepEqual(r.json?.required, ['builder.roster.read']);
|
|
201
231
|
});
|
|
202
232
|
|
|
203
|
-
|
|
233
|
+
// ADR 0157 lowered `builder.roster.read` archon→metic: reading who is on the roster is
|
|
234
|
+
// administration, not authority over who holds authority. The refusal assertion that
|
|
235
|
+
// still guards the surface is the sub-Metic one above (thetes/xenos → 403).
|
|
236
|
+
await t('GET /builders/roster as metic → admitted (ADR 0157)', async () => {
|
|
204
237
|
const r = await send({
|
|
205
238
|
port, method: 'GET', path: '/api/gds/builders/roster',
|
|
206
239
|
headers: { 'x-stub-builder-id': '7', 'x-stub-builder-rank': 'metic' },
|
|
207
240
|
});
|
|
208
|
-
assert.
|
|
209
|
-
assert.equal(r.json?.required, 'archon');
|
|
241
|
+
assert.notEqual(r.status, 403, 'a metic now holds builder.roster.read');
|
|
210
242
|
});
|
|
211
243
|
|
|
212
244
|
await t('GET /builders/roster as archon → 200', async () => {
|
|
@@ -279,16 +311,22 @@ await t('POST /claims/:id/release-on-behalf as xenos → 403', async () => {
|
|
|
279
311
|
headers: { 'x-stub-builder-id': '7', 'x-stub-builder-rank': 'xenos' },
|
|
280
312
|
});
|
|
281
313
|
assert.equal(r.status, 403);
|
|
282
|
-
|
|
314
|
+
// BV1.R105: the route now gates on `claim.release.any`, so the denial code is
|
|
315
|
+
// requirePermission's `permission_forbidden`. The AUDIENCE is unchanged (the atom
|
|
316
|
+
// floors at archon) — only the spelling of the refusal moved. This is the R97
|
|
317
|
+
// 403-contract change reaching its first module route.
|
|
318
|
+
assert.equal(r.json?.error, 'permission_forbidden');
|
|
283
319
|
});
|
|
284
320
|
|
|
285
|
-
|
|
321
|
+
// ADR 0157 lowered `claim.release.any` archon→metic: freeing another builder's stale
|
|
322
|
+
// claim is queue housekeeping, not an authority grant.
|
|
323
|
+
await t('POST /claims/:id/release-on-behalf as metic → admitted (ADR 0157)', async () => {
|
|
286
324
|
const r = await send({
|
|
287
325
|
port, method: 'POST', path: '/api/gds/claims/123/release-on-behalf',
|
|
288
326
|
body: { reason: 'stale' },
|
|
289
327
|
headers: { 'x-stub-builder-id': '7', 'x-stub-builder-rank': 'metic' },
|
|
290
328
|
});
|
|
291
|
-
assert.
|
|
329
|
+
assert.notEqual(r.status, 403, 'a metic now holds claim.release.any');
|
|
292
330
|
});
|
|
293
331
|
|
|
294
332
|
await t('POST /claims/:id/release-on-behalf w/o reason → 400 validation_failed', async () => {
|
|
@@ -183,5 +183,85 @@ t('names the in-flight task in the MATCHING worktree, not the first row', () =>
|
|
|
183
183
|
assert.equal(d.inFlightTitle, 'Two');
|
|
184
184
|
});
|
|
185
185
|
|
|
186
|
+
// =========================================================================
|
|
187
|
+
// task 1002589 (idea 1000638) — the DB backstop's ERROR SURFACE. A claims
|
|
188
|
+
// INSERT can trip TWO partial unique indexes; claimTask used to map every pg
|
|
189
|
+
// 23505 to ALREADY_CLAIMED, so a trip of migration 180's per-session index
|
|
190
|
+
// read as "task taken by another session" while the task sat 'ready'.
|
|
191
|
+
// claimUniqueViolationError branches on err.constraint: the per-task index
|
|
192
|
+
// keeps ALREADY_CLAIMED, the per-session index gets its own honest code, and
|
|
193
|
+
// an UNKNOWN constraint fails CLOSED on ALREADY_CLAIMED (never widen).
|
|
194
|
+
// =========================================================================
|
|
195
|
+
const { claimUniqueViolationError } = require('../modules/lifecycle/db.js');
|
|
196
|
+
|
|
197
|
+
t('23505 on uniq_active_claim_per_task → ALREADY_CLAIMED (race lost, unchanged)', () => {
|
|
198
|
+
const e = claimUniqueViolationError({ code: '23505', constraint: 'uniq_active_claim_per_task' });
|
|
199
|
+
assert.equal(e.code, 'ALREADY_CLAIMED');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
t('23505 on uniq_active_claim_per_session → session_already_has_bound_claim', () => {
|
|
203
|
+
const e = claimUniqueViolationError({ code: '23505', constraint: 'uniq_active_claim_per_session' });
|
|
204
|
+
assert.equal(e.code, 'session_already_has_bound_claim');
|
|
205
|
+
assert.match(e.what_to_do, /builder-ship|builder-release|[Ss]hip or release/,
|
|
206
|
+
'what_to_do should name the ship-or-release way out');
|
|
207
|
+
assert.match(e.what_to_do, /bind_session/,
|
|
208
|
+
'what_to_do should name the bind_session:false escape hatch');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// task 1002570: core_204 re-keys the index per (session, worktree) under a
|
|
212
|
+
// NEW name. Both generations must map to the honest code — a deploy applies
|
|
213
|
+
// code and migration at different instants, and an instance may still carry the
|
|
214
|
+
// migration-180 session-only index.
|
|
215
|
+
t('23505 on uniq_active_claim_per_session_worktree (core_204) → same honest code', () => {
|
|
216
|
+
const e = claimUniqueViolationError({ code: '23505', constraint: 'uniq_active_claim_per_session_worktree' });
|
|
217
|
+
assert.equal(e.code, 'session_already_has_bound_claim');
|
|
218
|
+
assert.match(e.what_to_do, /worktree/, 'what_to_do should name the separate-worktree way out');
|
|
219
|
+
assert.match(e.what_to_do, /bind_session/);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
t('23505 with an unknown constraint fails CLOSED → ALREADY_CLAIMED', () => {
|
|
223
|
+
assert.equal(claimUniqueViolationError({ code: '23505', constraint: 'uniq_something_future' }).code, 'ALREADY_CLAIMED');
|
|
224
|
+
assert.equal(claimUniqueViolationError({ code: '23505' }).code, 'ALREADY_CLAIMED');
|
|
225
|
+
assert.equal(claimUniqueViolationError(null).code, 'ALREADY_CLAIMED');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// =========================================================================
|
|
229
|
+
// task 1002570 — the core_204 re-key itself. Text tripwires on the
|
|
230
|
+
// migration file so the index can't silently drift from the guard's boundary
|
|
231
|
+
// (the exact mismatch this whole family of bugs came from). DB-free: pins the
|
|
232
|
+
// SQL's shape, not its execution.
|
|
233
|
+
// =========================================================================
|
|
234
|
+
import { readFileSync } from 'node:fs';
|
|
235
|
+
import { fileURLToPath } from 'node:url';
|
|
236
|
+
import { dirname, join } from 'node:path';
|
|
237
|
+
|
|
238
|
+
// Read lazily inside each pin so a missing/renamed file fails THESE tests, not
|
|
239
|
+
// the whole suite file at import time.
|
|
240
|
+
const migSql = () => readFileSync(
|
|
241
|
+
join(dirname(fileURLToPath(import.meta.url)),
|
|
242
|
+
'../migrations/core_204_rekey_active_claim_per_session_worktree.sql'),
|
|
243
|
+
'utf8');
|
|
244
|
+
|
|
245
|
+
t('core_204 drops the session-only index and creates the per-worktree one', () => {
|
|
246
|
+
assert.match(migSql(), /DROP INDEX IF EXISTS uniq_active_claim_per_session\b/,
|
|
247
|
+
'must remove the worktree-blind index');
|
|
248
|
+
assert.match(migSql(), /CREATE UNIQUE INDEX IF NOT EXISTS uniq_active_claim_per_session_worktree/,
|
|
249
|
+
'must create the re-keyed index idempotently');
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
t('core_204 folds NULL worktrees so a shared clone still self-collides', () => {
|
|
253
|
+
// In a plain multi-column unique index NULLs are DISTINCT — without the fold,
|
|
254
|
+
// two worktree-less claims would both insert and re-open the task-1642
|
|
255
|
+
// commingling hole. COALESCE is the version-safe fold (no PG15 requirement).
|
|
256
|
+
assert.match(migSql(), /COALESCE\(worktree_name,\s*''\)/i);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
t('core_204 keeps the 180 predicate (active + bound rows only) and heals first', () => {
|
|
260
|
+
assert.match(migSql(), /WHERE released_at IS NULL AND creator_session_id IS NOT NULL/,
|
|
261
|
+
'partial predicate must match migration 180 (web claims stay excluded)');
|
|
262
|
+
assert.match(migSql(), /PARTITION BY c\.creator_session_id,\s*COALESCE\(c\.worktree_name,\s*''\)/i,
|
|
263
|
+
'the pre-CREATE heal must group by the NEW key');
|
|
264
|
+
});
|
|
265
|
+
|
|
186
266
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
187
267
|
process.exit(failed ? 1 : 0);
|
package/tests/pre_compact.mjs
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
|
|
5
5
|
import { strict as assert } from 'node:assert';
|
|
6
6
|
import { createRequire } from 'node:module';
|
|
7
|
+
import { requireHostModule } from './helpers.mjs';
|
|
7
8
|
const require = createRequire(import.meta.url);
|
|
8
|
-
const { gateDecision, buildDirective, FLAG } = require
|
|
9
|
+
const { gateDecision, buildDirective, FLAG } = requireHostModule(require, '../.claude/hooks/pre-compact.js');
|
|
9
10
|
|
|
10
11
|
let passed = 0, failed = 0;
|
|
11
12
|
function test(name, fn) {
|
package/tests/preview_server.mjs
CHANGED
|
@@ -10,12 +10,13 @@
|
|
|
10
10
|
|
|
11
11
|
import { strict as assert } from 'node:assert';
|
|
12
12
|
import { createRequire } from 'node:module';
|
|
13
|
+
import { requireHostModule } from './helpers.mjs';
|
|
13
14
|
import fs from 'node:fs';
|
|
14
15
|
import os from 'node:os';
|
|
15
16
|
import path from 'node:path';
|
|
16
17
|
|
|
17
18
|
const require = createRequire(import.meta.url);
|
|
18
|
-
const { previewCacheControl, injectLivereload, renderArtGallery } = require
|
|
19
|
+
const { previewCacheControl, injectLivereload, renderArtGallery } = requireHostModule(require, '../src/preview-server.js');
|
|
19
20
|
|
|
20
21
|
// A minimal valid 1×1 PNG (transparent), base64-decoded to bytes.
|
|
21
22
|
const TINY_PNG = Buffer.from(
|
package/tests/profile_route.mjs
CHANGED
package/tests/provision.mjs
CHANGED
|
@@ -588,6 +588,176 @@ await ta('scaffoldStandaloneRepo: no owner token → falls back to the SSH origi
|
|
|
588
588
|
}
|
|
589
589
|
});
|
|
590
590
|
|
|
591
|
+
// ── ADOPT vs GREENFIELD onboarding (ADR 0121, task 1002562) ───────────────────────
|
|
592
|
+
// The runner used to run the greenfield scaffold unconditionally: `git init` a fresh tree
|
|
593
|
+
// and push HEAD:main. Against a repo that already has history that is an unrelated-history
|
|
594
|
+
// non-fast-forward, so adopting an existing repo could never succeed.
|
|
595
|
+
|
|
596
|
+
t('onboardMode: adopt only when the row says so; anything else → greenfield (the SAFE default)', () => {
|
|
597
|
+
assert.equal(P.onboardMode({ onboard_mode: 'adopt' }), 'adopt');
|
|
598
|
+
assert.equal(P.onboardMode({ onboard_mode: 'greenfield' }), 'greenfield');
|
|
599
|
+
// A row written BEFORE provisioning_004 added the column, and any unrecognized value,
|
|
600
|
+
// must resolve to greenfield — which refuses to push over existing history — never to
|
|
601
|
+
// adopt, which would start layering onto a repo nobody declared as pre-existing.
|
|
602
|
+
assert.equal(P.onboardMode({}), 'greenfield');
|
|
603
|
+
assert.equal(P.onboardMode({ onboard_mode: null }), 'greenfield');
|
|
604
|
+
assert.equal(P.onboardMode({ onboard_mode: 'ADOPT' }), 'greenfield');
|
|
605
|
+
assert.equal(P.onboardMode({ onboard_mode: 'something-else' }), 'greenfield');
|
|
606
|
+
assert.equal(P.onboardMode(null), 'greenfield');
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
t('alreadyScaffolded: the marker is mode-dependent — package.json alone does NOT mean an adopt is done', () => {
|
|
610
|
+
const dir = mkdtempSync(join(tmpdir(), 'marker-'));
|
|
611
|
+
try {
|
|
612
|
+
assert.equal(P.alreadyScaffolded(dir, 'greenfield'), false, 'no package.json → not scaffolded');
|
|
613
|
+
assert.equal(P.alreadyScaffolded(dir, 'adopt'), false);
|
|
614
|
+
|
|
615
|
+
// An ordinary existing Node project: package.json present, no core dep. Greenfield's
|
|
616
|
+
// marker would call this "done" — for ADOPT that is the bug this guards, because the
|
|
617
|
+
// layering would be skipped on the very first run and the instance never become bootable.
|
|
618
|
+
writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'their-app', dependencies: { react: '^18' } }));
|
|
619
|
+
assert.equal(P.alreadyScaffolded(dir, 'greenfield'), true, 'greenfield: package.json IS the marker (unchanged)');
|
|
620
|
+
assert.equal(P.alreadyScaffolded(dir, 'adopt'), false, 'adopt: an existing project is NOT already adopted');
|
|
621
|
+
|
|
622
|
+
// Once the layer has added the core dep, adopt IS done. Both scope aliases count.
|
|
623
|
+
writeFileSync(join(dir, 'package.json'), JSON.stringify({ dependencies: { react: '^18', '@bongos/core': 'file:vendor/x.tgz' } }));
|
|
624
|
+
assert.equal(P.alreadyScaffolded(dir, 'adopt'), true);
|
|
625
|
+
writeFileSync(join(dir, 'package.json'), JSON.stringify({ dependencies: { '@cloudbongos/core': '^1.0.0' } }));
|
|
626
|
+
assert.equal(P.alreadyScaffolded(dir, 'adopt'), true, 'the @cloudbongos alias counts too');
|
|
627
|
+
|
|
628
|
+
// Unparseable → NOT already done, so the adopt layer runs and reports it, rather than
|
|
629
|
+
// silently skipping on a file it could not read.
|
|
630
|
+
writeFileSync(join(dir, 'package.json'), '{ not json');
|
|
631
|
+
assert.equal(P.alreadyScaffolded(dir, 'adopt'), false);
|
|
632
|
+
} finally {
|
|
633
|
+
rmSync(dir, { recursive: true, force: true });
|
|
634
|
+
}
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
// Drive the scaffold in a given mode against a fresh base and return the joined argv.
|
|
638
|
+
async function scaffoldInMode(mode, { token = null, seedRoot = null } = {}) {
|
|
639
|
+
const base = mkdtempSync(join(tmpdir(), `mode-${mode}-`));
|
|
640
|
+
const origBase = P.CONFIG.standaloneBase;
|
|
641
|
+
P.CONFIG.standaloneBase = base;
|
|
642
|
+
try {
|
|
643
|
+
if (seedRoot) { mkdirSync(join(base, 'demo'), { recursive: true }); seedRoot(join(base, 'demo')); }
|
|
644
|
+
const { deps, cmds } = recorder({ apply: true });
|
|
645
|
+
const writes = [];
|
|
646
|
+
deps.writeFile = (p, body, opts) => { writes.push({ p, body, opts }); return { ok: true }; };
|
|
647
|
+
if (token) deps.getGithubToken = async () => ({ token, scope: 'read:user,repo' });
|
|
648
|
+
await P.scaffoldStandaloneRepo(
|
|
649
|
+
{ slug: 'demo', hosting_shape: 'standalone', target_ref: 'lars589/demo', owner_builder_id: 7, onboard_mode: mode },
|
|
650
|
+
{ ...deps, instanceRoot: base },
|
|
651
|
+
);
|
|
652
|
+
return { joined: cmds.join('\n'), cmds, writes, root: join(base, 'demo') };
|
|
653
|
+
} finally {
|
|
654
|
+
P.CONFIG.standaloneBase = origBase;
|
|
655
|
+
rmSync(base, { recursive: true, force: true });
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
await ta('scaffoldStandaloneRepo(adopt): CLONES the existing repo instead of git-init-ing a fresh tree', async () => {
|
|
660
|
+
const { joined } = await scaffoldInMode('adopt', { token: 'gho_TOK' });
|
|
661
|
+
assert.match(joined, /clone https:\/\/x-access-token@github\.com\/lars589\/demo\.git/, 'clones over the token URL (covers a PRIVATE repo the shared deploy key cannot read)');
|
|
662
|
+
assert.ok(!/git -C \S+ init -q/.test(joined), 'never git-inits a fresh tree on the adopt path — that is what produced the unrelated history');
|
|
663
|
+
assert.ok(!/checkout -B main/.test(joined), 'does not force-create the branch over the cloned one');
|
|
664
|
+
assert.match(joined, /init\.js --from .* --no-seed --adopt --accept /, 'materializes via init --adopt --accept (layering, accept-gated)');
|
|
665
|
+
// origin must end up as the SSH remote, like greenfield leaves it: the ongoing deploy
|
|
666
|
+
// (`git pull --ff-only origin main`, and the box timer's `git reset --hard origin/main`)
|
|
667
|
+
// reads origin, and the one-shot token URL cannot authenticate once its credential file
|
|
668
|
+
// is deleted.
|
|
669
|
+
assert.match(joined, /git -C \S+ remote set-url origin git@github-deploy:lars589\/demo\.git/, 'normalizes origin to the SSH remote after cloning');
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
// THE SAFETY PROPERTY. Since ADR 0155 this credential can write to EVERY repo the owner
|
|
673
|
+
// owns, so a --force here would be a route to destroying a user's work. A rejected push is
|
|
674
|
+
// the correct, recoverable outcome.
|
|
675
|
+
await ta('scaffoldStandaloneRepo(adopt): NEVER force-pushes and never passes --force to init', async () => {
|
|
676
|
+
const { joined } = await scaffoldInMode('adopt', { token: 'gho_TOK' });
|
|
677
|
+
assert.ok(!/--force/.test(joined), 'no --force anywhere in the adopt argv');
|
|
678
|
+
assert.ok(!/ -f\b/.test(joined.replace(/rm -f /g, '')), 'no -f force shorthand (rm -f of the credential file excepted)');
|
|
679
|
+
assert.ok(!/\+refs|\+HEAD|:\+/.test(joined), 'no leading-+ refspec (the other way to force a push)');
|
|
680
|
+
assert.match(joined, /push https:\/\/x-access-token@github\.com\/lars589\/demo\.git HEAD:main/, 'a plain fast-forward push');
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
await ta('scaffoldStandaloneRepo(greenfield): unchanged — git init + --force, no clone', async () => {
|
|
684
|
+
const { joined } = await scaffoldInMode('greenfield', { token: 'gho_TOK' });
|
|
685
|
+
assert.match(joined, /git -C \S+ init -q/, 'still git-inits the empty repo');
|
|
686
|
+
assert.match(joined, /checkout -B main/);
|
|
687
|
+
assert.match(joined, /init\.js --from .* --no-seed --force /, 'greenfield keeps --force (safe on an empty repo)');
|
|
688
|
+
assert.ok(!/--adopt/.test(joined), 'no --adopt on the greenfield path');
|
|
689
|
+
assert.ok(!/ clone /.test(joined), 'nothing to clone from an empty repo');
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
await ta('scaffoldStandaloneRepo(adopt): no owner token → clones over the SSH remote (no credential helper)', async () => {
|
|
693
|
+
const { joined } = await scaffoldInMode('adopt'); // recorder default getGithubToken → null
|
|
694
|
+
assert.match(joined, /clone git@github-deploy:lars589\/demo\.git/, 'falls back to the SSH remote');
|
|
695
|
+
assert.ok(!/credential\.helper=/.test(joined), 'no credential helper without a token');
|
|
696
|
+
assert.match(joined, /push -u origin main/, 'and the SSH origin push');
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
await ta('scaffoldStandaloneRepo(adopt): does NOT clobber an existing .gitignore — appends only what the deploy needs', async () => {
|
|
700
|
+
const { writes } = await scaffoldInMode('adopt', {
|
|
701
|
+
token: 'gho_TOK',
|
|
702
|
+
seedRoot: (root) => writeFileSync(join(root, '.gitignore'), 'their-secrets/\ncoverage/\n'),
|
|
703
|
+
});
|
|
704
|
+
const gi = writes.find((w) => /\.gitignore$/.test(w.p));
|
|
705
|
+
assert.ok(gi, 'wrote .gitignore');
|
|
706
|
+
assert.match(String(gi.body), /their-secrets\//, 'the owner’s existing rules survive');
|
|
707
|
+
assert.match(String(gi.body), /coverage\//);
|
|
708
|
+
assert.match(String(gi.body), /node_modules\//, 'and the entries `npm ci` + the deploy require are added');
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
await ta('scaffoldStandaloneRepo(adopt): a default branch that is not `main` fails LOUDLY, not silently', async () => {
|
|
712
|
+
// The rest of provisioning is main-only (git pull --ff-only origin main; the box deploy
|
|
713
|
+
// does git reset --hard origin/main), so layering onto another branch would push somewhere
|
|
714
|
+
// the deploy never reads — a half-working instance with no error.
|
|
715
|
+
const base = mkdtempSync(join(tmpdir(), 'mode-branch-'));
|
|
716
|
+
const origBase = P.CONFIG.standaloneBase;
|
|
717
|
+
P.CONFIG.standaloneBase = base;
|
|
718
|
+
try {
|
|
719
|
+
const { deps } = recorder({ apply: true });
|
|
720
|
+
deps.getGithubToken = async () => ({ token: 'gho_TOK', scope: 'repo' });
|
|
721
|
+
deps.exec = (cmd) => ({ ok: true, stdout: /rev-parse --abbrev-ref HEAD/.test(cmd) ? 'master\n' : '' });
|
|
722
|
+
await assert.rejects(
|
|
723
|
+
() => P.scaffoldStandaloneRepo(
|
|
724
|
+
{ slug: 'demo', hosting_shape: 'standalone', target_ref: 'lars589/demo', owner_builder_id: 7, onboard_mode: 'adopt' },
|
|
725
|
+
{ ...deps, instanceRoot: base },
|
|
726
|
+
),
|
|
727
|
+
/default branch is "master", not "main"/,
|
|
728
|
+
);
|
|
729
|
+
} finally {
|
|
730
|
+
P.CONFIG.standaloneBase = origBase;
|
|
731
|
+
rmSync(base, { recursive: true, force: true });
|
|
732
|
+
}
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
await ta('scaffoldStandaloneRepo(adopt): the credential file is removed even when a git step THROWS', async () => {
|
|
736
|
+
const base = mkdtempSync(join(tmpdir(), 'mode-cred-'));
|
|
737
|
+
const origBase = P.CONFIG.standaloneBase;
|
|
738
|
+
P.CONFIG.standaloneBase = base;
|
|
739
|
+
try {
|
|
740
|
+
const { deps, cmds } = recorder({ apply: true });
|
|
741
|
+
deps.getGithubToken = async () => ({ token: 'gho_TOK', scope: 'repo' });
|
|
742
|
+
deps.exec = (cmd) => {
|
|
743
|
+
cmds.push(cmd);
|
|
744
|
+
if (/ push /.test(cmd)) throw new Error('remote rejected: non-fast-forward');
|
|
745
|
+
return { ok: true, stdout: '' };
|
|
746
|
+
};
|
|
747
|
+
await assert.rejects(
|
|
748
|
+
() => P.scaffoldStandaloneRepo(
|
|
749
|
+
{ slug: 'demo', hosting_shape: 'standalone', target_ref: 'lars589/demo', owner_builder_id: 7, onboard_mode: 'adopt' },
|
|
750
|
+
{ ...deps, instanceRoot: base },
|
|
751
|
+
),
|
|
752
|
+
/non-fast-forward/,
|
|
753
|
+
);
|
|
754
|
+
assert.match(cmds.join('\n'), /rm -f [^\n]*bongos-gitcred-demo/, 'a failed push must not leave the token on disk');
|
|
755
|
+
} finally {
|
|
756
|
+
P.CONFIG.standaloneBase = origBase;
|
|
757
|
+
rmSync(base, { recursive: true, force: true });
|
|
758
|
+
}
|
|
759
|
+
});
|
|
760
|
+
|
|
591
761
|
await ta('provisionInstance is idempotent: an already-active instance is a no-op', async () => {
|
|
592
762
|
const { deps, cmds } = recorder();
|
|
593
763
|
const r = await P.provisionInstance({ ...coTenant, status: 'active' }, deps);
|