oysterun 1.6.0-beta.2 → 1.6.0-beta.21
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/bin/oysterun.mjs +64 -17
- package/dev/client/connectivity/completed-message-neighbor-click.mjs +78 -0
- package/dev/client/connectivity/completed-message-neighbor-contract.mjs +205 -0
- package/dev/client/connectivity/completed-message-visibility.d.mts +4 -4
- package/dev/client/connectivity/completed-message-visibility.mjs +14 -11
- package/dev/client/connectivity/desktop-chat-sidebar-preference.mjs +57 -0
- package/dev/client/connectivity/host-connection-feedback.mjs +12 -1
- package/dev/client/connectivity/managed-host-browser.mjs +10 -0
- package/dev/client/connectivity/session-navigation-intent.mjs +189 -0
- package/dev/client/connectivity/session-transition-state.mjs +18 -6
- package/dev/client/connectivity/test-completed-message-neighbor-click.mjs +180 -0
- package/dev/client/web/index.html +5367 -609
- package/dev/client/web-chat/dist/assets/{ReactPrism-BwJhidYf.js → ReactPrism-C6YiTZTC.js} +1 -1
- package/dev/client/web-chat/dist/assets/{ReactPrism-BwJhidYf.js.map → ReactPrism-C6YiTZTC.js.map} +1 -1
- package/dev/client/web-chat/dist/assets/SessionsSetupApp-CKJOSPFP.js +6442 -0
- package/dev/client/web-chat/dist/assets/SessionsSetupApp-CKJOSPFP.js.map +1 -0
- package/dev/client/web-chat/dist/assets/SessionsSetupApp-Dx09Uz-6.css +1 -0
- package/dev/client/web-chat/dist/assets/index-Ae5I4LHp.js +9 -0
- package/dev/client/web-chat/dist/assets/index-Ae5I4LHp.js.map +1 -0
- package/dev/client/web-chat/dist/assets/index-CN_FoTB1.js +2 -0
- package/dev/client/web-chat/dist/assets/index-CN_FoTB1.js.map +1 -0
- package/dev/client/web-chat/dist/assets/{index--1xmQ99x.js → index-DCQFwgGf.js} +2 -2
- package/dev/client/web-chat/dist/assets/{index--1xmQ99x.js.map → index-DCQFwgGf.js.map} +1 -1
- package/dev/client/web-chat/dist/assets/matrix-entry-BV7G38kH.css +10 -0
- package/dev/client/web-chat/dist/assets/matrix-entry-abAy3aOo.js +594 -0
- package/dev/client/web-chat/dist/assets/matrix-entry-abAy3aOo.js.map +1 -0
- package/dev/client/web-chat/dist/assets/{pdf-ZjtalfgI.js → pdf-DN7oVvM6.js} +5 -5
- package/dev/client/web-chat/dist/assets/pdf-DN7oVvM6.js.map +1 -0
- package/dev/client/web-chat/dist/assets/session-notice-DlyODy1z.css +1 -0
- package/dev/client/web-chat/dist/assets/web-BlYuDlOV.js +1 -0
- package/dev/client/web-chat/dist/assets/{web-c70YaskK.js.map → web-BlYuDlOV.js.map} +1 -1
- package/dev/client/web-chat/dist/assets/web-Dabw6cJi.js +1 -0
- package/dev/client/web-chat/dist/assets/{web-BzHcZK2M.js.map → web-Dabw6cJi.js.map} +1 -1
- package/dev/client/web-chat/dist/index.html +1 -2
- package/docs/routec/dev/cloud_deplyments.md +5 -0
- package/docs/routec/dev/npm_native_dependency_install.md +6 -2
- package/docs/routec/dev/npmjs_publish.md +55 -19
- package/docs/routec/dev/p262_setup_input_file.md +117 -0
- package/docs/routec/host2_turn_started_idempotency_diagnostics.md +8 -7
- package/docs/routec/host_setup.md +39 -10
- package/docs/routec/oysterun_operation_interface_spec.md +78 -2
- package/docs/routec/provider_contract.md +35 -5
- package/host-service/README.md +36 -0
- package/host-service/adapters/claude-code-adapter.mjs +48 -5
- package/host-service/adapters/codex-app-server-adapter.mjs +320 -18
- package/host-service/adapters/debug-fixture-provider-adapter.mjs +102 -1
- package/host-service/adapters/debug-large-tool-spillover-provider-adapter.mjs +2 -0
- package/host-service/adapters/debug-p135-codex-replay-provider-adapter.mjs +2 -0
- package/host-service/adapters/debug-routec-structural-replay-provider-adapter.mjs +2 -0
- package/host-service/agent-config.mjs +16 -5
- package/host-service/agent-folder-availability.mjs +41 -0
- package/host-service/apns-complete-message-dispatcher.mjs +1 -0
- package/host-service/assets/product-skills/Oysterun/modules/oysterun-find-context/SKILL.md +24 -0
- package/host-service/assets/product-skills/Oysterun/modules/oysterun-session-chat/SKILL.md +94 -5
- package/host-service/assets/product-skills/Oysterun/modules/oysterun-sessions/SKILL.md +103 -0
- package/host-service/assets/product-skills/Oysterun/modules/oysterun-website/SKILL.md +7 -0
- package/host-service/assets/product-skills/oysterun-find-context/SKILL.md +24 -0
- package/host-service/assets/product-skills/oysterun-session-chat/SKILL.md +94 -5
- package/host-service/assets/product-skills/oysterun-sessions/SKILL.md +103 -0
- package/host-service/assets/product-skills/oysterun-website/SKILL.md +7 -0
- package/host-service/callback-durable-store.mjs +354 -47
- package/host-service/callback-foundation.mjs +21 -1
- package/host-service/callback-host-integration.mjs +345 -30
- package/host-service/claude-acp-model-discovery.mjs +23 -18
- package/host-service/cli/assert-installer-reinstall-target.mjs +9 -0
- package/host-service/cli/installer-recovery-guidance.mjs +20 -0
- package/host-service/cli/prepare-restart-restore.mjs +2 -0
- package/host-service/cli/product-cli.mjs +34 -33
- package/host-service/cli/reconcile-initial-preflight.mjs +19 -0
- package/host-service/cli/recover-installer-lifecycle.mjs +24 -0
- package/host-service/cli/run-host-lifecycle.mjs +2 -0
- package/host-service/cli/run-staged-lifecycle-recovery.mjs +161 -0
- package/host-service/codex-model-discovery.mjs +23 -18
- package/host-service/complete-cli-uninstall.mjs +176 -0
- package/host-service/complete-message-notification-predicate.mjs +8 -6
- package/host-service/complete-message-notification-title.mjs +4 -0
- package/host-service/completed-session-unread.mjs +425 -42
- package/host-service/completed-turn-navigation.mjs +444 -144
- package/host-service/completed-turn-notification-observer.mjs +116 -0
- package/host-service/completed-turn-service.mjs +445 -0
- package/host-service/config.mjs +115 -9
- package/host-service/desired-open-session-state.mjs +331 -0
- package/host-service/fixtures/p218-completed-turn-notification.json +11 -0
- package/host-service/frp-agent.mjs +9 -9
- package/host-service/grok-acp-model-discovery.mjs +31 -33
- package/host-service/host-lifecycle-recovery.mjs +37 -0
- package/host-service/host-port-resolution.mjs +98 -0
- package/host-service/host-runtime-diagnostics.mjs +18 -1
- package/host-service/initial-preflight-recovery.mjs +71 -0
- package/host-service/installer-recovery-guidance.mjs +85 -0
- package/host-service/installer-reinstall-bindings.mjs +49 -0
- package/host-service/installer-reinstall-target.mjs +49 -0
- package/host-service/legacy-continuity-observation.mjs +33 -0
- package/host-service/lifecycle-compat-cli.mjs +13 -0
- package/host-service/lifecycle-compat-preload.mjs +89 -0
- package/host-service/lifecycle-compatibility.mjs +164 -0
- package/host-service/lifecycle-continuity-assets.mjs +222 -0
- package/host-service/lifecycle-continuity-probe.mjs +97 -0
- package/host-service/lifecycle-continuity-projection.mjs +29 -0
- package/host-service/lifecycle-continuity-runner.mjs +192 -0
- package/host-service/lifecycle-continuity.mjs +493 -0
- package/host-service/lifecycle-service-render-bundle.mjs +120 -0
- package/host-service/lifecycle-user-path.mjs +34 -0
- package/host-service/link-annotations.mjs +10 -0
- package/host-service/matrix-credential-issuer.mjs +32 -1
- package/host-service/matrix-event-correlator.mjs +166 -27
- package/host-service/matrix-facade.mjs +96 -19
- package/host-service/matrix-room-binding.mjs +24 -0
- package/host-service/matrix-transcript-read-adapter.mjs +17 -3
- package/host-service/model-discovery-guardian.mjs +40 -0
- package/host-service/model-discovery-process.mjs +141 -0
- package/host-service/onboard-lifecycle-status.mjs +28 -0
- package/host-service/onboard.mjs +479 -0
- package/host-service/package.json +2 -1
- package/host-service/portable-scheduler-definitions.mjs +28 -0
- package/host-service/product-runtime-host-origin.mjs +22 -0
- package/host-service/provider-model-params.mjs +14 -17
- package/host-service/provider-model-refresh-runner.mjs +124 -169
- package/host-service/provider-model-refresh-schedule.mjs +11 -0
- package/host-service/provider-resume-contract.mjs +9 -3
- package/host-service/restart-restore-state.mjs +25 -0
- package/host-service/retained-host-state-holder.mjs +57 -0
- package/host-service/routec-matrix-sqlite-store.mjs +155 -10
- package/host-service/routec-matrix-storage-adapter.mjs +208 -25
- package/host-service/routec-matrix-storage-paths.mjs +12 -0
- package/host-service/routec-request-delay-window.mjs +55 -0
- package/host-service/scheduler-acp-provider-runner.mjs +2 -1
- package/host-service/scheduler-direct-provider-runner.mjs +71 -9
- package/host-service/scheduler-run-timeout-policy.mjs +85 -0
- package/host-service/scheduler-service.mjs +96 -9
- package/host-service/scheduler-store.mjs +9 -2
- package/host-service/server.mjs +2202 -552
- package/host-service/service-definition-migration.mjs +10 -3
- package/host-service/session-collection-service.mjs +320 -0
- package/host-service/session-history.mjs +99 -15
- package/host-service/session-list-preview-service.mjs +87 -12
- package/host-service/session-list-projection-service.mjs +182 -0
- package/host-service/session-loop-definitions.mjs +702 -75
- package/host-service/session-manager.mjs +473 -38
- package/host-service/session-switcher-projection.mjs +11 -3
- package/host-service/session-tags.mjs +451 -0
- package/host-service/session-transcript-target.mjs +103 -0
- package/host-service/setup-input-file.mjs +91 -0
- package/host-service/setup-provider-model-defaults.mjs +53 -0
- package/host-service/setup.mjs +133 -101
- package/host-service/stopped-user-host-package.mjs +60 -0
- package/host-service/test-fixtures/p236-navigation-original.mjs +500 -0
- package/host-service/update-reminder-state.mjs +7 -0
- package/host-service/voice-input-receipt-store.mjs +60 -0
- package/package.json +1 -1
- package/static/oysterun-social-preview.png +0 -0
- package/static/site/assets/oysterun-social-preview.png +0 -0
- package/static/site/blog/en/index.html +17 -0
- package/static/site/blog/generated_manifest.json +9 -9
- package/static/site/blog/index.html +18 -0
- package/static/site/blog/voice-input-model-experiment/en/index.html +17 -0
- package/static/site/blog/voice-input-model-experiment/zh/index.html +17 -0
- package/static/site/blog/zh/index.html +17 -0
- package/static/site/docs/choose-and-recover-a-provider/en/index.html +17 -0
- package/static/site/docs/choose-and-recover-a-provider/zh/index.html +17 -0
- package/static/site/docs/configure-session-files-and-settings/en/index.html +17 -0
- package/static/site/docs/configure-session-files-and-settings/zh/index.html +17 -0
- package/static/site/docs/en/index.html +17 -0
- package/static/site/docs/generated_manifest.json +60 -60
- package/static/site/docs/host-authentication-and-connection/en/index.html +17 -0
- package/static/site/docs/host-authentication-and-connection/zh/index.html +17 -0
- package/static/site/docs/index.html +18 -0
- package/static/site/docs/install-and-use-a-skill/en/index.html +17 -0
- package/static/site/docs/install-and-use-a-skill/zh/index.html +17 -0
- package/static/site/docs/read-large-tool-results/en/index.html +17 -0
- package/static/site/docs/read-large-tool-results/zh/index.html +17 -0
- package/static/site/docs/recover-from-a-host-runtime-error/en/index.html +17 -0
- package/static/site/docs/recover-from-a-host-runtime-error/zh/index.html +17 -0
- package/static/site/docs/restore-work-after-a-host-restart/en/index.html +17 -0
- package/static/site/docs/restore-work-after-a-host-restart/zh/index.html +17 -0
- package/static/site/docs/send-images-and-media/en/index.html +17 -0
- package/static/site/docs/send-images-and-media/zh/index.html +17 -0
- package/static/site/docs/set-up-and-understand-a-host/en/index.html +17 -0
- package/static/site/docs/set-up-and-understand-a-host/zh/index.html +17 -0
- package/static/site/docs/understand-agent-activity/en/index.html +19 -2
- package/static/site/docs/understand-agent-activity/en/index.md +2 -2
- package/static/site/docs/understand-agent-activity/zh/index.html +19 -2
- package/static/site/docs/understand-agent-activity/zh/index.md +2 -2
- package/static/site/docs/use-the-oysterun-cli-safely/en/index.html +17 -0
- package/static/site/docs/use-the-oysterun-cli-safely/zh/index.html +17 -0
- package/static/site/docs/zh/index.html +17 -0
- package/static/site/en/index.html +18 -1
- package/static/site/en/index.md +1 -1
- package/static/site/faq/en/index.html +17 -0
- package/static/site/faq/index.html +18 -0
- package/static/site/faq/zh/index.html +17 -0
- package/static/site/getting-started/en/index.html +23 -1
- package/static/site/getting-started/en/index.md +7 -1
- package/static/site/getting-started/index.html +18 -0
- package/static/site/getting-started/zh/index.html +23 -1
- package/static/site/getting-started/zh/index.md +7 -1
- package/static/site/index.html +18 -0
- package/static/site/install.sh +676 -127
- package/static/site/overview/en/index.html +18 -1
- package/static/site/overview/en/index.md +2 -2
- package/static/site/overview/index.html +18 -0
- package/static/site/overview/zh/index.html +18 -1
- package/static/site/overview/zh/index.md +2 -2
- package/static/site/privacy/en/index.html +17 -0
- package/static/site/privacy/index.html +18 -0
- package/static/site/privacy/zh/index.html +17 -0
- package/static/site/zh/index.html +18 -1
- package/static/site/zh/index.md +1 -1
- package/static/site_source/content/tutorials/published/understand-agent-activity/en/article.html +2 -2
- package/static/site_source/content/tutorials/published/understand-agent-activity/zh/article.html +2 -2
- package/static/site_source/downloads/install.sh +676 -127
- package/static/site_source/manifests/host_install.json +2 -10
- package/static/site_source/pages/getting-started/en.html +6 -1
- package/static/site_source/pages/getting-started/zh.html +6 -1
- package/static/site_source/pages/home/en.html +1 -1
- package/static/site_source/pages/home/zh.html +1 -1
- package/static/site_source/pages/home_main.html +3 -2
- package/static/site_source/pages/overview/en.html +1 -1
- package/static/site_source/pages/overview/zh.html +1 -1
- package/tool_scripts/install_oysterun_update.mjs +78 -13
- package/tool_scripts/oysterun_continuity_transaction.mjs +438 -0
- package/tool_scripts/oysterun_npm_package_manifest.mjs +32 -0
- package/tool_scripts/oysterun_release_service.sh +5 -0
- package/tool_scripts/oysterun_service_common.sh +59 -24
- package/tool_scripts/oysterun_setup_input.sh +97 -0
- package/tool_scripts/oysterun_update_restart_runner.mjs +19 -3
- package/tool_scripts/p233_legacy_bootstrap.mjs +254 -0
- package/tool_scripts/resolve_user_host_port.mjs +15 -0
- package/tool_scripts/restart_oysterun.sh +8 -2
- package/tool_scripts/stack_dashboard_credentials.mjs +2 -1
- package/tool_scripts/status_oysterun_launch_agents.sh +13 -1
- package/tool_scripts/sync_host_stack_config.mjs +9 -6
- package/tool_scripts/test_environment_remote_cloud_deprovision.mjs +419 -0
- package/tool_scripts/uninstall_oysterun_launch_agents.sh +3 -5
- package/dev/client/web-chat/dist/assets/index-Ce9NZBZf.js +0 -129
- package/dev/client/web-chat/dist/assets/index-Ce9NZBZf.js.map +0 -1
- package/dev/client/web-chat/dist/assets/index-DzIClZ3T.js +0 -2
- package/dev/client/web-chat/dist/assets/index-DzIClZ3T.js.map +0 -1
- package/dev/client/web-chat/dist/assets/index-NYnaSGBb.css +0 -1
- package/dev/client/web-chat/dist/assets/pdf-ZjtalfgI.js.map +0 -1
- package/dev/client/web-chat/dist/assets/web-BzHcZK2M.js +0 -1
- package/dev/client/web-chat/dist/assets/web-c70YaskK.js +0 -1
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Host-only observation; lifecycle children receive only the exact renderer
|
|
2
|
+
// bridge. Provider children receive neither NODE_OPTIONS nor this observer.
|
|
3
|
+
import { channel } from 'node:diagnostics_channel';
|
|
4
|
+
import { registerHooks, syncBuiltinESMExports } from 'node:module';
|
|
5
|
+
import childProcess from 'node:child_process';
|
|
6
|
+
import { dirname, join, resolve } from 'node:path';
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
8
|
+
import { readCompatibility, assertCompatibilityProcess } from './lifecycle-compatibility.mjs';
|
|
9
|
+
import { readContinuity } from './lifecycle-continuity.mjs';
|
|
10
|
+
import { ensureContinuityProbeGrant, fetchContinuityObservation, removeContinuityProbeGrant } from './lifecycle-continuity-probe.mjs';
|
|
11
|
+
import { readContinuityUserPath, recordContinuityUserPath } from './lifecycle-user-path.mjs';
|
|
12
|
+
|
|
13
|
+
export function installResponseObserver(bound, dependencies = {}) {
|
|
14
|
+
const d = bound.descriptor;
|
|
15
|
+
const observe = dependencies.observe || fetchContinuityObservation;
|
|
16
|
+
const responseChannel = dependencies.channel || channel('http.server.response.finish');
|
|
17
|
+
let active = true, inFlight = false;
|
|
18
|
+
const unsubscribe = () => { active = false; responseChannel.unsubscribe(onResponse); };
|
|
19
|
+
async function onResponse({ request }) {
|
|
20
|
+
if (!active || inFlight || !request || /^\/(health|admin\/lifecycle-qualification)(?:\?|$)/.test(request.url || '')) return;
|
|
21
|
+
inFlight = true;
|
|
22
|
+
try {
|
|
23
|
+
readCompatibility(bound.path); // Changed descriptor/dependencies never authorize evidence.
|
|
24
|
+
const receipt = readContinuity(d.receipt_path, d.operation_id);
|
|
25
|
+
const expected = readContinuityUserPath(d.receipt_path, receipt);
|
|
26
|
+
if (!['COMMITTED','CLEANED','ROLLED_BACK_HEALTHY'].includes(receipt.state) || !receipt.snapshot_replay_retired_at) return;
|
|
27
|
+
if (expected?.status === 'verified') { removeContinuityProbeGrant(d.config_dir, d.observation_id); unsubscribe(); return; }
|
|
28
|
+
if (expected?.status !== 'pending' || expected.pid !== process.pid || expected.origin !== d.origin) return;
|
|
29
|
+
ensureContinuityProbeGrant(d.config_dir, d.observation_id);
|
|
30
|
+
const observation = await observe({ configDir: d.config_dir, operationId: d.observation_id, origin: d.origin, timeoutMs: 5000 });
|
|
31
|
+
const proof = observation.authenticated_user_path;
|
|
32
|
+
const observedOrigin = new URL(observation.host.managed_endpoint || observation.host.direct_host_url || `http://127.0.0.1:${observation.host.port}`).origin;
|
|
33
|
+
if (observedOrigin !== d.origin || observation.host.version !== receipt.source.version ||
|
|
34
|
+
observation.host.pid !== process.pid || observation.host.config_dir !== d.config_dir ||
|
|
35
|
+
observation.host.package_root !== d.package_root || observation.host.runtime_identity_digest !== d.runtime_identity_digest ||
|
|
36
|
+
observation.host.service_definition_sha256 !== d.definition_sha256 || observation.route.origin !== d.origin ||
|
|
37
|
+
observation.route.operation_id !== d.observation_id || !proof || proof.pid !== process.pid) return;
|
|
38
|
+
// The old Host selects its configured route Map entry; no Dashboard auth
|
|
39
|
+
// is performed by this request. This function only writes the sidecar.
|
|
40
|
+
if (recordContinuityUserPath(d.receipt_path, d.operation_id, { ...proof, origin: d.origin })) {
|
|
41
|
+
removeContinuityProbeGrant(d.config_dir, d.observation_id);
|
|
42
|
+
unsubscribe();
|
|
43
|
+
}
|
|
44
|
+
} catch { /* One failed event stays pending. No retry timer or mutation. */ }
|
|
45
|
+
finally { inFlight = false; }
|
|
46
|
+
}
|
|
47
|
+
responseChannel.subscribe(onResponse);
|
|
48
|
+
return { unsubscribe, state: () => ({ active, inFlight }) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function installLifecycleChildBridge(bound) {
|
|
52
|
+
const d = bound.descriptor;
|
|
53
|
+
const allowed = new Set(['preflight_oysterun_restart.sh', 'oysterun_update_restart_runner.mjs', 'install_oysterun_update.mjs']
|
|
54
|
+
.map(name => join(d.package_root, 'tool_scripts', name)));
|
|
55
|
+
for (const key of ['spawn', 'spawnSync']) {
|
|
56
|
+
const original = childProcess[key];
|
|
57
|
+
childProcess[key] = function(command, args, options) {
|
|
58
|
+
if (Array.isArray(args) && allowed.has(args[0]) && options?.cwd === d.package_root &&
|
|
59
|
+
options.env?.OYSTERUN_CONFIG_DIR === d.config_dir &&
|
|
60
|
+
(command === '/bin/bash' || command === d.node_realpath)) {
|
|
61
|
+
readCompatibility(bound.path);
|
|
62
|
+
options = { ...options, env: { ...options.env, NODE_OPTIONS: d.preload } };
|
|
63
|
+
}
|
|
64
|
+
return original.call(this, command, args, options);
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
syncBuiltinESMExports();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (process.env.NODE_OPTIONS?.includes(import.meta.url)) {
|
|
71
|
+
const bound = readCompatibility(join(dirname(fileURLToPath(import.meta.url)), 'descriptor.json'));
|
|
72
|
+
const d = bound.descriptor;
|
|
73
|
+
const bridges = new Map(['service-definition-migration.mjs', 'lifecycle-continuity-projection.mjs'].map(name =>
|
|
74
|
+
[pathToFileURL(join(d.package_root, 'host-service', name)).href, pathToFileURL(join(bound.root, name)).href]));
|
|
75
|
+
registerHooks({ resolve(specifier, context, nextResolve) {
|
|
76
|
+
const result = nextResolve(specifier, context);
|
|
77
|
+
const bridged = bridges.get(result.url.split('?')[0]);
|
|
78
|
+
if (bridged) return { ...result, url: bridged, shortCircuit: true };
|
|
79
|
+
return result;
|
|
80
|
+
} });
|
|
81
|
+
if (resolve(process.argv[1] || '') === join(d.package_root, 'host-service/server.mjs')) {
|
|
82
|
+
assertCompatibilityProcess(bound);
|
|
83
|
+
delete process.env.NODE_OPTIONS;
|
|
84
|
+
// fork inherits execArgv as well as env. The observer must never propagate.
|
|
85
|
+
process.execArgv = process.execArgv.filter(arg => !arg.includes('lifecycle-compat-preload.mjs'));
|
|
86
|
+
installLifecycleChildBridge(bound);
|
|
87
|
+
installResponseObserver(bound);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// Receipt-bound executable dependency for a restored pre-P267 Host. The pointer
|
|
2
|
+
// selects an immutable descriptor; it is never an active mutation operation.
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { existsSync, lstatSync, readFileSync, mkdirSync, writeFileSync, rmSync, symlinkSync, realpathSync } from 'node:fs';
|
|
5
|
+
import { dirname, join, resolve, relative } from 'node:path';
|
|
6
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
7
|
+
import { readContinuity, continuityReceiptPath, prepareContinuity, writeContinuityAtomic, writeContinuityBytesAtomic } from './lifecycle-continuity.mjs';
|
|
8
|
+
import { createContinuityProbeGrant } from './lifecycle-continuity-probe.mjs';
|
|
9
|
+
const hash = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
10
|
+
const fail = message => { throw new Error(`p267_compatibility: ${message}`); };
|
|
11
|
+
export const compatibilityPointer = config => join(resolve(config), 'lifecycle-compatibility.json');
|
|
12
|
+
const names = ['lifecycle-compatibility.mjs', 'lifecycle-compat-preload.mjs', 'lifecycle-compat-cli.mjs',
|
|
13
|
+
'service-definition-migration.mjs', 'lifecycle-continuity.mjs', 'lifecycle-continuity-probe.mjs',
|
|
14
|
+
'lifecycle-user-path.mjs', 'lifecycle-continuity-projection.mjs', 'restart-restore-state.mjs', 'npm-update-policy.mjs', 'lifecycle-diagnostics.mjs'];
|
|
15
|
+
function bytes(path) {
|
|
16
|
+
const stat = lstatSync(path);
|
|
17
|
+
if (!stat.isFile() || stat.isSymbolicLink()) fail('dependency must be a regular file');
|
|
18
|
+
return readFileSync(path);
|
|
19
|
+
}
|
|
20
|
+
export function readCompatibility(path, seen = new Set()) {
|
|
21
|
+
if (seen.has(path)) fail('cyclic predecessor');
|
|
22
|
+
seen.add(path);
|
|
23
|
+
const descriptor = JSON.parse(bytes(path));
|
|
24
|
+
const d = descriptor;
|
|
25
|
+
if (d.schema !== 'oysterun.lifecycle_compatibility.v1' ||
|
|
26
|
+
path !== join(resolve(d.config_dir), 'lifecycle-compat', d.operation_id, 'descriptor.json') ||
|
|
27
|
+
d.receipt_path !== continuityReceiptPath(d.config_dir, d.operation_id)) fail('descriptor geometry');
|
|
28
|
+
const receipt = readContinuity(d.receipt_path, d.operation_id);
|
|
29
|
+
if (receipt.compatibility?.descriptor_path !== path || receipt.compatibility.sha256 !== hash(bytes(path)) ||
|
|
30
|
+
receipt.source.service_definition_sha256 !== d.source_definition_sha256 ||
|
|
31
|
+
receipt.baseline.host.package_root !== d.package_root || receipt.baseline.host.config_dir !== d.config_dir ||
|
|
32
|
+
receipt.baseline.host.stack !== d.stack || receipt.source.runtime_identity_digest !== d.runtime_identity_digest)
|
|
33
|
+
fail('descriptor receipt binding');
|
|
34
|
+
if (lstatSync(dirname(path)).isSymbolicLink()) fail('dependency directory symlink');
|
|
35
|
+
const inventory = Object.keys(d.files).sort().join();
|
|
36
|
+
const currentInventory = [...names, 'compatible-definition.json'].sort().join();
|
|
37
|
+
const rendererOnlyInventory = [...names.filter(name => name !== 'lifecycle-continuity-projection.mjs'), 'compatible-definition.json'].sort().join();
|
|
38
|
+
// A preceding receipt may retain the renderer-only closure. Its exact bytes
|
|
39
|
+
// remain bound; a new descriptor always retains the complete current closure.
|
|
40
|
+
if (inventory !== currentInventory && inventory !== rendererOnlyInventory) fail('dependency inventory');
|
|
41
|
+
for (const [name, digest] of Object.entries(d.files)) if (hash(bytes(join(dirname(path), name))) !== digest) fail(`changed dependency ${name}`);
|
|
42
|
+
if (d.predecessor) {
|
|
43
|
+
const prior = readCompatibility(d.predecessor.descriptor_path, seen);
|
|
44
|
+
if (hash(bytes(prior.path)) !== d.predecessor.sha256 || prior.descriptor.config_dir !== d.config_dir ||
|
|
45
|
+
prior.descriptor.definition_sha256 !== d.source_definition_sha256) fail('predecessor binding');
|
|
46
|
+
}
|
|
47
|
+
const definition = JSON.parse(bytes(join(dirname(path), 'compatible-definition.json')));
|
|
48
|
+
if (hash(definition.bytes) !== d.definition_sha256 || definition.sha256 !== d.definition_sha256) fail('definition identity');
|
|
49
|
+
return { descriptor: d, receipt, definition, path, root: dirname(path) };
|
|
50
|
+
}
|
|
51
|
+
export function readInstalledCompatibility(configDir) {
|
|
52
|
+
const pointer = compatibilityPointer(configDir);
|
|
53
|
+
if (!existsSync(pointer)) return null;
|
|
54
|
+
const selected = JSON.parse(bytes(pointer));
|
|
55
|
+
const bound = readCompatibility(selected.descriptor_path);
|
|
56
|
+
if (bound.descriptor.config_dir !== resolve(configDir) || hash(bytes(bound.path)) !== selected.sha256) fail('installed pointer binding');
|
|
57
|
+
return bound;
|
|
58
|
+
}
|
|
59
|
+
export function compatibilityRender(base, bundle) {
|
|
60
|
+
const bound = readInstalledCompatibility(bundle.host.config_dir);
|
|
61
|
+
if (!bound || bundle.artifact.package_root !== bound.descriptor.package_root ||
|
|
62
|
+
bundle.artifact.integrity_sha256 !== bound.descriptor.manifest_sha256) return base;
|
|
63
|
+
if (base.sha256 !== bound.descriptor.source_render_sha256 ||
|
|
64
|
+
bundle.runtime.toolchain_identity_digest !== bound.descriptor.runtime_identity_digest ||
|
|
65
|
+
bundle.host.stack_name !== bound.descriptor.stack) fail('canonical source render changed');
|
|
66
|
+
return bound.definition;
|
|
67
|
+
}
|
|
68
|
+
export function prepareCompatibility({ path, operationId, render }) {
|
|
69
|
+
let receipt = readContinuity(path, operationId);
|
|
70
|
+
if (receipt.compatibility) return readCompatibility(receipt.compatibility.descriptor_path);
|
|
71
|
+
if (['COMMITTED','CLEANED','ROLLED_BACK_HEALTHY'].includes(receipt.state)) fail('cannot add authority after closeout');
|
|
72
|
+
if (!receipt.resources || !receipt.source.definition_snapshot_digest) return null;
|
|
73
|
+
const source = existsSync(receipt.resources.previous_package_root) ? receipt.resources.previous_package_root : receipt.resources.active_package_root;
|
|
74
|
+
if (existsSync(join(source, 'host-service/lifecycle-user-path.mjs'))) return null;
|
|
75
|
+
const snapshot = JSON.parse(bytes(receipt.resources.service_definition_snapshot_path));
|
|
76
|
+
if (!snapshot.existed) return null; // No installed service dependency to extend.
|
|
77
|
+
const bundle = JSON.parse(bytes(receipt.resources.source_service_render_bundle_path));
|
|
78
|
+
const original = render(bundle, { compatibility: false });
|
|
79
|
+
const installed = readInstalledCompatibility(receipt.baseline.host.config_dir);
|
|
80
|
+
if ((original.sha256 !== snapshot.sha256 && installed?.definition.sha256 !== snapshot.sha256) ||
|
|
81
|
+
snapshot.sha256 !== receipt.source.service_definition_sha256) fail('source render/snapshot differs');
|
|
82
|
+
const config = receipt.baseline.host.config_dir;
|
|
83
|
+
const root = join(config, 'lifecycle-compat', operationId);
|
|
84
|
+
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
85
|
+
const files = {};
|
|
86
|
+
for (const name of names) {
|
|
87
|
+
const data = bytes(join(dirname(fileURLToPath(import.meta.url)), name));
|
|
88
|
+
writeFileSync(join(root, name), data, { mode: name === 'lifecycle-compat-cli.mjs' ? 0o700 : 0o600 });
|
|
89
|
+
files[name] = hash(data);
|
|
90
|
+
}
|
|
91
|
+
const preload = `--import=${pathToFileURL(join(root, 'lifecycle-compat-preload.mjs')).href}`;
|
|
92
|
+
if (bundle.version_owned.environment.NODE_OPTIONS) fail('source already has Node loader options');
|
|
93
|
+
const definition = render({ ...bundle, version_owned: { ...bundle.version_owned,
|
|
94
|
+
environment: { ...bundle.version_owned.environment, NODE_OPTIONS: preload } } }, { compatibility: false });
|
|
95
|
+
const defBytes = JSON.stringify(definition);
|
|
96
|
+
writeFileSync(join(root, 'compatible-definition.json'), defBytes, { mode: 0o600 });
|
|
97
|
+
files['compatible-definition.json'] = hash(defBytes);
|
|
98
|
+
const observationId = `p267-observation-${operationId}`;
|
|
99
|
+
const observationPath = continuityReceiptPath(config, observationId);
|
|
100
|
+
if (!existsSync(observationPath)) prepareContinuity({ path: observationPath, operationId: observationId, source: receipt.source, target: receipt.source,
|
|
101
|
+
baseline: { ...receipt.baseline, sessions: [] }, authority: { purpose: 'supplementary_read_only_observation', parent_operation_id: operationId } });
|
|
102
|
+
if (!existsSync(`${observationPath}.probe`)) createContinuityProbeGrant(config, observationId);
|
|
103
|
+
const d = { schema: 'oysterun.lifecycle_compatibility.v1', operation_id: operationId,
|
|
104
|
+
receipt_path: path, config_dir: config, package_root: receipt.baseline.host.package_root,
|
|
105
|
+
stack: receipt.baseline.host.stack, port: receipt.baseline.host.port,
|
|
106
|
+
source_definition_sha256: snapshot.sha256, source_render_sha256: original.sha256, definition_path: snapshot.definition_path,
|
|
107
|
+
definition_sha256: definition.sha256, manifest_sha256: hash(bytes(join(source, 'package.json'))),
|
|
108
|
+
runtime_identity_digest: receipt.source.runtime_identity_digest, node_realpath: bundle.runtime.node_realpath,
|
|
109
|
+
origin: new URL(receipt.baseline.host.managed_endpoint || receipt.baseline.host.direct_host_url || `http://127.0.0.1:${receipt.baseline.host.port}`).origin,
|
|
110
|
+
observation_id: observationId, observation_path: observationPath, preload, files,
|
|
111
|
+
predecessor: installed?.definition.sha256 === snapshot.sha256
|
|
112
|
+
? { descriptor_path: installed.path, sha256: hash(bytes(installed.path)) } : null };
|
|
113
|
+
const descriptorPath = join(root, 'descriptor.json');
|
|
114
|
+
writeContinuityAtomic(descriptorPath, d);
|
|
115
|
+
receipt = { ...receipt, compatibility: { descriptor_path: descriptorPath, sha256: hash(bytes(descriptorPath)) } };
|
|
116
|
+
writeContinuityAtomic(path, receipt);
|
|
117
|
+
return readCompatibility(descriptorPath);
|
|
118
|
+
}
|
|
119
|
+
export function installCompatibility(bound) {
|
|
120
|
+
// Caller already validated the source/target pair before restoring source.
|
|
121
|
+
const { descriptor: d, definition } = readCompatibility(bound.path);
|
|
122
|
+
if (hash(bytes(d.definition_path)) !== d.source_definition_sha256) fail('install requires exact restored source definition');
|
|
123
|
+
writeContinuityBytesAtomic(d.definition_path, definition.bytes, { mode: 0o644 });
|
|
124
|
+
writeContinuityAtomic(compatibilityPointer(d.config_dir), { descriptor_path: bound.path, sha256: hash(bytes(bound.path)) });
|
|
125
|
+
const cli = join(resolve(d.package_root, '../../..'), 'bin/oysterun');
|
|
126
|
+
rmSync(cli, { force: true });
|
|
127
|
+
symlinkSync(relative(dirname(cli), join(bound.root, 'lifecycle-compat-cli.mjs')), cli);
|
|
128
|
+
}
|
|
129
|
+
export function compatibilityEnvironment(configDir, environment) {
|
|
130
|
+
if (!configDir) return environment;
|
|
131
|
+
const bound = readInstalledCompatibility(configDir);
|
|
132
|
+
if (!bound) return environment;
|
|
133
|
+
if (hash(bytes(bound.descriptor.definition_path)) !== bound.descriptor.definition_sha256) return environment;
|
|
134
|
+
return { ...environment, NODE_OPTIONS: bound.descriptor.preload };
|
|
135
|
+
}
|
|
136
|
+
export function assertCompatibilityProcess(bound) {
|
|
137
|
+
const d = bound.descriptor;
|
|
138
|
+
if (realpathSync(process.execPath) !== d.node_realpath || resolve(process.env.OYSTERUN_CONFIG_DIR || '') !== d.config_dir ||
|
|
139
|
+
hash(bytes(join(d.package_root, 'package.json'))) !== d.manifest_sha256 ||
|
|
140
|
+
hash(bytes(d.definition_path)) !== d.definition_sha256) fail('running Host identity');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function retireUnusedCompatibility(configDir, { isAlive = pid => {
|
|
144
|
+
try { process.kill(pid, 0); return true; } catch (error) { return error.code !== 'ESRCH'; }
|
|
145
|
+
} } = {}) {
|
|
146
|
+
const bound = readInstalledCompatibility(configDir);
|
|
147
|
+
if (!bound) return false;
|
|
148
|
+
const d = bound.descriptor;
|
|
149
|
+
if (hash(bytes(d.definition_path)) === d.definition_sha256) return false;
|
|
150
|
+
const chain = [bound];
|
|
151
|
+
while (chain.at(-1).descriptor.predecessor) chain.push(readCompatibility(chain.at(-1).descriptor.predecessor.descriptor_path));
|
|
152
|
+
// File replacement alone is not proof that launchd's loaded dependency died.
|
|
153
|
+
for (const entry of chain) {
|
|
154
|
+
const priorPid = entry.receipt.user_path_verification?.pid;
|
|
155
|
+
if (!Number.isInteger(priorPid) || isAlive(priorPid)) return false;
|
|
156
|
+
}
|
|
157
|
+
rmSync(compatibilityPointer(configDir));
|
|
158
|
+
for (const entry of chain) {
|
|
159
|
+
rmSync(`${entry.descriptor.observation_path}.probe`, { force: true });
|
|
160
|
+
rmSync(entry.descriptor.observation_path, { force: true });
|
|
161
|
+
rmSync(entry.root, { recursive: true });
|
|
162
|
+
}
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import {
|
|
2
|
+
chmodSync, cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync,
|
|
3
|
+
readlinkSync, renameSync, rmSync, symlinkSync,
|
|
4
|
+
} from "node:fs";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
7
|
+
import {
|
|
8
|
+
assertContinuityFinalizable, advanceContinuity, continuityDigest,
|
|
9
|
+
continuityFailure, readContinuity, writeContinuityAtomic, writeContinuityBytesAtomic,
|
|
10
|
+
} from "./lifecycle-continuity.mjs";
|
|
11
|
+
import { restoreServiceDefinitionSnapshot, inspectServiceDefinition } from "./service-definition-migration.mjs";
|
|
12
|
+
import { removeContinuityProbeGrant } from "./lifecycle-continuity-probe.mjs";
|
|
13
|
+
|
|
14
|
+
import { readCompatibility, installCompatibility, retireUnusedCompatibility } from "./lifecycle-compatibility.mjs";
|
|
15
|
+
|
|
16
|
+
const fail = (check, message) => { throw continuityFailure("retained_assets", check, message); };
|
|
17
|
+
|
|
18
|
+
// Hash executable bits, relative names, symlink text and every regular file.
|
|
19
|
+
// No symlink is followed, including npm's provider executables.
|
|
20
|
+
export function digestContinuityPackage(root) {
|
|
21
|
+
if (!existsSync(root) || !lstatSync(root).isDirectory() || lstatSync(root).isSymbolicLink()) fail("package_root", "Package root must be a retained regular directory.");
|
|
22
|
+
const hash = createHash("sha256");
|
|
23
|
+
const visit = (directory) => {
|
|
24
|
+
for (const name of readdirSync(directory).sort()) {
|
|
25
|
+
const path = join(directory, name);
|
|
26
|
+
const stat = lstatSync(path);
|
|
27
|
+
const kind = stat.isSymbolicLink() ? "link" : stat.isDirectory() ? "directory" : stat.isFile() ? "file" : null;
|
|
28
|
+
if (!kind) fail("package_special_file", "Package contains an unsupported special file.");
|
|
29
|
+
hash.update(JSON.stringify([relative(root, path), kind, stat.mode & 0o777]));
|
|
30
|
+
if (kind === "file") hash.update(continuityDigest(readFileSync(path)));
|
|
31
|
+
if (kind === "link") hash.update(JSON.stringify(readlinkSync(path)));
|
|
32
|
+
if (kind === "directory") visit(path);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
visit(root);
|
|
36
|
+
return hash.digest("hex");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function validateContinuityResources(receipt, configDir, activePackageRoot) {
|
|
40
|
+
const r = receipt.resources;
|
|
41
|
+
if (!r || !receipt.operation_root || !isAbsolute(receipt.operation_root)) fail("resources_missing", "Operation-owned recovery resources are missing.");
|
|
42
|
+
const root = resolve(receipt.operation_root);
|
|
43
|
+
const active = resolve(activePackageRoot);
|
|
44
|
+
const prefix = resolve(active, "../../..");
|
|
45
|
+
if (active !== join(prefix, "lib", "node_modules", "oysterun") ||
|
|
46
|
+
dirname(root) !== prefix || !root.split("/").at(-1).startsWith(".oysterun-update-")) fail("resource_boundary", "Recovery resources escape the exact npm prefix.");
|
|
47
|
+
const exact = {
|
|
48
|
+
active_package_root: active,
|
|
49
|
+
previous_package_root: join(root, "previous-oysterun"),
|
|
50
|
+
failed_package_root: join(root, "failed-oysterun"),
|
|
51
|
+
global_lock_path: join(prefix, "lib", "node_modules", ".package-lock.json"),
|
|
52
|
+
saved_global_lock_path: join(root, "global-package-lock.json"),
|
|
53
|
+
restart_restore_state_path: join(resolve(configDir), "restart-restore-state.json"),
|
|
54
|
+
saved_pre_handoff_restart_restore_state_path: join(root, "pre-handoff-restart-restore-state.json"),
|
|
55
|
+
saved_service_definition_authority_path: join(root, "service-definition-authority.json"),
|
|
56
|
+
source_service_render_bundle_path: join(root, "source-service-render-bundle.json"),
|
|
57
|
+
mutation_journal_path: join(root, "update-mutation-journal.json"),
|
|
58
|
+
};
|
|
59
|
+
for (const [key, value] of Object.entries(exact)) {
|
|
60
|
+
if (r[key] !== value) fail("resource_binding", `Recovery ${key} differs from the operation geometry.`);
|
|
61
|
+
}
|
|
62
|
+
const definitionBindings = [
|
|
63
|
+
r.service_definition_path,
|
|
64
|
+
r.service_definition_authority_path,
|
|
65
|
+
r.service_definition_snapshot_path,
|
|
66
|
+
];
|
|
67
|
+
const absentDefinitionBindings = definitionBindings.every((value) => value === null);
|
|
68
|
+
const partialDefinitionBindings = definitionBindings.some((value) => value === null) &&
|
|
69
|
+
!absentDefinitionBindings;
|
|
70
|
+
const absentDefinitionGeometry = receipt.baseline.host.service_definition_sha256 === "absent" &&
|
|
71
|
+
receipt.source.service_definition_sha256 === "absent" &&
|
|
72
|
+
receipt.target.service_definition_sha256 === "absent";
|
|
73
|
+
if (partialDefinitionBindings || (absentDefinitionBindings && !absentDefinitionGeometry)) {
|
|
74
|
+
fail("definition_binding", "Recovery definition/authority differs from the retained Host binding.");
|
|
75
|
+
}
|
|
76
|
+
if (!absentDefinitionBindings) {
|
|
77
|
+
if (r.service_definition_path !== receipt.baseline.host.service_definition_path ||
|
|
78
|
+
r.service_definition_authority_path !== join(resolve(configDir), "service-definition-authority.json")) fail("definition_binding", "Recovery definition/authority differs from the retained Host binding.");
|
|
79
|
+
if (r.service_definition_snapshot_path !== join(root, "service-definition-snapshot.json")) fail("definition_snapshot", "Recovery definition snapshot is outside the operation root.");
|
|
80
|
+
}
|
|
81
|
+
if (existsSync(root) && (!lstatSync(root).isDirectory() || lstatSync(root).isSymbolicLink())) fail("operation_root", "Recovery root must be a regular directory.");
|
|
82
|
+
return { ...r, root, active, prefix, absent_service_definition_bindings: absentDefinitionBindings };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function retainContinuityFile(path, savedPath) {
|
|
86
|
+
const existed = existsSync(path);
|
|
87
|
+
if (existed && (!lstatSync(path).isFile() || lstatSync(path).isSymbolicLink())) fail("regular_snapshot", "Recovery file must be a regular file.");
|
|
88
|
+
const bytes = existed ? readFileSync(path) : null;
|
|
89
|
+
if (bytes) writeContinuityBytesAtomic(savedPath, bytes);
|
|
90
|
+
return { path, saved_path: savedPath, existed, mode: existed ? lstatSync(path).mode & 0o777 : null,
|
|
91
|
+
sha256: bytes ? continuityDigest(bytes) : null };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function restoreRetainedFile(binding) {
|
|
95
|
+
if (!binding) fail("file_binding", "Recovery file binding is missing.");
|
|
96
|
+
if (!binding.existed) { rmSync(binding.path, { force: true }); return; }
|
|
97
|
+
const bytes = readFileSync(binding.saved_path);
|
|
98
|
+
if (continuityDigest(bytes) !== binding.sha256) fail("file_digest", "Retained recovery file changed.");
|
|
99
|
+
writeContinuityBytesAtomic(binding.path, bytes, { mode: binding.mode ?? 0o600 });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function preserveCopiedPackageModes(source, target) {
|
|
103
|
+
const sourceStat = lstatSync(source);
|
|
104
|
+
const targetStat = lstatSync(target);
|
|
105
|
+
if (sourceStat.isSymbolicLink()) {
|
|
106
|
+
if (!targetStat.isSymbolicLink()) fail("restore_copy_kind", "Prepared rollback package changed a symlink boundary.");
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (sourceStat.isDirectory() !== targetStat.isDirectory() ||
|
|
110
|
+
sourceStat.isFile() !== targetStat.isFile()) {
|
|
111
|
+
fail("restore_copy_kind", "Prepared rollback package changed a file boundary.");
|
|
112
|
+
}
|
|
113
|
+
chmodSync(target, sourceStat.mode & 0o777);
|
|
114
|
+
if (sourceStat.isDirectory()) {
|
|
115
|
+
for (const name of readdirSync(source)) {
|
|
116
|
+
preserveCopiedPackageModes(join(source, name), join(target, name));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Rollback keeps the original package. A fresh copy is verified before the
|
|
122
|
+
// active tree is exchanged. Repeating this function after either rename is
|
|
123
|
+
// safe; an unknown tree is never deleted to make recovery appear successful.
|
|
124
|
+
export function verifyContinuityAssets({ path, operationId, configDir, activePackageRoot }) {
|
|
125
|
+
const receipt = readContinuity(path, operationId);
|
|
126
|
+
if (["COMMITTED", "CLEANED", "ROLLED_BACK_HEALTHY"].includes(receipt.state)) fail("terminal_assets", "Terminal recovery resources cannot be rewritten.");
|
|
127
|
+
const r = validateContinuityResources(receipt, configDir, activePackageRoot);
|
|
128
|
+
const sourceHash = receipt.source.package_tree_sha256;
|
|
129
|
+
if (!sourceHash) fail("source_hash", "Prior package has no retained tree identity.");
|
|
130
|
+
const activeIsSource = existsSync(r.active) && digestContinuityPackage(r.active) === sourceHash;
|
|
131
|
+
// Validate the entire pair before the first mutation. A valid package alone
|
|
132
|
+
// cannot authorize overwriting a changed definition or a missing snapshot.
|
|
133
|
+
let snapshot = null;
|
|
134
|
+
let rendered = null;
|
|
135
|
+
if (!r.absent_service_definition_bindings) {
|
|
136
|
+
snapshot = JSON.parse(readFileSync(r.service_definition_snapshot_path, "utf8"));
|
|
137
|
+
if (continuityDigest(snapshot) !== receipt.source.definition_snapshot_digest || snapshot.definition_path !== r.service_definition_path ||
|
|
138
|
+
(snapshot.existed && continuityDigest(Buffer.from(snapshot.bytes_base64, "base64")) !== snapshot.sha256)) fail("definition_snapshot_digest", "Prior service definition snapshot changed.");
|
|
139
|
+
const compatible = receipt.compatibility ? readCompatibility(receipt.compatibility.descriptor_path) : null;
|
|
140
|
+
const liveDigest = existsSync(r.service_definition_path) ? continuityDigest(readFileSync(r.service_definition_path)) : null;
|
|
141
|
+
rendered = { sha256: compatible && liveDigest === compatible.descriptor.definition_sha256
|
|
142
|
+
? compatible.descriptor.definition_sha256 : receipt.target.service_definition_sha256 || "" };
|
|
143
|
+
if (inspectServiceDefinition({ definitionPath: r.service_definition_path, snapshot, rendered }).state === "unexpected") fail("definition_changed", "Live definition is outside the retained source/target pair.");
|
|
144
|
+
}
|
|
145
|
+
const bindings = [];
|
|
146
|
+
for (const key of ["runtime_identity", "service_authority", "global_lock", "fresh_restart_state"]) {
|
|
147
|
+
const binding = receipt.retained_files?.[key];
|
|
148
|
+
if (key === "service_authority" && r.absent_service_definition_bindings) {
|
|
149
|
+
if (binding && (
|
|
150
|
+
binding.path !== null ||
|
|
151
|
+
binding.saved_path !== r.saved_service_definition_authority_path ||
|
|
152
|
+
binding.existed !== false ||
|
|
153
|
+
binding.mode !== null ||
|
|
154
|
+
binding.sha256 !== null
|
|
155
|
+
)) fail("definition_binding", "Absent service-definition geometry retained an unexpected authority file binding.");
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (!binding && key === "fresh_restart_state" && !receipt.restart_attempted && activeIsSource) continue;
|
|
159
|
+
if (!binding) fail("file_binding", `Retained ${key} is missing.`);
|
|
160
|
+
const expectedPath = key === "runtime_identity" ? join(resolve(configDir), "node-npm-runtime-identity.json")
|
|
161
|
+
: key === "service_authority" ? r.service_definition_authority_path
|
|
162
|
+
: key === "global_lock" ? r.global_lock_path : r.restart_restore_state_path;
|
|
163
|
+
if (binding.path !== expectedPath || dirname(binding.saved_path) !== r.root) fail("file_boundary", "Retained file escapes the operation boundary.");
|
|
164
|
+
if (existsSync(binding.path) && (!lstatSync(binding.path).isFile() || lstatSync(binding.path).isSymbolicLink())) fail("live_file_kind", "Live recovery destination is not a regular file.");
|
|
165
|
+
if (binding.existed && (!lstatSync(binding.saved_path).isFile() || lstatSync(binding.saved_path).isSymbolicLink() ||
|
|
166
|
+
continuityDigest(readFileSync(binding.saved_path)) !== binding.sha256)) fail("file_digest", "Retained recovery file changed.");
|
|
167
|
+
bindings.push(binding);
|
|
168
|
+
}
|
|
169
|
+
const binDir = join(r.prefix, "bin");
|
|
170
|
+
const link = join(binDir, "oysterun");
|
|
171
|
+
if (existsSync(link) && !lstatSync(link).isSymbolicLink()) fail("cli_link", "Global CLI path is not the expected symlink.");
|
|
172
|
+
if (!activeIsSource && (!existsSync(r.previous_package_root) || digestContinuityPackage(r.previous_package_root) !== sourceHash)) fail("source_package_changed", "Retained prior package is missing or changed; recovery assets remain intact.");
|
|
173
|
+
return { receipt, r, sourceHash, activeIsSource, snapshot, rendered, bindings, binDir, link };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function restoreContinuityAssets({ path, operationId, configDir, activePackageRoot }) {
|
|
177
|
+
const { receipt, r, sourceHash, activeIsSource, snapshot, rendered, bindings, binDir, link } = verifyContinuityAssets({ path, operationId, configDir, activePackageRoot });
|
|
178
|
+
if (!activeIsSource) {
|
|
179
|
+
const prepared = join(r.root, "restore-oysterun");
|
|
180
|
+
// This copy is disposable; the receipt-bound original is never consumed.
|
|
181
|
+
// A crash during cp can therefore be retried without using partial bytes.
|
|
182
|
+
if (existsSync(prepared) && digestContinuityPackage(prepared) !== sourceHash) rmSync(prepared, { recursive: true });
|
|
183
|
+
if (!existsSync(prepared)) {
|
|
184
|
+
cpSync(r.previous_package_root, prepared, { recursive: true, dereference: false, verbatimSymlinks: true, preserveTimestamps: true });
|
|
185
|
+
// Node's recursive copy applies the process umask to directories on
|
|
186
|
+
// Linux. Restore the receipt-bound mode bits before hashing the copy;
|
|
187
|
+
// never relax the package digest to accommodate that platform detail.
|
|
188
|
+
preserveCopiedPackageModes(r.previous_package_root, prepared);
|
|
189
|
+
}
|
|
190
|
+
if (digestContinuityPackage(prepared) !== sourceHash) fail("restore_copy", "Prepared rollback package differs from the retained source.");
|
|
191
|
+
if (existsSync(r.active)) {
|
|
192
|
+
if (existsSync(r.failed_package_root)) fail("ambiguous_active_package", "Both an active target and failed target already exist; retain both for recovery review.");
|
|
193
|
+
renameSync(r.active, r.failed_package_root);
|
|
194
|
+
}
|
|
195
|
+
renameSync(prepared, r.active);
|
|
196
|
+
}
|
|
197
|
+
mkdirSync(binDir, { recursive: true });
|
|
198
|
+
rmSync(link, { force: true });
|
|
199
|
+
symlinkSync(relative(binDir, join(r.active, "bin", "oysterun.mjs")), link);
|
|
200
|
+
if (!r.absent_service_definition_bindings) {
|
|
201
|
+
restoreServiceDefinitionSnapshot({ definitionPath: r.service_definition_path, snapshot, rendered });
|
|
202
|
+
}
|
|
203
|
+
for (const binding of bindings) restoreRetainedFile(binding);
|
|
204
|
+
if (receipt.compatibility) installCompatibility(readCompatibility(receipt.compatibility.descriptor_path));
|
|
205
|
+
writeContinuityAtomic(path, { ...receipt, paired_restore_completed_at: new Date().toISOString() });
|
|
206
|
+
return { passed: true, source_package_sha256: sourceHash, version: receipt.source.version };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function finalizeContinuityAssets({ path, operationId, configDir, activePackageRoot }) {
|
|
210
|
+
const receipt = assertContinuityFinalizable(path, operationId);
|
|
211
|
+
retireUnusedCompatibility(configDir);
|
|
212
|
+
if (receipt.operation_root) {
|
|
213
|
+
const r = validateContinuityResources(receipt, configDir, activePackageRoot);
|
|
214
|
+
rmSync(r.root, { recursive: true, force: true });
|
|
215
|
+
}
|
|
216
|
+
removeContinuityProbeGrant(configDir, operationId);
|
|
217
|
+
if (receipt.restart_only_snapshot) {
|
|
218
|
+
if (receipt.restart_only_snapshot.saved_path !== `${path}.restore`) fail("restart_snapshot_boundary", "Restart snapshot is outside its exact operation path.");
|
|
219
|
+
rmSync(receipt.restart_only_snapshot.saved_path, { force: true });
|
|
220
|
+
}
|
|
221
|
+
return receipt.state === "CLEANED" ? receipt : advanceContinuity(path, operationId, "CLEANED", { recovery_assets_removed: true });
|
|
222
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { existsSync, lstatSync, readFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { continuityFailure, continuityReceiptPath, readContinuity, writeContinuityAtomic } from "./lifecycle-continuity.mjs";
|
|
4
|
+
|
|
5
|
+
// This grant authenticates one read-only operation observation. It cannot
|
|
6
|
+
// authenticate dashboard mutations, Matrix reads, Session control or login.
|
|
7
|
+
export const CONTINUITY_PROBE_ENDPOINT = "/admin/lifecycle-qualification";
|
|
8
|
+
export function continuityProbeGrantPath(configDir, operationId) {
|
|
9
|
+
return `${continuityReceiptPath(configDir, operationId)}.probe`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function createContinuityProbeGrant(configDir, operationId) {
|
|
13
|
+
const path = continuityProbeGrantPath(configDir, operationId);
|
|
14
|
+
if (existsSync(path)) throw continuityFailure("preparation", "probe_exists", "Lifecycle route grant already exists.");
|
|
15
|
+
writeContinuityAtomic(path, {
|
|
16
|
+
operation_id: operationId, token: randomBytes(32).toString("base64url"),
|
|
17
|
+
expires_at: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(),
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function readGrant(configDir, operationId) {
|
|
22
|
+
const path = continuityProbeGrantPath(configDir, operationId);
|
|
23
|
+
const stat = lstatSync(path);
|
|
24
|
+
if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0) {
|
|
25
|
+
throw continuityFailure("route_probe", "private_grant", "Lifecycle route grant must be a private regular file.");
|
|
26
|
+
}
|
|
27
|
+
const grant = JSON.parse(readFileSync(path, "utf8"));
|
|
28
|
+
if (grant.operation_id !== operationId || typeof grant.token !== "string" || !Number.isFinite(Date.parse(grant.expires_at)) || Date.parse(grant.expires_at) <= Date.now()) {
|
|
29
|
+
throw continuityFailure("route_probe", "expired_grant", "Lifecycle route grant is missing, expired or mismatched.");
|
|
30
|
+
}
|
|
31
|
+
return grant;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Only an already bound local controller may renew this read-only grant. The
|
|
35
|
+
// HTTP verifier never renews it; offline recovery does not need a live Host.
|
|
36
|
+
export function ensureContinuityProbeGrant(configDir, operationId) {
|
|
37
|
+
const receipt = readContinuity(continuityReceiptPath(configDir, operationId), operationId);
|
|
38
|
+
if (["CLEANED", "ROLLED_BACK_HEALTHY"].includes(receipt.state)) return;
|
|
39
|
+
const path = continuityProbeGrantPath(configDir, operationId);
|
|
40
|
+
if (existsSync(path)) {
|
|
41
|
+
const stat = lstatSync(path);
|
|
42
|
+
if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077)) throw continuityFailure("route_probe", "private_grant", "Lifecycle grant is not a private regular file.");
|
|
43
|
+
const grant = JSON.parse(readFileSync(path, "utf8"));
|
|
44
|
+
if (grant.operation_id !== operationId) throw continuityFailure("route_probe", "grant_binding", "Lifecycle grant belongs to a different operation.");
|
|
45
|
+
if (Date.parse(grant.expires_at) > Date.now() + 15 * 60 * 1000) return;
|
|
46
|
+
}
|
|
47
|
+
writeContinuityAtomic(path, { operation_id: operationId, token: randomBytes(32).toString("base64url"),
|
|
48
|
+
expires_at: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString() });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function removeContinuityProbeGrant(configDir, operationId) {
|
|
52
|
+
rmSync(continuityProbeGrantPath(configDir, operationId), { force: true });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function verifyContinuityProbeGrant(configDir, operationId, token) {
|
|
56
|
+
try {
|
|
57
|
+
const receipt = readContinuity(continuityReceiptPath(configDir, operationId), operationId);
|
|
58
|
+
if (["CLEANED", "ROLLED_BACK_HEALTHY"].includes(receipt.state)) return false;
|
|
59
|
+
const expected = Buffer.from(readGrant(configDir, operationId).token);
|
|
60
|
+
const actual = Buffer.from(typeof token === "string" ? token : "");
|
|
61
|
+
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
|
62
|
+
} catch { return false; }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function fetchContinuityObservation({ configDir, operationId, origin, timeoutMs = 10_000 }) {
|
|
66
|
+
const url = new URL(origin);
|
|
67
|
+
if (url.origin !== origin || url.username || url.password ||
|
|
68
|
+
!(url.protocol === "https:" || (url.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname)))) {
|
|
69
|
+
throw continuityFailure("route_probe", "bounded_origin", "Lifecycle observation requires the retained HTTPS or loopback origin.");
|
|
70
|
+
}
|
|
71
|
+
const nonce = randomUUID();
|
|
72
|
+
url.pathname = CONTINUITY_PROBE_ENDPOINT;
|
|
73
|
+
url.searchParams.set("operation_id", operationId);
|
|
74
|
+
url.searchParams.set("nonce", nonce);
|
|
75
|
+
const response = await fetch(url, {
|
|
76
|
+
headers: { Authorization: `Bearer ${readGrant(configDir, operationId).token}`, Accept: "application/json" },
|
|
77
|
+
signal: AbortSignal.timeout(timeoutMs), redirect: "error", cache: "no-store",
|
|
78
|
+
});
|
|
79
|
+
if (!response.ok) {
|
|
80
|
+
const failure = await response.json().catch(() => null);
|
|
81
|
+
if (failure?.operation_id === operationId && failure.request_nonce === nonce &&
|
|
82
|
+
typeof failure.lifecycle_diagnostic?.stage === "string" && typeof failure.lifecycle_diagnostic?.check === "string") {
|
|
83
|
+
throw continuityFailure(failure.lifecycle_diagnostic.stage, failure.lifecycle_diagnostic.check,
|
|
84
|
+
String(failure.error || "Bound lifecycle observation failed."));
|
|
85
|
+
}
|
|
86
|
+
throw continuityFailure("route_probe", "http_status", `Lifecycle observation returned HTTP ${response.status}.`);
|
|
87
|
+
}
|
|
88
|
+
const observed = await response.json();
|
|
89
|
+
if (observed.operation_id !== operationId || observed.request_nonce !== nonce) {
|
|
90
|
+
throw continuityFailure("route_probe", "response_binding", "Lifecycle observation is stale or belongs to a different operation.");
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
...observed,
|
|
94
|
+
route: { origin, device_id: observed.host.device_id, operation_id: operationId,
|
|
95
|
+
authenticated: true, authentication: "operation_read_only_grant", http_status: response.status },
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { readContinuityUserPath } from "./lifecycle-user-path.mjs";
|
|
2
|
+
// UI state is a projection of durable facts, never HTTP/consumed inference.
|
|
3
|
+
export function projectContinuityOperation(operation, receipt) {
|
|
4
|
+
if (!receipt || receipt.operation_id !== operation.operation_id) return operation;
|
|
5
|
+
const restartOnly = receipt.transaction_kind === "restart_only";
|
|
6
|
+
const userPath = readContinuityUserPath(operation.continuity_path, receipt);
|
|
7
|
+
const routeMessage = userPath?.status === "pending"
|
|
8
|
+
? " Ordinary login verification is pending; Sessions are available."
|
|
9
|
+
: " Authenticated login path verified.";
|
|
10
|
+
const common = { ...operation, continuity_state: receipt.state,
|
|
11
|
+
user_path_verification: userPath,
|
|
12
|
+
restart_attempted: receipt.restart_attempted === true,
|
|
13
|
+
...(receipt.first_failure ? { root_failure: { ...receipt.first_failure,
|
|
14
|
+
safe_message: receipt.first_failure.message, observed_at: receipt.first_failure.at } } : {}),
|
|
15
|
+
};
|
|
16
|
+
if (!["CLEANED", "ROLLED_BACK_HEALTHY"].includes(receipt.state)) return common;
|
|
17
|
+
const recovered = receipt.state === "ROLLED_BACK_HEALTHY";
|
|
18
|
+
return { ...common, status: restartOnly ? "restart_complete" : recovered ? "rolled_back" : "update_complete",
|
|
19
|
+
running: false, runner_pid: null, runner_lease_expires_at: null, lifecycle_owner_pid: null,
|
|
20
|
+
phase: (restartOnly ? "Host restart complete; Host and Sessions are healthy."
|
|
21
|
+
: recovered ? "Update failed; prior Host and Sessions are restored and usable."
|
|
22
|
+
: "Host and Sessions are healthy; automatic update checks and cleanup complete.") + routeMessage,
|
|
23
|
+
final_observed_version: recovered ? receipt.source.version : receipt.target.version,
|
|
24
|
+
operation_cleanup_completed: !recovered, rollback_completed: recovered && !restartOnly,
|
|
25
|
+
service_status: "healthy", session_restore_status: "qualified_without_replay",
|
|
26
|
+
restart_restore_status: receipt.recovery_qualification?.sessions?.restore_required === false ? "not_required" : "consumed",
|
|
27
|
+
next_safe_action: null, recovery_required_at: null, recovery_reason: null, recovery_boundary: null,
|
|
28
|
+
};
|
|
29
|
+
}
|