@sayknow-cli/coding-agent 0.3.12 → 0.3.15
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/dist/types/autoresearch/tools/init-experiment.d.ts +1 -1
- package/dist/types/autoresearch/tools/log-experiment.d.ts +2 -2
- package/dist/types/cli/classify-install-target.d.ts +0 -10
- package/dist/types/cli/daemon-cli.d.ts +2 -0
- package/dist/types/cli/initial-message.d.ts +1 -1
- package/dist/types/cli/malloc-env-guard.d.ts +65 -0
- package/dist/types/cli/malloc-env-guard.test.d.ts +1 -0
- package/dist/types/cli/notify-cli.d.ts +3 -1
- package/dist/types/cli/session-picker.d.ts +4 -3
- package/dist/types/cli/update-cli.d.ts +22 -3
- package/dist/types/cli.d.ts +5 -0
- package/dist/types/commands/daemon.d.ts +6 -2
- package/dist/types/commands/launch.d.ts +1 -0
- package/dist/types/commands/notify.d.ts +6 -0
- package/dist/types/commands/web-search.d.ts +1 -1
- package/dist/types/commit/agentic/tools/propose-changelog.d.ts +4 -4
- package/dist/types/commit/agentic/tools/propose-commit.d.ts +9 -9
- package/dist/types/commit/agentic/tools/schemas.d.ts +9 -9
- package/dist/types/commit/agentic/tools/split-commit.d.ts +9 -9
- package/dist/types/commit/analysis/conventional.d.ts +1 -1
- package/dist/types/commit/analysis/summary.d.ts +1 -1
- package/dist/types/commit/changelog/generate.d.ts +3 -3
- package/dist/types/commit/changelog/index.d.ts +2 -2
- package/dist/types/commit/map-reduce/map-phase.d.ts +1 -1
- package/dist/types/commit/map-reduce/reduce-phase.d.ts +1 -1
- package/dist/types/commit/shared-llm.d.ts +9 -9
- package/dist/types/config/keybindings.d.ts +134 -129
- package/dist/types/config/model-profile-activation.d.ts +1 -0
- package/dist/types/config/model-registry.d.ts +35 -35
- package/dist/types/config/models-config-schema.d.ts +111 -111
- package/dist/types/config/settings-schema.d.ts +56 -16
- package/dist/types/config/settings.d.ts +2 -0
- package/dist/types/coordinator/contract.d.ts +1 -1
- package/dist/types/coordinator-mcp/model-preset.d.ts +45 -0
- package/dist/types/coordinator-mcp/policy.d.ts +3 -0
- package/dist/types/coordinator-mcp/server.d.ts +79 -6
- package/dist/types/coordinator-mcp/session-reaper.d.ts +53 -0
- package/dist/types/daemon/control-types.d.ts +10 -0
- package/dist/types/daemon/operator-contract.d.ts +47 -0
- package/dist/types/defaults/skc/extensions/grok-cli-vendor/src/models/catalog.d.ts +4 -0
- package/dist/types/defaults/skc/extensions/grok-cli-vendor/src/shared/errors.d.ts +12 -12
- package/dist/types/discovery/helpers.d.ts +1 -1
- package/dist/types/discovery/substitute-plugin-root.d.ts +0 -4
- package/dist/types/edit/modes/patch.d.ts +2 -2
- package/dist/types/extensibility/extensions/types.d.ts +3 -1
- package/dist/types/goals/tools/goal-tool.d.ts +6 -6
- package/dist/types/i18n/messages/en.d.ts +8 -8
- package/dist/types/internal-urls/local-protocol.d.ts +13 -9
- package/dist/types/lsp/index.d.ts +9 -9
- package/dist/types/lsp/types.d.ts +9 -9
- package/dist/types/main.d.ts +66 -2
- package/dist/types/modes/acp/acp-agent.d.ts +19 -2
- package/dist/types/modes/acp/acp-mode.d.ts +12 -0
- package/dist/types/modes/components/assistant-message.d.ts +1 -1
- package/dist/types/modes/components/custom-editor.d.ts +2 -0
- package/dist/types/modes/components/hook-selector.d.ts +5 -0
- package/dist/types/modes/components/irc-sidebar.d.ts +32 -0
- package/dist/types/modes/components/pet-selector.d.ts +11 -0
- package/dist/types/modes/components/sayknow-pet-widget.d.ts +59 -0
- package/dist/types/modes/components/session-selector.d.ts +22 -9
- package/dist/types/modes/components/settings-selector.d.ts +2 -0
- package/dist/types/modes/components/status-line/types.d.ts +1 -1
- package/dist/types/modes/components/status-line.d.ts +0 -10
- package/dist/types/modes/components/tool-execution.d.ts +1 -0
- package/dist/types/modes/components/user-message.d.ts +2 -1
- package/dist/types/modes/controllers/event-controller.d.ts +4 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +24 -2
- package/dist/types/modes/irc-observation-ledger.d.ts +22 -0
- package/dist/types/modes/print-mode.d.ts +3 -4
- package/dist/types/modes/rpc/rpc-client.d.ts +7 -1
- package/dist/types/modes/rpc/rpc-mode.d.ts +2 -1
- package/dist/types/modes/rpc/rpc-types.d.ts +17 -1
- package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +8 -0
- package/dist/types/modes/shared/agent-wire/unattended-run-controller.d.ts +5 -0
- package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +45 -0
- package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +42 -2
- package/dist/types/modes/theme/defaults/index.d.ts +568 -568
- package/dist/types/modes/types.d.ts +28 -2
- package/dist/types/modes/utils/abort-message.d.ts +1 -1
- package/dist/types/modes/utils/context-usage.d.ts +4 -19
- package/dist/types/modes/utils/irc-message.d.ts +38 -0
- package/dist/types/modes/utils/ui-helpers.d.ts +13 -4
- package/dist/types/notifications/config.d.ts +34 -0
- package/dist/types/notifications/daemon-paths.d.ts +7 -0
- package/dist/types/notifications/index.d.ts +27 -0
- package/dist/types/notifications/lifecycle-control-runtime.d.ts +12 -4
- package/dist/types/notifications/notification-service.d.ts +144 -0
- package/dist/types/notifications/rate-limit-pool.d.ts +27 -9
- package/dist/types/notifications/telegram-daemon-control.d.ts +6 -0
- package/dist/types/notifications/telegram-daemon.d.ts +130 -3
- package/dist/types/notifications/telegram-reference.d.ts +19 -2
- package/dist/types/notifications/topic-registry.d.ts +24 -4
- package/dist/types/registry/agent-registry.d.ts +2 -0
- package/dist/types/rlm/index.d.ts +1 -1
- package/dist/types/sdk.d.ts +2 -0
- package/dist/types/secrets/obfuscator.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +40 -3
- package/dist/types/session/cache-economics.d.ts +79 -0
- package/dist/types/session/context-estimation.d.ts +31 -0
- package/dist/types/session/provider-safety-stop.d.ts +5 -0
- package/dist/types/session/session-manager.d.ts +124 -4
- package/dist/types/session/session-storage.d.ts +155 -13
- package/dist/types/setup/credential-auto-import.d.ts +2 -2
- package/dist/types/skc-runtime/session-state-sidecar.d.ts +34 -3
- package/dist/types/skc-runtime/state-schema.d.ts +19 -19
- package/dist/types/skc-runtime/tmux-common.d.ts +6 -0
- package/dist/types/skc-runtime/tmux-owner-isolation-cli.d.ts +5 -0
- package/dist/types/skc-runtime/tmux-owner-isolation.d.ts +309 -0
- package/dist/types/skc-runtime/tmux-sessions.d.ts +49 -17
- package/dist/types/task/agents.d.ts +2 -2
- package/dist/types/task/executor.d.ts +3 -0
- package/dist/types/task/receipt.d.ts +3 -1
- package/dist/types/task/types.d.ts +35 -27
- package/dist/types/tools/ask-answer-registry.d.ts +5 -0
- package/dist/types/tools/bash.d.ts +1 -1
- package/dist/types/tools/browser/tab-supervisor.d.ts +6 -0
- package/dist/types/tools/browser.d.ts +28 -28
- package/dist/types/tools/computer.d.ts +20 -20
- package/dist/types/tools/cron.d.ts +2 -2
- package/dist/types/tools/debug.d.ts +40 -40
- package/dist/types/tools/gh.d.ts +10 -10
- package/dist/types/tools/image-gen.d.ts +2 -2
- package/dist/types/tools/index.d.ts +58 -6
- package/dist/types/tools/monitor.d.ts +6 -6
- package/dist/types/tools/skill-discovery.d.ts +4 -4
- package/dist/types/tools/subagent.d.ts +12 -12
- package/dist/types/tools/telegram-send.d.ts +7 -0
- package/dist/types/tools/todo-write.d.ts +8 -8
- package/dist/types/utils/external-editor.d.ts +2 -0
- package/dist/types/web/insane/bridge.d.ts +9 -9
- package/dist/types/web/search/index.d.ts +4 -4
- package/package.json +8 -8
- package/scripts/g004-tmux-smoke.ts +196 -36
- package/src/cli/daemon-cli.ts +19 -29
- package/src/cli/malloc-env-guard.test.ts +104 -0
- package/src/cli/malloc-env-guard.ts +149 -0
- package/src/cli/notify-cli.ts +78 -14
- package/src/cli/session-picker.ts +23 -40
- package/src/cli/update-cli.ts +56 -37
- package/src/cli.ts +51 -10
- package/src/commands/daemon.ts +19 -5
- package/src/commands/harness.ts +515 -47
- package/src/commands/launch.ts +11 -3
- package/src/commands/notify.ts +5 -1
- package/src/commands/session.ts +6 -1
- package/src/config/keybindings.ts +5 -0
- package/src/config/model-profile-activation.ts +1 -1
- package/src/config/model-profiles.ts +14 -14
- package/src/config/settings-schema.ts +41 -1
- package/src/config/settings.ts +96 -45
- package/src/coordinator/contract.ts +1 -0
- package/src/coordinator-mcp/model-preset.ts +100 -0
- package/src/coordinator-mcp/policy.ts +30 -0
- package/src/coordinator-mcp/server.ts +2958 -430
- package/src/coordinator-mcp/session-reaper.ts +129 -0
- package/src/daemon/control-types.ts +11 -0
- package/src/daemon/operator-contract.ts +121 -0
- package/src/defaults/skc/extensions/grok-cli-vendor/src/models/catalog.ts +57 -17
- package/src/defaults/skc/extensions/grok-cli-vendor/src/payload/sanitize.ts +15 -2
- package/src/defaults/skc/extensions/grok-cli-vendor/src/provider/register.ts +5 -1
- package/src/extensibility/extensions/runner.ts +1 -1
- package/src/extensibility/extensions/types.ts +3 -1
- package/src/internal-urls/docs-index.generated.ts +10 -9
- package/src/internal-urls/local-protocol.ts +29 -10
- package/src/main.ts +301 -80
- package/src/modes/acp/acp-agent.ts +730 -100
- package/src/modes/acp/acp-mode.ts +29 -2
- package/src/modes/components/assistant-message.ts +32 -3
- package/src/modes/components/bash-execution.ts +43 -13
- package/src/modes/components/custom-editor.ts +5 -0
- package/src/modes/components/footer.ts +3 -3
- package/src/modes/components/hook-selector.ts +12 -0
- package/src/modes/components/irc-sidebar.ts +206 -0
- package/src/modes/components/pet-selector.ts +55 -0
- package/src/modes/components/sayknow-pet-widget.ts +360 -0
- package/src/modes/components/session-selector.ts +222 -221
- package/src/modes/components/settings-selector.ts +10 -0
- package/src/modes/components/status-line/segments.ts +14 -7
- package/src/modes/components/status-line/types.ts +1 -1
- package/src/modes/components/status-line.ts +4 -171
- package/src/modes/components/tool-execution.ts +47 -6
- package/src/modes/components/user-message.ts +26 -9
- package/src/modes/controllers/command-controller.ts +28 -2
- package/src/modes/controllers/event-controller.ts +159 -36
- package/src/modes/controllers/extension-ui-controller.ts +37 -18
- package/src/modes/controllers/input-controller.ts +8 -2
- package/src/modes/controllers/selector-controller.ts +62 -15
- package/src/modes/interactive-mode.ts +199 -7
- package/src/modes/irc-observation-ledger.ts +127 -0
- package/src/modes/print-mode.ts +228 -92
- package/src/modes/rpc/rpc-client.ts +58 -1
- package/src/modes/rpc/rpc-mode.ts +286 -85
- package/src/modes/rpc/rpc-types.ts +15 -1
- package/src/modes/shared/agent-wire/command-dispatch.ts +33 -1
- package/src/modes/shared/agent-wire/command-validation.ts +10 -0
- package/src/modes/shared/agent-wire/deep-interview-gate.ts +45 -2
- package/src/modes/shared/agent-wire/scopes.ts +1 -0
- package/src/modes/shared/agent-wire/unattended-session.ts +288 -1
- package/src/modes/shared/agent-wire/workflow-gate-broker.ts +130 -20
- package/src/modes/types.ts +34 -2
- package/src/modes/utils/context-usage.ts +56 -92
- package/src/modes/utils/hotkeys-markdown.ts +1 -0
- package/src/modes/utils/irc-message.ts +138 -0
- package/src/modes/utils/ui-helpers.ts +216 -50
- package/src/notifications/config.ts +44 -0
- package/src/notifications/daemon-paths.ts +8 -0
- package/src/notifications/index.ts +535 -75
- package/src/notifications/lifecycle-control-runtime.ts +930 -77
- package/src/notifications/notification-service.ts +710 -0
- package/src/notifications/rate-limit-pool.ts +46 -12
- package/src/notifications/telegram-daemon-control.ts +27 -2
- package/src/notifications/telegram-daemon.ts +635 -94
- package/src/notifications/telegram-reference.ts +73 -13
- package/src/notifications/topic-registry.ts +86 -9
- package/src/prompts/system/irc-peer-roster.md +1 -0
- package/src/prompts/system/subagent-system-prompt.md +1 -0
- package/src/prompts/system/system-prompt.md +1 -0
- package/src/prompts/tools/irc.md +1 -1
- package/src/registry/agent-registry.ts +3 -0
- package/src/sdk.ts +52 -1
- package/src/secrets/obfuscator.ts +4 -2
- package/src/session/agent-session.ts +670 -217
- package/src/session/cache-economics.ts +275 -0
- package/src/session/context-estimation.ts +91 -0
- package/src/session/provider-safety-stop.ts +9 -0
- package/src/session/session-dump-format.ts +6 -0
- package/src/session/session-manager.ts +809 -39
- package/src/session/session-storage.ts +582 -38
- package/src/skc-runtime/session-state-sidecar.ts +756 -135
- package/src/skc-runtime/tmux-common.ts +26 -7
- package/src/skc-runtime/tmux-gc.ts +52 -9
- package/src/skc-runtime/tmux-owner-isolation-cli.ts +185 -0
- package/src/skc-runtime/tmux-owner-isolation.ts +2060 -0
- package/src/skc-runtime/tmux-sessions.ts +871 -68
- package/src/slash-commands/builtin-registry.ts +153 -9
- package/src/slash-commands/helpers/context-report.ts +26 -4
- package/src/task/executor.ts +12 -3
- package/src/task/index.ts +8 -0
- package/src/task/receipt.ts +7 -2
- package/src/task/types.ts +26 -0
- package/src/tools/ask-answer-registry.ts +25 -0
- package/src/tools/browser/tab-supervisor.ts +63 -6
- package/src/tools/browser.ts +8 -3
- package/src/tools/index.ts +81 -6
- package/src/tools/sqlite-reader.ts +8 -4
- package/src/tui/output-block.ts +18 -6
- package/src/utils/external-editor.ts +6 -1
|
@@ -8,19 +8,39 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import * as crypto from "node:crypto";
|
|
10
10
|
import * as fs from "node:fs";
|
|
11
|
+
import * as fsPromises from "node:fs/promises";
|
|
11
12
|
import * as path from "node:path";
|
|
12
|
-
|
|
13
|
+
import { tmuxRuntimeSessionPath } from "../skc-runtime/session-layout";
|
|
14
|
+
import {
|
|
15
|
+
SKC_COORDINATOR_SESSION_ID_ENV,
|
|
16
|
+
SKC_COORDINATOR_SESSION_STATE_FILE_ENV,
|
|
17
|
+
SKC_TMUX_OWNER_GENERATION_ENV,
|
|
18
|
+
SKC_TMUX_OWNER_SERVER_KEY_ENV,
|
|
19
|
+
SKC_TMUX_OWNER_STATE_DIR_ENV,
|
|
20
|
+
} from "../skc-runtime/session-state-sidecar";
|
|
13
21
|
import {
|
|
14
|
-
buildSkcTmuxExactOptionTarget,
|
|
15
22
|
buildSkcTmuxProfileCommands,
|
|
23
|
+
buildSkcTmuxSessionSlug,
|
|
24
|
+
resolveSkcTmuxBinary,
|
|
16
25
|
resolveSkcTmuxCommand,
|
|
17
26
|
} from "../skc-runtime/tmux-common";
|
|
27
|
+
import {
|
|
28
|
+
captureOwnerGenerationBaseline,
|
|
29
|
+
classifyCgroup,
|
|
30
|
+
isExactScopedBootstrapSuccessReceipt,
|
|
31
|
+
type OwnerGenerationBaseline,
|
|
32
|
+
type OwnerIsolationProbe,
|
|
33
|
+
planTmuxOwnerIsolation,
|
|
34
|
+
replaceOwnerGeneration,
|
|
35
|
+
type TmuxServerProof,
|
|
36
|
+
} from "../skc-runtime/tmux-owner-isolation";
|
|
18
37
|
import {
|
|
19
38
|
findSkcTmuxSessionByName,
|
|
20
39
|
forceCloseSkcTmuxSession,
|
|
21
40
|
listSkcTmuxSessions,
|
|
22
|
-
|
|
41
|
+
type SkcTmuxSessionStatus,
|
|
23
42
|
} from "../skc-runtime/tmux-sessions";
|
|
43
|
+
|
|
24
44
|
import type { ResumeCandidate, SessionCreateFrame, SessionLifecycleRequest, SessionLifecycleResponse } from "./index";
|
|
25
45
|
import { normalizeLifecyclePath } from "./lifecycle-commands";
|
|
26
46
|
import {
|
|
@@ -28,6 +48,7 @@ import {
|
|
|
28
48
|
type CreateEffectResult,
|
|
29
49
|
handleLifecycleRequest,
|
|
30
50
|
type LedgerDoc,
|
|
51
|
+
type LedgerEntry,
|
|
31
52
|
type LedgerStore,
|
|
32
53
|
type LifecycleOutcome,
|
|
33
54
|
type OrchestratorDeps,
|
|
@@ -61,36 +82,132 @@ export type LifecycleControlServerFactory = (input: {
|
|
|
61
82
|
}) => LifecycleControlServer;
|
|
62
83
|
|
|
63
84
|
/** Atomic + fsynced file-backed idempotency ledger store. */
|
|
85
|
+
function ledgerReadError(error: unknown): Error {
|
|
86
|
+
const errorCode = (error as NodeJS.ErrnoException | undefined)?.code;
|
|
87
|
+
const code = typeof errorCode === "string" ? errorCode : "invalid";
|
|
88
|
+
return new Error(`skc_lifecycle_ledger_read_failed:${code.slice(0, 32)}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isLedgerDoc(value: unknown): value is LedgerDoc {
|
|
92
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
93
|
+
const doc = value as { version?: unknown; entries?: unknown };
|
|
94
|
+
if (doc.version !== 1 || !doc.entries || typeof doc.entries !== "object" || Array.isArray(doc.entries)) return false;
|
|
95
|
+
return Object.values(doc.entries).every(entry => {
|
|
96
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false;
|
|
97
|
+
const candidate = entry as Partial<LedgerEntry>;
|
|
98
|
+
return (
|
|
99
|
+
typeof candidate.requestHash === "string" &&
|
|
100
|
+
(candidate.state === "in_progress" ||
|
|
101
|
+
candidate.state === "success" ||
|
|
102
|
+
candidate.state === "failure" ||
|
|
103
|
+
candidate.state === "terminal_uncertain") &&
|
|
104
|
+
typeof candidate.requestId === "string" &&
|
|
105
|
+
(candidate.verb === "session_create" ||
|
|
106
|
+
candidate.verb === "session_close" ||
|
|
107
|
+
candidate.verb === "session_resume") &&
|
|
108
|
+
typeof candidate.createdAt === "number" &&
|
|
109
|
+
typeof candidate.updatedAt === "number" &&
|
|
110
|
+
!!candidate.targetSummary &&
|
|
111
|
+
typeof candidate.targetSummary === "object" &&
|
|
112
|
+
!Array.isArray(candidate.targetSummary)
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function isUnsupportedDirectorySyncError(error: unknown): boolean {
|
|
118
|
+
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
|
119
|
+
return code === "EINVAL" || code === "ENOTSUP" || code === "EOPNOTSUPP";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function recordDirectorySyncCompatibilityDiagnostic(): void {
|
|
123
|
+
try {
|
|
124
|
+
process.stderr.write("skc lifecycle ledger directory sync unsupported\n");
|
|
125
|
+
} catch {
|
|
126
|
+
// Compatibility diagnostics must not alter ledger durability semantics.
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function fsyncLedgerParentDirectory(directory: string): void {
|
|
131
|
+
const dirFd = fs.openSync(directory, "r");
|
|
132
|
+
let syncFailure: unknown;
|
|
133
|
+
let closeFailure: unknown;
|
|
134
|
+
try {
|
|
135
|
+
fs.fsyncSync(dirFd);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
if (isUnsupportedDirectorySyncError(error)) recordDirectorySyncCompatibilityDiagnostic();
|
|
138
|
+
else syncFailure = error;
|
|
139
|
+
} finally {
|
|
140
|
+
try {
|
|
141
|
+
fs.closeSync(dirFd);
|
|
142
|
+
} catch (error) {
|
|
143
|
+
closeFailure = error;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (syncFailure !== undefined && closeFailure !== undefined)
|
|
147
|
+
throw new AggregateError([syncFailure, closeFailure], "ledger directory sync failed");
|
|
148
|
+
if (syncFailure !== undefined) throw syncFailure;
|
|
149
|
+
if (closeFailure !== undefined) throw closeFailure;
|
|
150
|
+
}
|
|
151
|
+
|
|
64
152
|
export function fileLedgerStore(idempotencyFile: string): LedgerStore {
|
|
65
153
|
return {
|
|
66
154
|
async read(): Promise<LedgerDoc> {
|
|
155
|
+
let contents: string;
|
|
67
156
|
try {
|
|
68
|
-
|
|
69
|
-
} catch {
|
|
70
|
-
|
|
157
|
+
contents = fs.readFileSync(idempotencyFile, "utf8");
|
|
158
|
+
} catch (error) {
|
|
159
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
160
|
+
if (code === "ENOENT") return { version: 1, entries: {} };
|
|
161
|
+
throw ledgerReadError(error);
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
const doc = JSON.parse(contents) as unknown;
|
|
165
|
+
if (!isLedgerDoc(doc)) throw new Error("invalid");
|
|
166
|
+
return doc;
|
|
167
|
+
} catch (error) {
|
|
168
|
+
throw ledgerReadError(error);
|
|
71
169
|
}
|
|
72
170
|
},
|
|
73
171
|
async write(doc: LedgerDoc): Promise<void> {
|
|
74
172
|
fs.mkdirSync(path.dirname(idempotencyFile), { recursive: true });
|
|
75
173
|
const tmp = `${idempotencyFile}.${process.pid}.${Date.now()}.tmp`;
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
fs.closeSync(fd);
|
|
80
|
-
fs.renameSync(tmp, idempotencyFile);
|
|
81
|
-
// fsync the parent directory so the rename itself is durable across a
|
|
82
|
-
// crash / power loss (the temp-file fsync alone does not persist the
|
|
83
|
-
// directory entry).
|
|
174
|
+
let fd: number | undefined;
|
|
175
|
+
let writeFailure: unknown;
|
|
176
|
+
let closeFailure: unknown;
|
|
84
177
|
try {
|
|
85
|
-
|
|
178
|
+
fd = fs.openSync(tmp, "w", 0o600);
|
|
179
|
+
const encodedDoc = Buffer.from(JSON.stringify(doc), "utf8");
|
|
180
|
+
let offset = 0;
|
|
181
|
+
while (offset < encodedDoc.length) {
|
|
182
|
+
const written = fs.writeSync(fd, encodedDoc, offset, encodedDoc.length - offset);
|
|
183
|
+
if (written === 0) throw new Error("Short write");
|
|
184
|
+
offset += written;
|
|
185
|
+
}
|
|
186
|
+
fs.fsyncSync(fd);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
writeFailure = error;
|
|
189
|
+
} finally {
|
|
190
|
+
if (fd !== undefined) {
|
|
191
|
+
try {
|
|
192
|
+
fs.closeSync(fd);
|
|
193
|
+
} catch (error) {
|
|
194
|
+
closeFailure = error;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (writeFailure !== undefined || closeFailure !== undefined) {
|
|
86
199
|
try {
|
|
87
|
-
fs.
|
|
88
|
-
}
|
|
89
|
-
|
|
200
|
+
fs.unlinkSync(tmp);
|
|
201
|
+
} catch {
|
|
202
|
+
// The failed temporary file is never published.
|
|
90
203
|
}
|
|
91
|
-
|
|
92
|
-
|
|
204
|
+
if (writeFailure !== undefined && closeFailure !== undefined)
|
|
205
|
+
throw new AggregateError([writeFailure, closeFailure], "ledger temporary write failed");
|
|
206
|
+
throw writeFailure ?? closeFailure;
|
|
93
207
|
}
|
|
208
|
+
fs.renameSync(tmp, idempotencyFile);
|
|
209
|
+
// The temp-file fsync does not persist the rename's directory entry.
|
|
210
|
+
fsyncLedgerParentDirectory(path.dirname(idempotencyFile));
|
|
94
211
|
},
|
|
95
212
|
};
|
|
96
213
|
}
|
|
@@ -150,77 +267,598 @@ export function buildCreateArgv(
|
|
|
150
267
|
return { cwd, args: extraArgs };
|
|
151
268
|
}
|
|
152
269
|
|
|
153
|
-
|
|
154
|
-
|
|
270
|
+
function isKnownNoServerDiagnostic(stderr: string): boolean {
|
|
271
|
+
const diagnostic = stderr.trim().toLowerCase();
|
|
272
|
+
return (
|
|
273
|
+
diagnostic.length > 0 &&
|
|
274
|
+
diagnostic.length <= 512 &&
|
|
275
|
+
/no server running|failed to connect to server|error connecting to/.test(diagnostic)
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function lifecycleOwnerIsolationProbe(tmux: string, env: NodeJS.ProcessEnv): OwnerIsolationProbe {
|
|
280
|
+
return {
|
|
281
|
+
readCallerCgroup: async () =>
|
|
282
|
+
process.platform === "linux" ? await fsPromises.readFile("/proc/self/cgroup", "utf8").catch(() => null) : null,
|
|
283
|
+
probeServer: async (serverKey: string): Promise<TmuxServerProof> => {
|
|
284
|
+
const result = Bun.spawnSync([tmux, "-L", serverKey, "list-sessions", "-F", "#{pid}\t#{session_name}"], {
|
|
285
|
+
stdout: "pipe",
|
|
286
|
+
stderr: "pipe",
|
|
287
|
+
env,
|
|
288
|
+
});
|
|
289
|
+
const stderr = result.stderr.toString();
|
|
290
|
+
if (result.exitCode !== 0)
|
|
291
|
+
return isKnownNoServerDiagnostic(stderr) ? { state: "absent" } : { state: "unverifiable" };
|
|
292
|
+
const lines = result.stdout
|
|
293
|
+
.toString()
|
|
294
|
+
.split("\n")
|
|
295
|
+
.map(line => line.trim())
|
|
296
|
+
.filter(Boolean);
|
|
297
|
+
const [pidText] = lines[0]?.split("\t") ?? [];
|
|
298
|
+
const pid = Number(pidText);
|
|
299
|
+
const sessionNames = lines.map(line => line.split("\t")[1]).filter((name): name is string => Boolean(name));
|
|
300
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return { state: "unverifiable" };
|
|
301
|
+
if (process.platform !== "linux")
|
|
302
|
+
return {
|
|
303
|
+
state: "safe",
|
|
304
|
+
pid,
|
|
305
|
+
startTime: "not_applicable",
|
|
306
|
+
cgroup: { classification: "not_applicable" },
|
|
307
|
+
sessionNames,
|
|
308
|
+
};
|
|
309
|
+
const [cgroupText, stat] = await Promise.all([
|
|
310
|
+
fsPromises.readFile(`/proc/${pid}/cgroup`, "utf8").catch(() => null),
|
|
311
|
+
fsPromises.readFile(`/proc/${pid}/stat`, "utf8").catch(() => null),
|
|
312
|
+
]);
|
|
313
|
+
const cgroup = classifyCgroup({ platform: process.platform, cgroupText });
|
|
314
|
+
const startTime = stat
|
|
315
|
+
?.slice(stat.lastIndexOf(")") + 2)
|
|
316
|
+
.trim()
|
|
317
|
+
.split(/\s+/)[19];
|
|
318
|
+
if (!startTime) return { state: "unverifiable", pid, cgroup, sessionNames };
|
|
319
|
+
return {
|
|
320
|
+
state:
|
|
321
|
+
cgroup.classification === "safe"
|
|
322
|
+
? "safe"
|
|
323
|
+
: cgroup.classification === "unsafe_service"
|
|
324
|
+
? "unsafe"
|
|
325
|
+
: "unverifiable",
|
|
326
|
+
pid,
|
|
327
|
+
startTime,
|
|
328
|
+
cgroup,
|
|
329
|
+
sessionNames,
|
|
330
|
+
};
|
|
331
|
+
},
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function lifecycleRuntimeStateFile(cwd: string, sessionId: string, tmuxSession: string): string {
|
|
336
|
+
return tmuxRuntimeSessionPath(cwd, sessionId, buildSkcTmuxSessionSlug(tmuxSession));
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function preflightLifecycleTmuxOwner(input: {
|
|
340
|
+
tmux: string;
|
|
341
|
+
env: NodeJS.ProcessEnv;
|
|
342
|
+
ownerIsolationProbe?: OwnerIsolationProbe;
|
|
343
|
+
}): Promise<void> {
|
|
344
|
+
const probe = input.ownerIsolationProbe ?? lifecycleOwnerIsolationProbe(input.tmux, input.env);
|
|
345
|
+
let server: TmuxServerProof;
|
|
346
|
+
try {
|
|
347
|
+
server = await probe.probeServer("default");
|
|
348
|
+
} catch {
|
|
349
|
+
throw new Error("skc_lifecycle_owner_server_unverifiable");
|
|
350
|
+
}
|
|
351
|
+
if (server.state === "unsafe") throw new Error("skc_lifecycle_owner_server_unsafe");
|
|
352
|
+
if (server.state === "unverifiable") throw new Error("skc_lifecycle_owner_server_unverifiable");
|
|
353
|
+
if (
|
|
354
|
+
server.state === "safe" &&
|
|
355
|
+
(!server.pid || !server.startTime || (process.platform === "linux" && server.cgroup?.classification !== "safe"))
|
|
356
|
+
)
|
|
357
|
+
throw new Error("skc_lifecycle_owner_server_unverifiable");
|
|
358
|
+
if (server.state === "absent") {
|
|
359
|
+
try {
|
|
360
|
+
if (
|
|
361
|
+
classifyCgroup({ platform: process.platform, cgroupText: await probe.readCallerCgroup() })
|
|
362
|
+
.classification === "unverifiable"
|
|
363
|
+
)
|
|
364
|
+
throw new Error("skc_lifecycle_owner_server_unverifiable");
|
|
365
|
+
} catch (error) {
|
|
366
|
+
if (error instanceof Error && error.message === "skc_lifecycle_owner_server_unverifiable") throw error;
|
|
367
|
+
throw new Error("skc_lifecycle_owner_server_unverifiable");
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
interface LifecycleAttemptExecution {
|
|
373
|
+
attemptCreated: true;
|
|
374
|
+
serverKey: string;
|
|
375
|
+
serverPid?: number;
|
|
376
|
+
serverStartTime?: string;
|
|
377
|
+
nativeSessionId?: string;
|
|
378
|
+
attemptSession: string;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function nativeTmuxSessionIdFromSpawn(stdout: string): string | undefined {
|
|
382
|
+
const value = stdout.endsWith("\n") ? stdout.slice(0, -1) : stdout;
|
|
383
|
+
return !value.includes("\n") && !value.includes("\r") && /^\$\d+$/.test(value) ? value : undefined;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async function isLifecycleGenerationUnchanged(
|
|
387
|
+
stateDir: string,
|
|
388
|
+
sessionId: string,
|
|
389
|
+
previousBaseline: OwnerGenerationBaseline,
|
|
390
|
+
): Promise<boolean> {
|
|
391
|
+
try {
|
|
392
|
+
return (
|
|
393
|
+
JSON.stringify(await captureOwnerGenerationBaseline(stateDir, sessionId)) === JSON.stringify(previousBaseline)
|
|
394
|
+
);
|
|
395
|
+
} catch {
|
|
396
|
+
return false;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function executeLifecycleTmuxOwnerPlan(input: {
|
|
401
|
+
tmux: string;
|
|
402
|
+
env: NodeJS.ProcessEnv;
|
|
403
|
+
sessionId: string;
|
|
404
|
+
generation: string;
|
|
405
|
+
stateDir: string;
|
|
406
|
+
cwd: string;
|
|
407
|
+
argv: string[];
|
|
408
|
+
sessionName: string;
|
|
409
|
+
ownerIsolationProbe?: OwnerIsolationProbe;
|
|
410
|
+
prepareSpawn?: () => void;
|
|
411
|
+
onAttemptCreated?: (attempt: LifecycleAttemptExecution) => void;
|
|
412
|
+
previousBaseline: OwnerGenerationBaseline;
|
|
413
|
+
}): Promise<LifecycleAttemptExecution> {
|
|
414
|
+
const probe = input.ownerIsolationProbe ?? lifecycleOwnerIsolationProbe(input.tmux, input.env);
|
|
415
|
+
await preflightLifecycleTmuxOwner(input);
|
|
416
|
+
if (!(await isLifecycleGenerationUnchanged(input.stateDir, input.sessionId, input.previousBaseline)))
|
|
417
|
+
throw new Error("skc_lifecycle_owner_generation_changed");
|
|
418
|
+
const plan = await planTmuxOwnerIsolation(
|
|
419
|
+
{
|
|
420
|
+
schema_version: 1,
|
|
421
|
+
op: "plan",
|
|
422
|
+
platform: process.platform,
|
|
423
|
+
session_id: input.sessionId,
|
|
424
|
+
owner_generation: input.generation,
|
|
425
|
+
baseline: input.previousBaseline,
|
|
426
|
+
cwd: input.cwd,
|
|
427
|
+
state_dir: input.stateDir,
|
|
428
|
+
socket_key: "default",
|
|
429
|
+
tmux_argv: input.argv,
|
|
430
|
+
},
|
|
431
|
+
probe,
|
|
432
|
+
);
|
|
433
|
+
if (!plan.ok) throw new Error(`skc_lifecycle_owner_${plan.code}`);
|
|
434
|
+
const preSpawnProof =
|
|
435
|
+
plan.execution.mode === "direct" && plan.server_state === "safe"
|
|
436
|
+
? await probe.probeServer(plan.execution.server_key)
|
|
437
|
+
: undefined;
|
|
438
|
+
if (
|
|
439
|
+
preSpawnProof &&
|
|
440
|
+
(preSpawnProof.state !== "safe" ||
|
|
441
|
+
!preSpawnProof.pid ||
|
|
442
|
+
!preSpawnProof.startTime ||
|
|
443
|
+
(process.platform === "linux" && preSpawnProof.cgroup?.classification !== "safe"))
|
|
444
|
+
)
|
|
445
|
+
throw new Error("skc_lifecycle_owner_server_unverifiable");
|
|
446
|
+
input.prepareSpawn?.();
|
|
447
|
+
if (!(await isLifecycleGenerationUnchanged(input.stateDir, input.sessionId, input.previousBaseline)))
|
|
448
|
+
throw new Error("skc_lifecycle_owner_generation_changed");
|
|
449
|
+
const created = Bun.spawnSync(plan.execution.argv, {
|
|
450
|
+
stdout: "pipe",
|
|
451
|
+
stderr: "pipe",
|
|
452
|
+
env: input.env,
|
|
453
|
+
...(plan.execution.mode === "scoped" ? { stdin: new TextEncoder().encode(plan.execution.stdin_line) } : {}),
|
|
454
|
+
});
|
|
455
|
+
const rawStdout = created.stdout.toString();
|
|
456
|
+
const rawScopedReceipt =
|
|
457
|
+
plan.execution.mode === "scoped" && isExactScopedBootstrapSuccessReceipt(rawStdout)
|
|
458
|
+
? (JSON.parse(rawStdout.endsWith("\n") ? rawStdout.slice(0, -1) : rawStdout) as {
|
|
459
|
+
native_session_id: string;
|
|
460
|
+
server_pid: number;
|
|
461
|
+
server_start_time: string;
|
|
462
|
+
session_name: string;
|
|
463
|
+
})
|
|
464
|
+
: undefined;
|
|
465
|
+
const scopedReceipt =
|
|
466
|
+
rawScopedReceipt?.session_name === plan.execution.attempt_session ? rawScopedReceipt : undefined;
|
|
467
|
+
const attempt: LifecycleAttemptExecution = {
|
|
468
|
+
attemptCreated: true,
|
|
469
|
+
serverKey: plan.execution.server_key,
|
|
470
|
+
nativeSessionId:
|
|
471
|
+
plan.execution.mode === "scoped" ? scopedReceipt?.native_session_id : nativeTmuxSessionIdFromSpawn(rawStdout),
|
|
472
|
+
attemptSession: plan.execution.attempt_session,
|
|
473
|
+
...(scopedReceipt
|
|
474
|
+
? { serverPid: scopedReceipt.server_pid, serverStartTime: scopedReceipt.server_start_time }
|
|
475
|
+
: preSpawnProof
|
|
476
|
+
? { serverPid: preSpawnProof.pid, serverStartTime: preSpawnProof.startTime }
|
|
477
|
+
: {}),
|
|
478
|
+
};
|
|
479
|
+
input.onAttemptCreated?.(attempt);
|
|
480
|
+
if (created.exitCode !== 0) {
|
|
481
|
+
if (!attempt.nativeSessionId || attempt.serverPid === undefined || attempt.serverStartTime === undefined)
|
|
482
|
+
throw new Error("skc_lifecycle_spawn_failed_cleanup_uncertain");
|
|
483
|
+
throw new Error("skc_lifecycle_spawn_failed");
|
|
484
|
+
}
|
|
485
|
+
if (!attempt.nativeSessionId) throw new Error("skc_lifecycle_spawn_failed_cleanup_uncertain");
|
|
486
|
+
const proof = await probe.probeServer(plan.execution.server_key);
|
|
487
|
+
if (
|
|
488
|
+
proof.state !== "safe" ||
|
|
489
|
+
!proof.pid ||
|
|
490
|
+
!proof.startTime ||
|
|
491
|
+
!proof.sessionNames?.includes(plan.execution.attempt_session) ||
|
|
492
|
+
(process.platform === "linux" && proof.cgroup?.classification !== "safe") ||
|
|
493
|
+
(preSpawnProof && (proof.pid !== preSpawnProof.pid || proof.startTime !== preSpawnProof.startTime)) ||
|
|
494
|
+
(scopedReceipt && (proof.pid !== scopedReceipt.server_pid || proof.startTime !== scopedReceipt.server_start_time))
|
|
495
|
+
)
|
|
496
|
+
throw new Error("skc_lifecycle_owner_server_unverifiable");
|
|
497
|
+
attempt.serverPid = proof.pid;
|
|
498
|
+
attempt.serverStartTime = proof.startTime;
|
|
499
|
+
if (
|
|
500
|
+
!(await reproveLifecycleAttempt({
|
|
501
|
+
tmux: input.tmux,
|
|
502
|
+
env: input.env,
|
|
503
|
+
serverKey: attempt.serverKey,
|
|
504
|
+
nativeSessionId: attempt.nativeSessionId,
|
|
505
|
+
attemptSession: attempt.attemptSession,
|
|
506
|
+
expectedServerPid: attempt.serverPid,
|
|
507
|
+
expectedServerStartTime: attempt.serverStartTime,
|
|
508
|
+
ownerIsolationProbe: input.ownerIsolationProbe,
|
|
509
|
+
}))
|
|
510
|
+
)
|
|
511
|
+
throw new Error("skc_lifecycle_owner_server_unverifiable");
|
|
512
|
+
|
|
513
|
+
if (!(await isLifecycleGenerationUnchanged(input.stateDir, input.sessionId, input.previousBaseline)))
|
|
514
|
+
throw new Error("skc_lifecycle_owner_generation_changed");
|
|
515
|
+
return attempt;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function hasExactNativeSessionBinding(input: {
|
|
519
|
+
tmux: string;
|
|
520
|
+
env: NodeJS.ProcessEnv;
|
|
521
|
+
serverKey: string;
|
|
522
|
+
nativeSessionId: string;
|
|
523
|
+
attemptSession: string;
|
|
524
|
+
}): boolean {
|
|
525
|
+
const result = Bun.spawnSync(
|
|
526
|
+
[
|
|
527
|
+
input.tmux,
|
|
528
|
+
"-L",
|
|
529
|
+
input.serverKey,
|
|
530
|
+
"display-message",
|
|
531
|
+
"-p",
|
|
532
|
+
"-t",
|
|
533
|
+
input.nativeSessionId,
|
|
534
|
+
"#{session_id}\t#{session_name}",
|
|
535
|
+
],
|
|
536
|
+
{ stdout: "pipe", stderr: "pipe", env: input.env },
|
|
537
|
+
);
|
|
538
|
+
if (result.exitCode !== 0) return false;
|
|
539
|
+
const output = result.stdout.toString();
|
|
540
|
+
const line = output.endsWith("\n") ? output.slice(0, -1) : output;
|
|
541
|
+
return line === `${input.nativeSessionId}\t${input.attemptSession}`;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function cleanupUncertain(): Error {
|
|
545
|
+
return new Error("skc_lifecycle_cleanup_uncertain");
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
async function reproveLifecycleAttempt(input: {
|
|
549
|
+
tmux: string;
|
|
550
|
+
env: NodeJS.ProcessEnv;
|
|
551
|
+
serverKey: string;
|
|
552
|
+
nativeSessionId: string;
|
|
553
|
+
attemptSession: string;
|
|
554
|
+
expectedServerPid: number;
|
|
555
|
+
expectedServerStartTime: string;
|
|
556
|
+
ownerIsolationProbe?: OwnerIsolationProbe;
|
|
557
|
+
}): Promise<boolean> {
|
|
558
|
+
const probe = input.ownerIsolationProbe ?? lifecycleOwnerIsolationProbe(input.tmux, input.env);
|
|
559
|
+
const isExpectedServer = (proof: TmuxServerProof): boolean =>
|
|
560
|
+
proof.state === "safe" &&
|
|
561
|
+
proof.pid === input.expectedServerPid &&
|
|
562
|
+
proof.startTime === input.expectedServerStartTime &&
|
|
563
|
+
proof.sessionNames?.includes(input.attemptSession) === true &&
|
|
564
|
+
(process.platform !== "linux" || proof.cgroup?.classification === "safe");
|
|
565
|
+
let proof: TmuxServerProof;
|
|
566
|
+
try {
|
|
567
|
+
proof = await probe.probeServer(input.serverKey);
|
|
568
|
+
} catch {
|
|
569
|
+
return false;
|
|
570
|
+
}
|
|
571
|
+
if (!isExpectedServer(proof) || !hasExactNativeSessionBinding(input)) return false;
|
|
572
|
+
try {
|
|
573
|
+
proof = await probe.probeServer(input.serverKey);
|
|
574
|
+
} catch {
|
|
575
|
+
return false;
|
|
576
|
+
}
|
|
577
|
+
return isExpectedServer(proof);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
async function cleanupLifecycleAttempt(input: {
|
|
581
|
+
tmux: string;
|
|
582
|
+
env: NodeJS.ProcessEnv;
|
|
583
|
+
serverKey: string;
|
|
584
|
+
nativeSessionId?: string;
|
|
585
|
+
attemptSession: string;
|
|
586
|
+
expectedServerPid?: number;
|
|
587
|
+
expectedServerStartTime?: string;
|
|
588
|
+
ownerIsolationProbe?: OwnerIsolationProbe;
|
|
589
|
+
}): Promise<void> {
|
|
590
|
+
if (!input.nativeSessionId || input.expectedServerPid === undefined || input.expectedServerStartTime === undefined)
|
|
591
|
+
throw cleanupUncertain();
|
|
592
|
+
// External reproof narrows the window and detects obvious replacement before
|
|
593
|
+
// mutation, but the server evaluates the identity predicate atomically.
|
|
594
|
+
if (
|
|
595
|
+
!(await reproveLifecycleAttempt({
|
|
596
|
+
tmux: input.tmux,
|
|
597
|
+
env: input.env,
|
|
598
|
+
serverKey: input.serverKey,
|
|
599
|
+
nativeSessionId: input.nativeSessionId,
|
|
600
|
+
attemptSession: input.attemptSession,
|
|
601
|
+
expectedServerPid: input.expectedServerPid,
|
|
602
|
+
expectedServerStartTime: input.expectedServerStartTime,
|
|
603
|
+
ownerIsolationProbe: input.ownerIsolationProbe,
|
|
604
|
+
}))
|
|
605
|
+
)
|
|
606
|
+
throw cleanupUncertain();
|
|
607
|
+
const guarded = Bun.spawnSync(
|
|
608
|
+
[
|
|
609
|
+
input.tmux,
|
|
610
|
+
"-L",
|
|
611
|
+
input.serverKey,
|
|
612
|
+
"if-shell",
|
|
613
|
+
"-t",
|
|
614
|
+
input.nativeSessionId,
|
|
615
|
+
"-F",
|
|
616
|
+
lifecycleMetadataPredicate(input.expectedServerPid, input.nativeSessionId, input.attemptSession),
|
|
617
|
+
`kill-session -t '${input.nativeSessionId}' ; display-message -p __skc_lifecycle_cleanup_ok__`,
|
|
618
|
+
"display-message -p __skc_lifecycle_cleanup_refused__",
|
|
619
|
+
],
|
|
620
|
+
{ stdout: "pipe", stderr: "pipe", env: input.env },
|
|
621
|
+
);
|
|
622
|
+
if (guarded.exitCode !== 0 || guarded.stdout.toString().trim() !== "__skc_lifecycle_cleanup_ok__")
|
|
623
|
+
throw cleanupUncertain();
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
async function assertLifecycleTmuxServerSafe(input: {
|
|
627
|
+
tmux: string;
|
|
628
|
+
env: NodeJS.ProcessEnv;
|
|
629
|
+
ownerIsolationProbe?: OwnerIsolationProbe;
|
|
630
|
+
}): Promise<void> {
|
|
631
|
+
const proof = await (input.ownerIsolationProbe ?? lifecycleOwnerIsolationProbe(input.tmux, input.env)).probeServer(
|
|
632
|
+
"default",
|
|
633
|
+
);
|
|
634
|
+
if (
|
|
635
|
+
proof.state !== "safe" ||
|
|
636
|
+
!proof.pid ||
|
|
637
|
+
!proof.startTime ||
|
|
638
|
+
(process.platform === "linux" && proof.cgroup?.classification !== "safe")
|
|
639
|
+
)
|
|
640
|
+
throw new Error(`skc_lifecycle_owner_${proof.state === "unsafe" ? "server_unsafe" : "server_unverifiable"}`);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** Real daemon-safe tmux launcher: canonical owner-isolation plan + SKC tags. */
|
|
644
|
+
export function daemonSpawnCreate(
|
|
645
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
646
|
+
opts: { ownerIsolationProbe?: OwnerIsolationProbe } = {},
|
|
647
|
+
) {
|
|
155
648
|
return async (
|
|
156
649
|
frame: SessionCreateFrame,
|
|
157
650
|
ids: { lifecycleRequestId: string; intendedSessionId: string; startupPromptRef?: string },
|
|
158
651
|
): Promise<CreateEffectResult> => {
|
|
159
|
-
const
|
|
652
|
+
const tmuxBinary = resolveSkcTmuxBinary({ env });
|
|
653
|
+
if (tmuxBinary.isPsmux) throw new Error("skc_lifecycle_psmux_unsupported");
|
|
654
|
+
const tmux = tmuxBinary.command;
|
|
160
655
|
const name = tmuxSessionNameFor(ids.intendedSessionId);
|
|
161
656
|
const { cwd, args } = buildCreateArgv(frame, ids);
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
//
|
|
657
|
+
const sessionStateFile = lifecycleRuntimeStateFile(cwd, ids.intendedSessionId, name);
|
|
658
|
+
const stateDir = path.dirname(sessionStateFile);
|
|
659
|
+
const generation = crypto.randomUUID();
|
|
660
|
+
const previousBaseline = await captureOwnerGenerationBaseline(stateDir, ids.intendedSessionId);
|
|
661
|
+
// Detached: no interactive TTY needed (daemon-safe). These values contain
|
|
662
|
+
// only opaque ids and paths needed by the resident sidecar to publish its
|
|
663
|
+
// exact-owner terminal verdict.
|
|
168
664
|
const childEnv: Record<string, string> = {
|
|
169
665
|
SKC_TMUX_LAUNCHED: "1",
|
|
170
666
|
SKC_NOTIFICATIONS: "1",
|
|
171
667
|
SKC_SESSION_ID: ids.intendedSessionId,
|
|
172
668
|
SKC_LIFECYCLE_REQUEST_ID: ids.lifecycleRequestId,
|
|
669
|
+
[SKC_COORDINATOR_SESSION_ID_ENV]: ids.intendedSessionId,
|
|
670
|
+
[SKC_COORDINATOR_SESSION_STATE_FILE_ENV]: sessionStateFile,
|
|
671
|
+
[SKC_TMUX_OWNER_GENERATION_ENV]: generation,
|
|
672
|
+
[SKC_TMUX_OWNER_STATE_DIR_ENV]: stateDir,
|
|
673
|
+
[SKC_TMUX_OWNER_SERVER_KEY_ENV]: "default",
|
|
173
674
|
};
|
|
174
675
|
if (ids.startupPromptRef) childEnv.SKC_STARTUP_PROMPT_REF = ids.startupPromptRef;
|
|
175
676
|
const envPairs = Object.entries(childEnv)
|
|
176
677
|
.map(([k, v]) => `${k}=${shellQuote(v)}`)
|
|
177
678
|
.join(" ");
|
|
178
679
|
const command = `cd ${shellQuote(cwd)} && exec env ${envPairs} skc ${args.map(shellQuote).join(" ")}`;
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
680
|
+
let ownerExecution: LifecycleAttemptExecution | undefined;
|
|
681
|
+
try {
|
|
682
|
+
ownerExecution = await executeLifecycleTmuxOwnerPlan({
|
|
683
|
+
tmux,
|
|
684
|
+
env,
|
|
685
|
+
sessionId: ids.intendedSessionId,
|
|
686
|
+
generation,
|
|
687
|
+
stateDir,
|
|
688
|
+
cwd,
|
|
689
|
+
sessionName: name,
|
|
690
|
+
argv: [tmux, "new-session", "-d", "-P", "-F", "#{session_id}", "-s", name, "sh", "-c", command],
|
|
691
|
+
ownerIsolationProbe: opts.ownerIsolationProbe,
|
|
692
|
+
onAttemptCreated: attempt => {
|
|
693
|
+
ownerExecution = attempt;
|
|
694
|
+
},
|
|
695
|
+
previousBaseline,
|
|
696
|
+
|
|
697
|
+
prepareSpawn: () => {
|
|
698
|
+
if (frame.target.kind === "plain_dir") fs.mkdirSync(cwd, { recursive: true });
|
|
699
|
+
},
|
|
700
|
+
});
|
|
701
|
+
if (!ownerExecution.nativeSessionId || ownerExecution.serverPid === undefined)
|
|
702
|
+
throw new Error("skc_lifecycle_owner_server_unverifiable");
|
|
703
|
+
const target = `${ownerExecution.nativeSessionId}:`;
|
|
704
|
+
await applyRequiredLifecycleTmuxMetadata(
|
|
705
|
+
tmux,
|
|
706
|
+
target,
|
|
707
|
+
env,
|
|
708
|
+
{
|
|
709
|
+
sessionId: ids.intendedSessionId,
|
|
710
|
+
sessionStateFile,
|
|
711
|
+
project: cwd,
|
|
712
|
+
ownerGeneration: generation,
|
|
713
|
+
ownerServerKey: ownerExecution.serverKey,
|
|
714
|
+
},
|
|
715
|
+
{
|
|
716
|
+
nativeSessionId: ownerExecution.nativeSessionId,
|
|
717
|
+
attemptSession: ownerExecution.attemptSession,
|
|
718
|
+
serverPid: ownerExecution.serverPid,
|
|
719
|
+
},
|
|
720
|
+
);
|
|
721
|
+
if (
|
|
722
|
+
!ownerExecution.nativeSessionId ||
|
|
723
|
+
ownerExecution.serverPid === undefined ||
|
|
724
|
+
ownerExecution.serverStartTime === undefined ||
|
|
725
|
+
!(await reproveLifecycleAttempt({
|
|
726
|
+
tmux,
|
|
727
|
+
env,
|
|
728
|
+
serverKey: ownerExecution.serverKey,
|
|
729
|
+
nativeSessionId: ownerExecution.nativeSessionId,
|
|
730
|
+
attemptSession: ownerExecution.attemptSession,
|
|
731
|
+
expectedServerPid: ownerExecution.serverPid,
|
|
732
|
+
expectedServerStartTime: ownerExecution.serverStartTime,
|
|
733
|
+
ownerIsolationProbe: opts.ownerIsolationProbe,
|
|
734
|
+
}))
|
|
735
|
+
)
|
|
736
|
+
throw new Error("skc_lifecycle_owner_server_unverifiable");
|
|
737
|
+
if (!(await isLifecycleGenerationUnchanged(stateDir, ids.intendedSessionId, previousBaseline)))
|
|
738
|
+
throw new Error("skc_lifecycle_owner_generation_changed");
|
|
739
|
+
await replaceOwnerGeneration(stateDir, ids.intendedSessionId, generation, previousBaseline);
|
|
740
|
+
} catch (error) {
|
|
741
|
+
let cleanupFailure: unknown;
|
|
742
|
+
if (ownerExecution?.attemptCreated) {
|
|
743
|
+
try {
|
|
744
|
+
await cleanupLifecycleAttempt({
|
|
745
|
+
tmux,
|
|
746
|
+
env,
|
|
747
|
+
serverKey: ownerExecution.serverKey,
|
|
748
|
+
nativeSessionId: ownerExecution.nativeSessionId,
|
|
749
|
+
attemptSession: ownerExecution.attemptSession,
|
|
750
|
+
expectedServerPid: ownerExecution.serverPid,
|
|
751
|
+
expectedServerStartTime: ownerExecution.serverStartTime,
|
|
752
|
+
ownerIsolationProbe: opts.ownerIsolationProbe,
|
|
753
|
+
});
|
|
754
|
+
} catch (cleanupError) {
|
|
755
|
+
cleanupFailure = cleanupError;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
if (cleanupFailure !== undefined)
|
|
759
|
+
throw new AggregateError([error, cleanupFailure], "skc_lifecycle_cleanup_uncertain");
|
|
760
|
+
throw error;
|
|
194
761
|
}
|
|
195
|
-
|
|
762
|
+
|
|
196
763
|
return {
|
|
197
764
|
sessionId: ids.intendedSessionId,
|
|
198
765
|
tmuxSession: name,
|
|
199
|
-
sessionStateFile
|
|
766
|
+
sessionStateFile,
|
|
200
767
|
endpointUrl: "",
|
|
201
768
|
topicThreadId: "",
|
|
202
769
|
};
|
|
203
770
|
};
|
|
204
771
|
}
|
|
205
772
|
|
|
773
|
+
function tmuxCommandArgument(value: string): string {
|
|
774
|
+
return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("$", "\\$").replaceAll("`", "\\`")}"`;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function lifecycleMetadataPredicate(expectedPid: number, nativeSessionId: string, attemptSession: string): string {
|
|
778
|
+
return `#{&&:#{==:#{pid},${expectedPid}},#{&&:#{==:#{session_id},${nativeSessionId}},#{==:#{session_name},${attemptSession}}}}`;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
async function applyRequiredLifecycleTmuxMetadata(
|
|
782
|
+
tmux: string,
|
|
783
|
+
target: string,
|
|
784
|
+
env: NodeJS.ProcessEnv,
|
|
785
|
+
metadata: {
|
|
786
|
+
sessionId: string;
|
|
787
|
+
sessionStateFile: string;
|
|
788
|
+
project: string;
|
|
789
|
+
ownerGeneration: string;
|
|
790
|
+
ownerServerKey: string;
|
|
791
|
+
},
|
|
792
|
+
attempt: { nativeSessionId: string; attemptSession: string; serverPid: number },
|
|
793
|
+
): Promise<void> {
|
|
794
|
+
if (
|
|
795
|
+
!metadata.sessionId.trim() ||
|
|
796
|
+
!metadata.sessionStateFile.trim() ||
|
|
797
|
+
!metadata.project.trim() ||
|
|
798
|
+
!metadata.ownerGeneration.trim() ||
|
|
799
|
+
!metadata.ownerServerKey.trim()
|
|
800
|
+
)
|
|
801
|
+
throw new Error("skc_lifecycle_metadata_required_missing");
|
|
802
|
+
const commands = buildSkcTmuxProfileCommands(target, env, metadata)
|
|
803
|
+
.map(command => command.args.map(tmuxCommandArgument).join(" "))
|
|
804
|
+
.join(" ; ");
|
|
805
|
+
const result = Bun.spawnSync(
|
|
806
|
+
[
|
|
807
|
+
tmux,
|
|
808
|
+
"-L",
|
|
809
|
+
metadata.ownerServerKey,
|
|
810
|
+
"if-shell",
|
|
811
|
+
"-t",
|
|
812
|
+
attempt.nativeSessionId,
|
|
813
|
+
"-F",
|
|
814
|
+
lifecycleMetadataPredicate(attempt.serverPid, attempt.nativeSessionId, attempt.attemptSession),
|
|
815
|
+
`${commands} ; display-message -p __skc_lifecycle_metadata_ok__`,
|
|
816
|
+
"display-message -p __skc_lifecycle_metadata_refused__",
|
|
817
|
+
],
|
|
818
|
+
{ stdout: "pipe", stderr: "pipe", env },
|
|
819
|
+
);
|
|
820
|
+
if (result.exitCode !== 0 || result.stdout.toString().trim() !== "__skc_lifecycle_metadata_ok__")
|
|
821
|
+
throw new Error("skc_lifecycle_metadata_write_failed");
|
|
822
|
+
}
|
|
823
|
+
|
|
206
824
|
/** Real force-close effect (SKC-managed only, id-matched). */
|
|
207
|
-
export function daemonCloseSession(
|
|
825
|
+
export function daemonCloseSession(
|
|
826
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
827
|
+
deps: {
|
|
828
|
+
forceClose?: (
|
|
829
|
+
name: string,
|
|
830
|
+
env: NodeJS.ProcessEnv,
|
|
831
|
+
expectedSessionId?: string,
|
|
832
|
+
sessionStateFile?: string,
|
|
833
|
+
) => Promise<void>;
|
|
834
|
+
findSession?: (name: string, env: NodeJS.ProcessEnv) => SkcTmuxSessionStatus | undefined;
|
|
835
|
+
} = {},
|
|
836
|
+
) {
|
|
208
837
|
return async (target: { sessionId: string; tmuxSession?: string; sessionStateFile?: string }) => {
|
|
209
838
|
const name = target.tmuxSession ?? tmuxSessionNameFor(target.sessionId);
|
|
210
|
-
forceCloseSkcTmuxSession(name, env, target.sessionId, target.sessionStateFile);
|
|
211
|
-
return { processGone: findSkcTmuxSessionByName(name, env) === undefined };
|
|
839
|
+
await (deps.forceClose ?? forceCloseSkcTmuxSession)(name, env, target.sessionId, target.sessionStateFile);
|
|
840
|
+
return { processGone: (deps.findSession ?? findSkcTmuxSessionByName)(name, env) === undefined };
|
|
212
841
|
};
|
|
213
842
|
}
|
|
214
843
|
|
|
215
844
|
/** Real resume effect: reattach if a live SKC session matches; else resolve the
|
|
216
845
|
* prefix against saved history and fail closed (`ambiguous`/`notFound`) before
|
|
217
846
|
* cold-restarting exactly one resolved session via the daemon-safe launcher. */
|
|
218
|
-
export function daemonResumeSession(
|
|
847
|
+
export function daemonResumeSession(
|
|
848
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
849
|
+
opts: {
|
|
850
|
+
sessionsRoot?: string;
|
|
851
|
+
listSessions?: (env: NodeJS.ProcessEnv) => SkcTmuxSessionStatus[];
|
|
852
|
+
ownerIsolationProbe?: OwnerIsolationProbe;
|
|
853
|
+
} = {},
|
|
854
|
+
) {
|
|
219
855
|
return async (target: {
|
|
220
856
|
sessionIdOrPrefix: string;
|
|
221
857
|
path?: string;
|
|
222
858
|
}): Promise<ResumeEffectResult | { ambiguous: ResumeCandidate[] } | { notFound: true }> => {
|
|
223
|
-
const
|
|
859
|
+
const tmuxBinary = resolveSkcTmuxBinary({ env });
|
|
860
|
+
if (tmuxBinary.isPsmux) throw new Error("skc_lifecycle_psmux_unsupported");
|
|
861
|
+
const live = (opts.listSessions?.(env) ?? listSkcTmuxSessions(env)).filter(
|
|
224
862
|
s => s.sessionId === target.sessionIdOrPrefix || s.sessionId?.startsWith(target.sessionIdOrPrefix),
|
|
225
863
|
);
|
|
226
864
|
if (live.length > 1) {
|
|
@@ -230,6 +868,11 @@ export function daemonResumeSession(env: NodeJS.ProcessEnv = process.env, opts:
|
|
|
230
868
|
}
|
|
231
869
|
if (live.length === 1) {
|
|
232
870
|
const s = live[0]!;
|
|
871
|
+
await assertLifecycleTmuxServerSafe({
|
|
872
|
+
tmux: resolveSkcTmuxCommand(env),
|
|
873
|
+
env,
|
|
874
|
+
ownerIsolationProbe: opts.ownerIsolationProbe,
|
|
875
|
+
});
|
|
233
876
|
return {
|
|
234
877
|
sessionId: s.sessionId ?? s.name,
|
|
235
878
|
tmuxSession: s.name,
|
|
@@ -261,26 +904,111 @@ export function daemonResumeSession(env: NodeJS.ProcessEnv = process.env, opts:
|
|
|
261
904
|
}
|
|
262
905
|
const resolvedResumeCwd = resumeCwd ? path.resolve(resumeCwd) : undefined;
|
|
263
906
|
const resumeCwdStat = resolvedResumeCwd ? fs.statSync(resolvedResumeCwd, { throwIfNoEntry: false }) : undefined;
|
|
264
|
-
if (!resumeCwdStat?.isDirectory()) {
|
|
907
|
+
if (typeof resolvedResumeCwd !== "string" || !resumeCwdStat?.isDirectory()) {
|
|
265
908
|
throw new Error(`skc_lifecycle_resume_cwd_unavailable: ${resolvedResumeCwd ?? "(missing)"}`);
|
|
266
909
|
}
|
|
267
|
-
const tmux =
|
|
910
|
+
const tmux = tmuxBinary.command;
|
|
268
911
|
const name = tmuxSessionNameFor(resumeId);
|
|
269
|
-
const
|
|
270
|
-
const
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
912
|
+
const sessionStateFile = lifecycleRuntimeStateFile(resolvedResumeCwd, resumeId, name);
|
|
913
|
+
const stateDir = path.dirname(sessionStateFile);
|
|
914
|
+
const generation = crypto.randomUUID();
|
|
915
|
+
const previousBaseline = await captureOwnerGenerationBaseline(stateDir, resumeId);
|
|
916
|
+
const childEnv: Record<string, string> = {
|
|
917
|
+
SKC_TMUX_LAUNCHED: "1",
|
|
918
|
+
SKC_NOTIFICATIONS: "1",
|
|
919
|
+
[SKC_COORDINATOR_SESSION_ID_ENV]: resumeId,
|
|
920
|
+
[SKC_COORDINATOR_SESSION_STATE_FILE_ENV]: sessionStateFile,
|
|
921
|
+
[SKC_TMUX_OWNER_GENERATION_ENV]: generation,
|
|
922
|
+
[SKC_TMUX_OWNER_STATE_DIR_ENV]: stateDir,
|
|
923
|
+
[SKC_TMUX_OWNER_SERVER_KEY_ENV]: "default",
|
|
924
|
+
};
|
|
925
|
+
const envPairs = Object.entries(childEnv)
|
|
926
|
+
.map(([key, value]) => `${key}=${shellQuote(value)}`)
|
|
927
|
+
.join(" ");
|
|
928
|
+
const command = `cd ${shellQuote(resolvedResumeCwd)} && exec env ${envPairs} skc --resume ${shellQuote(resumeId)}`;
|
|
929
|
+
let ownerExecution: LifecycleAttemptExecution | undefined;
|
|
930
|
+
try {
|
|
931
|
+
ownerExecution = await executeLifecycleTmuxOwnerPlan({
|
|
932
|
+
tmux,
|
|
933
|
+
env,
|
|
934
|
+
sessionId: resumeId,
|
|
935
|
+
generation,
|
|
936
|
+
stateDir,
|
|
937
|
+
cwd: resolvedResumeCwd,
|
|
938
|
+
sessionName: name,
|
|
939
|
+
argv: [tmux, "new-session", "-d", "-P", "-F", "#{session_id}", "-s", name, "sh", "-c", command],
|
|
940
|
+
ownerIsolationProbe: opts.ownerIsolationProbe,
|
|
941
|
+
onAttemptCreated: attempt => {
|
|
942
|
+
ownerExecution = attempt;
|
|
943
|
+
},
|
|
944
|
+
previousBaseline,
|
|
945
|
+
});
|
|
946
|
+
if (!ownerExecution.nativeSessionId || ownerExecution.serverPid === undefined)
|
|
947
|
+
throw new Error("skc_lifecycle_owner_server_unverifiable");
|
|
948
|
+
const tgt = `${ownerExecution.nativeSessionId}:`;
|
|
949
|
+
await applyRequiredLifecycleTmuxMetadata(
|
|
950
|
+
tmux,
|
|
951
|
+
tgt,
|
|
952
|
+
env,
|
|
953
|
+
{
|
|
954
|
+
sessionId: resumeId,
|
|
955
|
+
sessionStateFile,
|
|
956
|
+
project: resolvedResumeCwd,
|
|
957
|
+
ownerGeneration: generation,
|
|
958
|
+
ownerServerKey: ownerExecution.serverKey,
|
|
959
|
+
},
|
|
960
|
+
{
|
|
961
|
+
nativeSessionId: ownerExecution.nativeSessionId,
|
|
962
|
+
attemptSession: ownerExecution.attemptSession,
|
|
963
|
+
serverPid: ownerExecution.serverPid,
|
|
964
|
+
},
|
|
965
|
+
);
|
|
966
|
+
if (
|
|
967
|
+
!ownerExecution.nativeSessionId ||
|
|
968
|
+
ownerExecution.serverPid === undefined ||
|
|
969
|
+
ownerExecution.serverStartTime === undefined ||
|
|
970
|
+
!(await reproveLifecycleAttempt({
|
|
971
|
+
tmux,
|
|
972
|
+
env,
|
|
973
|
+
serverKey: ownerExecution.serverKey,
|
|
974
|
+
nativeSessionId: ownerExecution.nativeSessionId,
|
|
975
|
+
attemptSession: ownerExecution.attemptSession,
|
|
976
|
+
expectedServerPid: ownerExecution.serverPid,
|
|
977
|
+
expectedServerStartTime: ownerExecution.serverStartTime,
|
|
978
|
+
ownerIsolationProbe: opts.ownerIsolationProbe,
|
|
979
|
+
}))
|
|
980
|
+
)
|
|
981
|
+
throw new Error("skc_lifecycle_owner_server_unverifiable");
|
|
982
|
+
if (!(await isLifecycleGenerationUnchanged(stateDir, resumeId, previousBaseline)))
|
|
983
|
+
throw new Error("skc_lifecycle_owner_generation_changed");
|
|
984
|
+
await replaceOwnerGeneration(stateDir, resumeId, generation, previousBaseline);
|
|
985
|
+
} catch (error) {
|
|
986
|
+
let cleanupFailure: unknown;
|
|
987
|
+
if (ownerExecution?.attemptCreated) {
|
|
988
|
+
try {
|
|
989
|
+
await cleanupLifecycleAttempt({
|
|
990
|
+
tmux,
|
|
991
|
+
env,
|
|
992
|
+
serverKey: ownerExecution.serverKey,
|
|
993
|
+
nativeSessionId: ownerExecution.nativeSessionId,
|
|
994
|
+
attemptSession: ownerExecution.attemptSession,
|
|
995
|
+
expectedServerPid: ownerExecution.serverPid,
|
|
996
|
+
expectedServerStartTime: ownerExecution.serverStartTime,
|
|
997
|
+
ownerIsolationProbe: opts.ownerIsolationProbe,
|
|
998
|
+
});
|
|
999
|
+
} catch (cleanupError) {
|
|
1000
|
+
cleanupFailure = cleanupError;
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
if (cleanupFailure !== undefined)
|
|
1004
|
+
throw new AggregateError([error, cleanupFailure], "skc_lifecycle_cleanup_uncertain");
|
|
1005
|
+
throw error;
|
|
280
1006
|
}
|
|
1007
|
+
|
|
281
1008
|
return {
|
|
282
1009
|
sessionId: resumeId,
|
|
283
1010
|
tmuxSession: name,
|
|
1011
|
+
sessionStateFile,
|
|
284
1012
|
endpointUrl: "",
|
|
285
1013
|
topicThreadId: "",
|
|
286
1014
|
mode: "cold_restarted",
|
|
@@ -343,6 +1071,113 @@ export function outcomeToResponse(frame: SessionLifecycleRequest, outcome: Lifec
|
|
|
343
1071
|
};
|
|
344
1072
|
}
|
|
345
1073
|
|
|
1074
|
+
const MAX_LIFECYCLE_REQUEST_ID_BYTES = 128;
|
|
1075
|
+
|
|
1076
|
+
function hasLoneUtf16Surrogate(value: string): boolean {
|
|
1077
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
1078
|
+
const codeUnit = value.charCodeAt(index);
|
|
1079
|
+
if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) {
|
|
1080
|
+
if (index + 1 >= value.length || value.charCodeAt(index + 1) < 0xdc00 || value.charCodeAt(index + 1) > 0xdfff)
|
|
1081
|
+
return true;
|
|
1082
|
+
index += 1;
|
|
1083
|
+
} else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) {
|
|
1084
|
+
return true;
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
return false;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
function boundedLifecycleRequestId(requestId: unknown): string {
|
|
1091
|
+
if (typeof requestId !== "string" || hasLoneUtf16Surrogate(requestId)) return "";
|
|
1092
|
+
let bounded = "";
|
|
1093
|
+
let byteLength = 0;
|
|
1094
|
+
for (const codePoint of requestId) {
|
|
1095
|
+
const codePointBytes = Buffer.byteLength(codePoint, "utf8");
|
|
1096
|
+
if (byteLength + codePointBytes > MAX_LIFECYCLE_REQUEST_ID_BYTES) break;
|
|
1097
|
+
bounded += codePoint;
|
|
1098
|
+
byteLength += codePointBytes;
|
|
1099
|
+
}
|
|
1100
|
+
return bounded;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
function lifecycleFailureResponse(requestId: unknown): SessionLifecycleResponse {
|
|
1104
|
+
return {
|
|
1105
|
+
type: "session_lifecycle_error",
|
|
1106
|
+
requestId: boundedLifecycleRequestId(requestId),
|
|
1107
|
+
status: "error",
|
|
1108
|
+
reason: "terminal_uncertain",
|
|
1109
|
+
message: "request could not be processed",
|
|
1110
|
+
};
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
function recordLifecycleControlDiagnostic(): void {
|
|
1114
|
+
try {
|
|
1115
|
+
process.stderr.write("skc lifecycle control request failed\n");
|
|
1116
|
+
} catch {
|
|
1117
|
+
// Diagnostics must not suppress the fixed wire failure response or recovery.
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
1122
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
function isSafeLifecycleId(value: unknown): value is string {
|
|
1126
|
+
return typeof value === "string" && !hasLoneUtf16Surrogate(value);
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
/** The native server authenticates callbacks before redacting the control token. */
|
|
1130
|
+
type AuthenticatedNativeLifecycleRequest =
|
|
1131
|
+
| Omit<SessionCreateFrame, "token">
|
|
1132
|
+
| Omit<Extract<SessionLifecycleRequest, { type: "session_close" }>, "token">
|
|
1133
|
+
| Omit<Extract<SessionLifecycleRequest, { type: "session_resume" }>, "token">;
|
|
1134
|
+
|
|
1135
|
+
function isBoundedRequestFrame(frame: unknown): frame is AuthenticatedNativeLifecycleRequest {
|
|
1136
|
+
if (
|
|
1137
|
+
!isRecord(frame) ||
|
|
1138
|
+
!isSafeLifecycleId(frame.requestId) ||
|
|
1139
|
+
Buffer.byteLength(frame.requestId, "utf8") > MAX_LIFECYCLE_REQUEST_ID_BYTES
|
|
1140
|
+
)
|
|
1141
|
+
return false;
|
|
1142
|
+
if (
|
|
1143
|
+
!isSafeLifecycleId(frame.chatId) ||
|
|
1144
|
+
(typeof frame.token !== "undefined" && typeof frame.token !== "string") ||
|
|
1145
|
+
!Number.isSafeInteger(frame.updateId)
|
|
1146
|
+
)
|
|
1147
|
+
return false;
|
|
1148
|
+
if (!isRecord(frame.target)) return false;
|
|
1149
|
+
switch (frame.type) {
|
|
1150
|
+
case "session_create":
|
|
1151
|
+
return (
|
|
1152
|
+
isSafeLifecycleId(frame.lifecycleRequestId) &&
|
|
1153
|
+
isSafeLifecycleId(frame.intendedSessionId) &&
|
|
1154
|
+
(typeof frame.startupPromptRef === "undefined" || typeof frame.startupPromptRef === "string") &&
|
|
1155
|
+
(typeof frame.modelPreset === "undefined" || typeof frame.modelPreset === "string") &&
|
|
1156
|
+
((frame.target.kind === "worktree" &&
|
|
1157
|
+
typeof frame.target.repo === "string" &&
|
|
1158
|
+
typeof frame.target.branch === "string") ||
|
|
1159
|
+
((frame.target.kind === "existing_path" || frame.target.kind === "plain_dir") &&
|
|
1160
|
+
typeof frame.target.path === "string"))
|
|
1161
|
+
);
|
|
1162
|
+
case "session_close":
|
|
1163
|
+
return (
|
|
1164
|
+
isSafeLifecycleId(frame.target.sessionId) &&
|
|
1165
|
+
(typeof frame.target.tmuxSession === "undefined" || isSafeLifecycleId(frame.target.tmuxSession)) &&
|
|
1166
|
+
(typeof frame.target.sessionStateFile === "undefined" ||
|
|
1167
|
+
typeof frame.target.sessionStateFile === "string") &&
|
|
1168
|
+
(typeof frame.force === "undefined" || typeof frame.force === "boolean")
|
|
1169
|
+
);
|
|
1170
|
+
case "session_resume":
|
|
1171
|
+
return (
|
|
1172
|
+
isSafeLifecycleId(frame.target.sessionIdOrPrefix) &&
|
|
1173
|
+
(typeof frame.target.path === "undefined" || typeof frame.target.path === "string") &&
|
|
1174
|
+
(typeof frame.startupPromptRef === "undefined" || typeof frame.startupPromptRef === "string")
|
|
1175
|
+
);
|
|
1176
|
+
default:
|
|
1177
|
+
return false;
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
346
1181
|
/**
|
|
347
1182
|
* Wire a control server's lifecycle requests through the orchestrator.
|
|
348
1183
|
*
|
|
@@ -356,21 +1191,39 @@ export function outcomeToResponse(frame: SessionLifecycleRequest, outcome: Lifec
|
|
|
356
1191
|
export function attachLifecycleControl(server: ControlServerLike, deps: OrchestratorDeps): void {
|
|
357
1192
|
let queue: Promise<void> = Promise.resolve();
|
|
358
1193
|
server.onLifecycleRequest((err, req) => {
|
|
359
|
-
if (err)
|
|
360
|
-
|
|
361
|
-
try {
|
|
362
|
-
frame = JSON.parse(req.payloadJson) as SessionLifecycleRequest;
|
|
363
|
-
} catch {
|
|
1194
|
+
if (err) {
|
|
1195
|
+
recordLifecycleControlDiagnostic();
|
|
364
1196
|
return;
|
|
365
1197
|
}
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
1198
|
+
const envelopeRequestId = boundedLifecycleRequestId(req.requestId);
|
|
1199
|
+
const run = async (): Promise<void> => {
|
|
1200
|
+
let frame: AuthenticatedNativeLifecycleRequest | undefined;
|
|
1201
|
+
|
|
1202
|
+
try {
|
|
1203
|
+
const parsed = JSON.parse(req.payloadJson) as unknown;
|
|
1204
|
+
if (!isBoundedRequestFrame(parsed)) throw new Error("invalid lifecycle frame");
|
|
1205
|
+
frame = parsed;
|
|
1206
|
+
const outcome = await handleLifecycleRequest(frame as SessionLifecycleRequest, deps);
|
|
1207
|
+
|
|
1208
|
+
server.respond(
|
|
1209
|
+
JSON.stringify(
|
|
1210
|
+
outcome.status === "error" && outcome.reason === "terminal_uncertain"
|
|
1211
|
+
? lifecycleFailureResponse(frame.requestId)
|
|
1212
|
+
: outcomeToResponse(frame as SessionLifecycleRequest, outcome),
|
|
1213
|
+
),
|
|
1214
|
+
);
|
|
1215
|
+
} catch {
|
|
1216
|
+
recordLifecycleControlDiagnostic();
|
|
1217
|
+
// A valid frame preserves its immutable request id; malformed frames use
|
|
1218
|
+
// only the bounded native envelope id.
|
|
1219
|
+
server.respond(JSON.stringify(lifecycleFailureResponse(frame?.requestId ?? envelopeRequestId)));
|
|
1220
|
+
}
|
|
1221
|
+
};
|
|
1222
|
+
// Recover queue liveness only after this request has attempted its own
|
|
1223
|
+
// failure response. A response transport failure cannot poison later work.
|
|
1224
|
+
queue = queue.then(run, run).catch(() => {
|
|
1225
|
+
recordLifecycleControlDiagnostic();
|
|
1226
|
+
});
|
|
374
1227
|
});
|
|
375
1228
|
}
|
|
376
1229
|
|