oh-my-openagent 4.18.0 → 4.18.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/codex-qa/scripts/lsp-e2e.sh +3677 -0
- package/.agents/skills/opencode-qa/scripts/lsp-e2e.sh +3154 -0
- package/.agents/skills/work-with-pr/SKILL.md +16 -37
- package/.agents/skills/work-with-pr-workspace/evals/evals.json +3 -3
- package/.opencode/skills/work-with-pr/SKILL.md +16 -37
- package/.opencode/skills/work-with-pr-workspace/evals/evals.json +3 -3
- package/bin/AGENTS.md +33 -0
- package/dist/cli/index.js +500 -158
- package/dist/cli-node/index.js +500 -158
- package/dist/hooks/anthropic-context-window-limit-recovery/empty-content-recovery-sdk.d.ts +6 -0
- package/dist/hooks/category-skill-reminder/hook.d.ts +9 -1
- package/dist/hooks/comment-checker/hook.d.ts +8 -1
- package/dist/hooks/todo-continuation-enforcer/types.d.ts +3 -0
- package/dist/index.js +956 -702
- package/dist/plugin/messages-transform.d.ts +1 -0
- package/dist/plugin-handlers/prometheus-agent-config-builder.d.ts +2 -0
- package/dist/tui.js +43 -4
- package/package.json +16 -16
- package/packages/git-bash-mcp/dist/cli.js +81 -19
- package/packages/lsp-core/package.json +4 -0
- package/packages/lsp-core/src/index.ts +1 -0
- package/packages/lsp-core/src/lsp/cleanup-errors.test.ts +18 -0
- package/packages/lsp-core/src/lsp/cleanup-errors.ts +12 -3
- package/packages/lsp-core/src/lsp/client-diagnostics-freshness.integration.test.ts +261 -0
- package/packages/lsp-core/src/lsp/client-wrapper.test.ts +63 -0
- package/packages/lsp-core/src/lsp/client-wrapper.ts +35 -5
- package/packages/lsp-core/src/lsp/client.ts +262 -80
- package/packages/lsp-core/src/lsp/config-loader.ts +5 -17
- package/packages/lsp-core/src/lsp/connection.ts +12 -6
- package/packages/lsp-core/src/lsp/directory-diagnostics.test.ts +221 -0
- package/packages/lsp-core/src/lsp/directory-diagnostics.ts +61 -28
- package/packages/lsp-core/src/lsp/errors.ts +11 -0
- package/packages/lsp-core/src/lsp/fixtures/diagnostics-freshness-contract-probe.ts +283 -0
- package/packages/lsp-core/src/lsp/fixtures/workspace-edit-contract-probe.ts +196 -0
- package/packages/lsp-core/src/lsp/fixtures/workspace-edit-server.mjs +215 -0
- package/packages/lsp-core/src/lsp/formatters.ts +3 -0
- package/packages/lsp-core/src/lsp/json-rpc-connection-cancellation.test.ts +97 -0
- package/packages/lsp-core/src/lsp/json-rpc-connection.ts +73 -5
- package/packages/lsp-core/src/lsp/server-install-state.ts +3 -6
- package/packages/lsp-core/src/lsp/transport-protocol.ts +52 -0
- package/packages/lsp-core/src/lsp/transport.ts +96 -70
- package/packages/lsp-core/src/lsp/workspace-apply-edit-failure.ts +19 -0
- package/packages/lsp-core/src/lsp/workspace-apply-edit-lease.integration.test.ts +214 -0
- package/packages/lsp-core/src/lsp/workspace-apply-edit-sync.integration.test.ts +113 -0
- package/packages/lsp-core/src/lsp/workspace-apply-edit-test-support.ts +163 -0
- package/packages/lsp-core/src/lsp/workspace-apply-edit.integration.test.ts +163 -0
- package/packages/lsp-core/src/lsp/workspace-document-state.test.ts +67 -0
- package/packages/lsp-core/src/lsp/workspace-document-state.ts +368 -0
- package/packages/lsp-core/src/lsp/workspace-edit-adversarial.test.ts +113 -0
- package/packages/lsp-core/src/lsp/workspace-edit-commit.test.ts +140 -0
- package/packages/lsp-core/src/lsp/workspace-edit-commit.ts +220 -0
- package/packages/lsp-core/src/lsp/workspace-edit-contract-evidence.test.ts +56 -0
- package/packages/lsp-core/src/lsp/workspace-edit-contract-evidence.ts +30 -0
- package/packages/lsp-core/src/lsp/workspace-edit-fingerprint.ts +44 -0
- package/packages/lsp-core/src/lsp/workspace-edit-options.test.ts +147 -0
- package/packages/lsp-core/src/lsp/workspace-edit-parse-helpers.ts +59 -0
- package/packages/lsp-core/src/lsp/workspace-edit-parser.ts +130 -0
- package/packages/lsp-core/src/lsp/workspace-edit-path.ts +98 -0
- package/packages/lsp-core/src/lsp/workspace-edit-plan-types.ts +60 -0
- package/packages/lsp-core/src/lsp/workspace-edit-plan.ts +73 -0
- package/packages/lsp-core/src/lsp/workspace-edit-prevalidation.test.ts +174 -0
- package/packages/lsp-core/src/lsp/workspace-edit-resource-parser.ts +89 -0
- package/packages/lsp-core/src/lsp/workspace-edit-simulation.ts +183 -0
- package/packages/lsp-core/src/lsp/workspace-edit-snapshot.ts +53 -0
- package/packages/lsp-core/src/lsp/workspace-edit-text.ts +125 -0
- package/packages/lsp-core/src/lsp/workspace-edit-types.ts +121 -0
- package/packages/lsp-core/src/lsp/workspace-edit.characterization.test.ts +95 -0
- package/packages/lsp-core/src/lsp/workspace-edit.ts +49 -200
- package/packages/lsp-core/src/lsp/workspace-mutation-controller.ts +182 -0
- package/packages/lsp-core/src/mcp.ts +18 -7
- package/packages/lsp-core/src/missing-dependency-result.test.ts +105 -0
- package/packages/lsp-core/src/missing-dependency-result.ts +57 -0
- package/packages/lsp-core/src/post-edit/index.ts +1 -0
- package/packages/lsp-core/src/post-edit/orchestration.test.ts +157 -0
- package/packages/lsp-core/src/post-edit/orchestration.ts +178 -0
- package/packages/lsp-core/src/request-context.test.ts +171 -0
- package/packages/lsp-core/src/request-context.ts +222 -9
- package/packages/lsp-core/src/tool-surface.test.ts +4 -1
- package/packages/lsp-core/src/tools/diagnostics.ts +32 -13
- package/packages/lsp-core/src/tools/navigation.ts +12 -12
- package/packages/lsp-core/src/tools/rename.ts +10 -15
- package/packages/lsp-core/src/tools/symbols.ts +11 -11
- package/packages/lsp-core/src/tools/types.ts +2 -1
- package/packages/lsp-daemon/dist/cli.js +3330 -764
- package/packages/lsp-daemon/dist/client.d.ts +105 -0
- package/packages/lsp-daemon/dist/client.js +5995 -0
- package/packages/lsp-daemon/dist/daemon-client.d.ts +12 -7
- package/packages/lsp-daemon/dist/daemon-client.js +139 -32
- package/packages/lsp-daemon/dist/daemon-server.d.ts +1 -0
- package/packages/lsp-daemon/dist/daemon-server.js +40 -15
- package/packages/lsp-daemon/dist/ensure-daemon.d.ts +10 -8
- package/packages/lsp-daemon/dist/ensure-daemon.js +135 -51
- package/packages/lsp-daemon/dist/index.d.ts +2 -2
- package/packages/lsp-daemon/dist/index.js +3093 -786
- package/packages/lsp-daemon/dist/ipc-protocol.d.ts +46 -0
- package/packages/lsp-daemon/dist/ipc-protocol.js +187 -0
- package/packages/lsp-daemon/dist/lock.js +14 -4
- package/packages/lsp-daemon/dist/ownership.d.ts +49 -0
- package/packages/lsp-daemon/dist/ownership.js +168 -0
- package/packages/lsp-daemon/dist/paths.d.ts +33 -9
- package/packages/lsp-daemon/dist/paths.js +72 -33
- package/packages/lsp-daemon/dist/proxy.d.ts +5 -0
- package/packages/lsp-daemon/dist/proxy.js +123 -16
- package/packages/lsp-daemon/dist/request-routing.d.ts +7 -2
- package/packages/lsp-daemon/dist/request-routing.js +71 -22
- package/packages/lsp-daemon/dist/run-daemon.js +9 -2
- package/packages/lsp-daemon/dist/runtime-contract.d.ts +21 -0
- package/packages/lsp-daemon/dist/runtime-contract.js +58 -0
- package/packages/lsp-daemon/dist/socket-jsonrpc.js +6 -1
- package/packages/lsp-daemon/package.json +12 -3
- package/packages/lsp-tools-mcp/dist/cli.js +2189 -454
- package/packages/lsp-tools-mcp/dist/lsp/manager.js +1741 -148
- package/packages/lsp-tools-mcp/dist/mcp.js +2206 -471
- package/packages/lsp-tools-mcp/dist/request-context.js +176 -6
- package/packages/lsp-tools-mcp/dist/tools.js +2119 -447
- package/packages/omo-codex/plugin/.codex-plugin/plugin.json +1 -1
- package/packages/omo-codex/plugin/.mcp.json +2 -1
- package/packages/omo-codex/plugin/components/bootstrap/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/bootstrap/package.json +1 -1
- package/packages/omo-codex/plugin/components/codegraph/dist/cli.js +100 -28
- package/packages/omo-codex/plugin/components/codegraph/dist/serve.js +100 -28
- package/packages/omo-codex/plugin/components/codegraph/package.json +1 -1
- package/packages/omo-codex/plugin/components/codegraph/src/mcp-bridge.ts +21 -9
- package/packages/omo-codex/plugin/components/codegraph/test/mcp-bridge-fixtures.ts +35 -0
- package/packages/omo-codex/plugin/components/codegraph/test/serve-mcp-bridge-lifecycle.test.ts +69 -0
- package/packages/omo-codex/plugin/components/codegraph/test/serve-mcp-bridge.test.ts +57 -1
- package/packages/omo-codex/plugin/components/comment-checker/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/comment-checker/package.json +1 -1
- package/packages/omo-codex/plugin/components/git-bash/hooks/hooks.json +2 -2
- package/packages/omo-codex/plugin/components/git-bash/package.json +1 -1
- package/packages/omo-codex/plugin/components/lazycodex-executor-verify/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/lazycodex-executor-verify/package.json +1 -1
- package/packages/omo-codex/plugin/components/lsp/.mcp.json +2 -1
- package/packages/omo-codex/plugin/components/lsp/dist/.omo-runtime-manifest.json +55 -0
- package/packages/omo-codex/plugin/components/lsp/dist/cli.js +3033 -936
- package/packages/omo-codex/plugin/components/lsp/dist/codex-hook-cli.js +0 -4
- package/packages/omo-codex/plugin/components/lsp/dist/codex-hook.d.ts +5 -2
- package/packages/omo-codex/plugin/components/lsp/dist/codex-hook.js +41 -62
- package/packages/omo-codex/plugin/components/lsp/dist/daemon-cli-path.d.ts +1 -1
- package/packages/omo-codex/plugin/components/lsp/dist/daemon-cli-path.js +24 -15
- package/packages/omo-codex/plugin/components/lsp/dist/lsp-session-state.d.ts +3 -7
- package/packages/omo-codex/plugin/components/lsp/dist/lsp-session-state.js +23 -49
- package/packages/omo-codex/plugin/components/lsp/hooks/hooks.json +2 -2
- package/packages/omo-codex/plugin/components/lsp/package.json +3 -2
- package/packages/omo-codex/plugin/components/lsp/scripts/build-lsp-daemon.mjs +31 -1
- package/packages/omo-codex/plugin/components/lsp/scripts/build-lsp-daemon.test.mjs +76 -0
- package/packages/omo-codex/plugin/components/lsp/scripts/build-runtime.mjs +201 -0
- package/packages/omo-codex/plugin/components/lsp/scripts/build-runtime.test.mjs +55 -0
- package/packages/omo-codex/plugin/components/lsp/src/codex-hook-cli.ts +0 -4
- package/packages/omo-codex/plugin/components/lsp/src/codex-hook.ts +49 -71
- package/packages/omo-codex/plugin/components/lsp/src/daemon-cli-path.ts +26 -15
- package/packages/omo-codex/plugin/components/lsp/src/lsp-session-state.ts +26 -64
- package/packages/omo-codex/plugin/components/lsp/test/codex-hook-unavailable.test.ts +16 -17
- package/packages/omo-codex/plugin/components/lsp/test/codex-hook.test.ts +30 -4
- package/packages/omo-codex/plugin/components/lsp/test/package-smoke.test.ts +20 -5
- package/packages/omo-codex/plugin/components/rules/bundled-rules/hephaestus/gpt-5.5.md +1 -1
- package/packages/omo-codex/plugin/components/rules/bundled-rules/hephaestus/gpt-5.6.md +5 -3
- package/packages/omo-codex/plugin/components/rules/hooks/hooks.json +4 -4
- package/packages/omo-codex/plugin/components/rules/package.json +1 -1
- package/packages/omo-codex/plugin/components/start-work-continuation/README.md +2 -2
- package/packages/omo-codex/plugin/components/start-work-continuation/directive.md +3 -3
- package/packages/omo-codex/plugin/components/start-work-continuation/dist/cli.js +2 -2
- package/packages/omo-codex/plugin/components/start-work-continuation/hooks/hooks.json +2 -2
- package/packages/omo-codex/plugin/components/start-work-continuation/package.json +1 -1
- package/packages/omo-codex/plugin/components/start-work-continuation/src/boulder-reader.ts +1 -1
- package/packages/omo-codex/plugin/components/start-work-continuation/src/codex-hook.ts +1 -1
- package/packages/omo-codex/plugin/components/start-work-continuation/test/boulder-reader.test.ts +15 -1
- package/packages/omo-codex/plugin/components/start-work-continuation/test/codex-hook.test.ts +20 -0
- package/packages/omo-codex/plugin/components/teammode/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/teammode/package.json +1 -1
- package/packages/omo-codex/plugin/components/telemetry/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/telemetry/package.json +1 -1
- package/packages/omo-codex/plugin/components/ultrawork/directive.md +50 -33
- package/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/ultrawork/package.json +1 -1
- package/packages/omo-codex/plugin/components/ultrawork/skills/ultrawork/SKILL.md +50 -33
- package/packages/omo-codex/plugin/components/ulw-loop/directive.md +50 -33
- package/packages/omo-codex/plugin/components/ulw-loop/dist/cli.js +3 -3
- package/packages/omo-codex/plugin/components/ulw-loop/dist/stop-resume-hook.js +5 -6
- package/packages/omo-codex/plugin/components/ulw-loop/hooks/hooks.json +4 -4
- package/packages/omo-codex/plugin/components/ulw-loop/package.json +1 -1
- package/packages/omo-codex/plugin/components/ulw-loop/skills/ulw-loop/SKILL.md +1 -1
- package/packages/omo-codex/plugin/components/ulw-loop/skills/ulw-loop/references/full-workflow.md +6 -5
- package/packages/omo-codex/plugin/components/ulw-loop/src/stop-resume-hook.ts +5 -6
- package/packages/omo-codex/plugin/components/ulw-loop/test/stop-resume-hook.test.ts +2 -2
- package/packages/omo-codex/plugin/hooks/post-compact-resetting-git-bash-mcp-reminder.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-compact-resetting-lsp-diagnostics-cache.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-compact-resetting-project-rule-cache.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-codegraph-init-guidance.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-comments.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-lsp-diagnostics.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-thread-title-hygiene.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-matching-project-rules.json +1 -1
- package/packages/omo-codex/plugin/hooks/pre-tool-use-enforcing-unlimited-goal-budget.json +1 -1
- package/packages/omo-codex/plugin/hooks/pre-tool-use-guarding-ulw-loop-spawns.json +1 -1
- package/packages/omo-codex/plugin/hooks/pre-tool-use-recommending-git-bash-mcp.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-checking-auto-update.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-checking-bootstrap-provisioning.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-checking-codegraph-bootstrap.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-loading-project-rules.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-recording-session-telemetry.json +1 -1
- package/packages/omo-codex/plugin/hooks/stop-checking-start-work-continuation.json +1 -1
- package/packages/omo-codex/plugin/hooks/stop-checking-ulw-loop-resume.json +1 -1
- package/packages/omo-codex/plugin/hooks/subagent-stop-checking-start-work-continuation.json +1 -1
- package/packages/omo-codex/plugin/hooks/subagent-stop-verifying-lazycodex-executor-evidence.json +1 -1
- package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ultrawork-trigger.json +1 -1
- package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ulw-loop-steering.json +1 -1
- package/packages/omo-codex/plugin/hooks/user-prompt-submit-loading-project-rules.json +1 -1
- package/packages/omo-codex/plugin/package-lock.json +26 -14
- package/packages/omo-codex/plugin/package.json +1 -1
- package/packages/omo-codex/plugin/scripts/build-bundled-mcp-runtimes.mjs +2 -3
- package/packages/omo-codex/plugin/scripts/build-components.mjs +13 -1
- package/packages/omo-codex/plugin/scripts/sync-skills.mjs +9 -1
- package/packages/omo-codex/plugin/skills/review-work/SKILL.md +7 -0
- package/packages/omo-codex/plugin/skills/start-work/SKILL.md +2 -1
- package/packages/omo-codex/plugin/skills/ultrawork/SKILL.md +50 -33
- package/packages/omo-codex/plugin/skills/ulw-loop/SKILL.md +1 -1
- package/packages/omo-codex/plugin/skills/ulw-loop/references/full-workflow.md +6 -5
- package/packages/omo-codex/plugin/test/aggregate-build.test.mjs +8 -0
- package/packages/omo-codex/plugin/test/component-bundled-cli.test.mjs +128 -15
- package/packages/omo-codex/plugin/test/install-time-build-runtime.test.mjs +10 -0
- package/packages/omo-codex/plugin/test/lsp-prebuild-layouts.test.mjs +2 -0
- package/packages/omo-codex/plugin/test/mcp-research-servers.test.mjs +1 -0
- package/packages/omo-codex/plugin/test/sync-skills-orchestration.test.mjs +7 -0
- package/packages/omo-codex/plugin/test/sync-skills-test-support.mjs +34 -3
- package/packages/omo-codex/scripts/install-dist/install-local.mjs +328 -63
|
@@ -0,0 +1,3154 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# lsp-e2e.sh - isolated live OpenCode QA for the shared OMO LSP daemon.
|
|
3
|
+
#
|
|
4
|
+
# Normal mode loads this worktree's OMO plugin into disposable XDG/HOME state,
|
|
5
|
+
# drives a real `opencode serve` with a local fake Responses provider, observes
|
|
6
|
+
# the event stream, and requires an actual completed LSP MCP tool call for the
|
|
7
|
+
# requested scenario.
|
|
8
|
+
#
|
|
9
|
+
# Usage:
|
|
10
|
+
# lsp-e2e.sh --scenario <name> --evidence-dir <absolute-dir>
|
|
11
|
+
# lsp-e2e.sh --self-test
|
|
12
|
+
|
|
13
|
+
set -uo pipefail
|
|
14
|
+
|
|
15
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
|
|
16
|
+
REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd -P)"
|
|
17
|
+
|
|
18
|
+
SCENARIO=""
|
|
19
|
+
EVIDENCE_DIR=""
|
|
20
|
+
SELF_TEST=0
|
|
21
|
+
SANDBOX_ROOT=""
|
|
22
|
+
OMO_TEST_ROOT=""
|
|
23
|
+
EXPECTED_DAEMON_CLI=""
|
|
24
|
+
EXPECTED_DAEMON_VERSION=""
|
|
25
|
+
OPENCODE_PID=""
|
|
26
|
+
FAKE_PID=""
|
|
27
|
+
SSE_PID=""
|
|
28
|
+
RESULT_STAGE=""
|
|
29
|
+
CLEANUP_RUNNING=0
|
|
30
|
+
NORMAL_CLEANUP_COMPLETE=0
|
|
31
|
+
REAL_HOME="${HOME:-}"
|
|
32
|
+
REAL_OMO_ROOT="${HOME:-}/.omo/lsp-daemon"
|
|
33
|
+
REAL_DB_PATH=""
|
|
34
|
+
REAL_DB_COUNT_BEFORE=""
|
|
35
|
+
REAL_OMO_BEFORE_HASH=""
|
|
36
|
+
HEALTH_READY_SECONDS=30
|
|
37
|
+
HEALTH_CURL_CONNECT_TIMEOUT_SECONDS=1
|
|
38
|
+
HEALTH_CURL_MAX_TIME_SECONDS=1
|
|
39
|
+
SSE_READY_SECONDS=30
|
|
40
|
+
SSE_ATTEMPT_SECONDS=2
|
|
41
|
+
BUILD_LOCK_DIR=""
|
|
42
|
+
SOURCE_PACKAGE_STAMP=""
|
|
43
|
+
SOURCE_PACKAGE_STAMP_CREATED=0
|
|
44
|
+
CANCELLATION_SMOKE_RELATIVE="packages/lsp-daemon/scripts/qa/cancellation-smoke.mjs"
|
|
45
|
+
COMMIT_BARRIER_SMOKE_RELATIVE="packages/lsp-daemon/scripts/qa/commit-barrier-smoke.mjs"
|
|
46
|
+
|
|
47
|
+
log() { printf '[opencode-lsp-e2e] %s\n' "$*" >&2; }
|
|
48
|
+
fail() { log "FAIL: $*"; return 1; }
|
|
49
|
+
|
|
50
|
+
usage() {
|
|
51
|
+
sed -n '2,11p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
parse_args() {
|
|
55
|
+
while [ "$#" -gt 0 ]; do
|
|
56
|
+
case "$1" in
|
|
57
|
+
--scenario)
|
|
58
|
+
[ "$#" -ge 2 ] || { log "--scenario requires a value"; return 2; }
|
|
59
|
+
[ -z "$SCENARIO" ] || { log "--scenario may be provided only once"; return 2; }
|
|
60
|
+
SCENARIO="$2"
|
|
61
|
+
shift 2
|
|
62
|
+
;;
|
|
63
|
+
--evidence-dir)
|
|
64
|
+
[ "$#" -ge 2 ] || { log "--evidence-dir requires a directory"; return 2; }
|
|
65
|
+
[ -z "$EVIDENCE_DIR" ] || { log "--evidence-dir may be provided only once"; return 2; }
|
|
66
|
+
EVIDENCE_DIR="$2"
|
|
67
|
+
shift 2
|
|
68
|
+
;;
|
|
69
|
+
--self-test)
|
|
70
|
+
[ "$SELF_TEST" -eq 0 ] || { log "--self-test may be provided only once"; return 2; }
|
|
71
|
+
SELF_TEST=1
|
|
72
|
+
shift
|
|
73
|
+
;;
|
|
74
|
+
-h|--help)
|
|
75
|
+
usage
|
|
76
|
+
exit 0
|
|
77
|
+
;;
|
|
78
|
+
*)
|
|
79
|
+
log "unknown option: $1"
|
|
80
|
+
return 2
|
|
81
|
+
;;
|
|
82
|
+
esac
|
|
83
|
+
done
|
|
84
|
+
|
|
85
|
+
if [ "$SELF_TEST" -eq 1 ]; then
|
|
86
|
+
if [ -n "$SCENARIO" ] || [ -n "$EVIDENCE_DIR" ]; then
|
|
87
|
+
log "--self-test cannot be combined with normal-mode options"
|
|
88
|
+
return 2
|
|
89
|
+
fi
|
|
90
|
+
return 0
|
|
91
|
+
fi
|
|
92
|
+
|
|
93
|
+
[ -n "$SCENARIO" ] || { log "--scenario is required"; return 2; }
|
|
94
|
+
[ -n "$EVIDENCE_DIR" ] || { log "--evidence-dir is required"; return 2; }
|
|
95
|
+
if ! printf '%s' "$SCENARIO" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$'; then
|
|
96
|
+
log "invalid scenario: $SCENARIO"
|
|
97
|
+
return 2
|
|
98
|
+
fi
|
|
99
|
+
case "$EVIDENCE_DIR" in
|
|
100
|
+
/*) ;;
|
|
101
|
+
*) log "--evidence-dir must be absolute"; return 2 ;;
|
|
102
|
+
esac
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
require_bins() {
|
|
106
|
+
local missing=0 bin
|
|
107
|
+
for bin in "$@"; do
|
|
108
|
+
if ! command -v "$bin" >/dev/null 2>&1; then
|
|
109
|
+
log "missing dependency: $bin"
|
|
110
|
+
missing=1
|
|
111
|
+
fi
|
|
112
|
+
done
|
|
113
|
+
[ "$missing" -eq 0 ]
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
verify_tracked_cancellation_probes() {
|
|
117
|
+
local dependency ignored_evidence_root=".omo""/evidence"
|
|
118
|
+
if grep -Fq "$ignored_evidence_root" "${BASH_SOURCE[0]}"; then
|
|
119
|
+
fail "LSP QA driver references ignored evidence state"
|
|
120
|
+
return 1
|
|
121
|
+
fi
|
|
122
|
+
for dependency in "$CANCELLATION_SMOKE_RELATIVE" "$COMMIT_BARRIER_SMOKE_RELATIVE"; do
|
|
123
|
+
[ -f "$REPO_ROOT/$dependency" ] || { fail "missing cancellation QA dependency: $dependency"; return 1; }
|
|
124
|
+
git -C "$REPO_ROOT" ls-files --error-unmatch -- "$dependency" >/dev/null 2>&1 || {
|
|
125
|
+
fail "cancellation QA dependency is not tracked: $dependency"
|
|
126
|
+
return 1
|
|
127
|
+
}
|
|
128
|
+
done
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
hash_path() {
|
|
132
|
+
node --input-type=module - "$1" <<'NODE'
|
|
133
|
+
import { createHash } from "node:crypto";
|
|
134
|
+
import { lstatSync, readFileSync, readlinkSync, readdirSync } from "node:fs";
|
|
135
|
+
import { basename, join } from "node:path";
|
|
136
|
+
|
|
137
|
+
const target = process.argv[2];
|
|
138
|
+
const hash = createHash("sha256");
|
|
139
|
+
|
|
140
|
+
function visit(path, relative) {
|
|
141
|
+
const stat = lstatSync(path);
|
|
142
|
+
const kind = stat.isDirectory() ? "dir" : stat.isFile() ? "file" : stat.isSymbolicLink() ? "link" : "special";
|
|
143
|
+
hash.update(`${kind}\0${relative}\0${stat.mode & 0o7777}\0`);
|
|
144
|
+
if (kind === "file") hash.update(readFileSync(path));
|
|
145
|
+
if (kind === "link") hash.update(readlinkSync(path));
|
|
146
|
+
if (kind === "dir") {
|
|
147
|
+
for (const name of readdirSync(path).sort()) visit(join(path, name), relative ? `${relative}/${name}` : name);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
visit(target, basename(target));
|
|
153
|
+
process.stdout.write(hash.digest("hex"));
|
|
154
|
+
} catch (error) {
|
|
155
|
+
if (error && error.code === "ENOENT") process.stdout.write("ABSENT");
|
|
156
|
+
else throw error;
|
|
157
|
+
}
|
|
158
|
+
NODE
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
run_bounded() {
|
|
162
|
+
local seconds="$1" output="$2"
|
|
163
|
+
shift 2
|
|
164
|
+
node --input-type=module - "$seconds" "$output" "$@" <<'NODE'
|
|
165
|
+
import { closeSync, openSync } from "node:fs";
|
|
166
|
+
import { spawn } from "node:child_process";
|
|
167
|
+
|
|
168
|
+
const [secondsRaw, output, command, ...args] = process.argv.slice(2);
|
|
169
|
+
const seconds = Number(secondsRaw);
|
|
170
|
+
if (!Number.isFinite(seconds) || seconds <= 0 || !command) process.exit(125);
|
|
171
|
+
const fd = openSync(output, "w");
|
|
172
|
+
const child = spawn(command, args, {
|
|
173
|
+
stdio: ["ignore", fd, fd],
|
|
174
|
+
detached: process.platform !== "win32",
|
|
175
|
+
env: process.env,
|
|
176
|
+
});
|
|
177
|
+
let timedOut = false;
|
|
178
|
+
let forceTimer;
|
|
179
|
+
const timer = setTimeout(() => {
|
|
180
|
+
timedOut = true;
|
|
181
|
+
try {
|
|
182
|
+
if (process.platform !== "win32") process.kill(-child.pid, "SIGTERM");
|
|
183
|
+
else child.kill("SIGTERM");
|
|
184
|
+
} catch {}
|
|
185
|
+
forceTimer = setTimeout(() => {
|
|
186
|
+
try {
|
|
187
|
+
if (process.platform !== "win32") process.kill(-child.pid, "SIGKILL");
|
|
188
|
+
else child.kill("SIGKILL");
|
|
189
|
+
} catch {}
|
|
190
|
+
}, 3000);
|
|
191
|
+
}, seconds * 1000);
|
|
192
|
+
child.on("error", () => {
|
|
193
|
+
clearTimeout(timer);
|
|
194
|
+
if (forceTimer) clearTimeout(forceTimer);
|
|
195
|
+
closeSync(fd);
|
|
196
|
+
process.exit(126);
|
|
197
|
+
});
|
|
198
|
+
child.on("exit", (code, signal) => {
|
|
199
|
+
clearTimeout(timer);
|
|
200
|
+
if (forceTimer) clearTimeout(forceTimer);
|
|
201
|
+
closeSync(fd);
|
|
202
|
+
if (timedOut) process.exit(124);
|
|
203
|
+
if (typeof code === "number") process.exit(code);
|
|
204
|
+
process.exit(signal ? 128 : 1);
|
|
205
|
+
});
|
|
206
|
+
NODE
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
with_shared_build_lock() {
|
|
210
|
+
local command_name="$1" attempts=0 rc
|
|
211
|
+
shift
|
|
212
|
+
BUILD_LOCK_DIR="$REPO_ROOT/.omo/locks/lsp-daemon-build.lock"
|
|
213
|
+
mkdir -p "$(dirname "$BUILD_LOCK_DIR")"
|
|
214
|
+
while ! mkdir "$BUILD_LOCK_DIR" 2>/dev/null; do
|
|
215
|
+
[ "$attempts" -lt 600 ] || { fail "timed out waiting for shared LSP daemon build lock"; return 1; }
|
|
216
|
+
sleep 0.2
|
|
217
|
+
attempts=$((attempts + 1))
|
|
218
|
+
done
|
|
219
|
+
printf 'pid=%s\ncommand=%s\n' "$$" "$command_name" >"$BUILD_LOCK_DIR/owner.txt"
|
|
220
|
+
"$@"
|
|
221
|
+
rc=$?
|
|
222
|
+
rm -rf "$BUILD_LOCK_DIR"
|
|
223
|
+
BUILD_LOCK_DIR=""
|
|
224
|
+
return "$rc"
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
safe_rm_tree() {
|
|
228
|
+
local path="$1" attempt=0
|
|
229
|
+
[ -n "$path" ] || return 0
|
|
230
|
+
case "$path" in
|
|
231
|
+
/var/folders/*/T/oqa-lsp-e2e.*|/tmp/oqa-lsp-e2e.*|/private/tmp/oqa-lsp-e2e.*)
|
|
232
|
+
while [ -e "$path" ] && [ "$attempt" -lt 100 ]; do
|
|
233
|
+
rm -rf "$path" 2>/dev/null || true
|
|
234
|
+
[ ! -e "$path" ] && return 0
|
|
235
|
+
sleep 0.1
|
|
236
|
+
attempt=$((attempt + 1))
|
|
237
|
+
done
|
|
238
|
+
[ ! -e "$path" ] || fail "isolated sandbox remained after bounded cleanup: $path"
|
|
239
|
+
;;
|
|
240
|
+
*)
|
|
241
|
+
fail "refusing to remove unexpected sandbox path: $path"
|
|
242
|
+
;;
|
|
243
|
+
esac
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
process_command() {
|
|
247
|
+
/bin/ps -p "$1" -o command= 2>/dev/null || true
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
wait_for_exit() {
|
|
251
|
+
local pid="$1" attempts=0
|
|
252
|
+
while [ "$attempts" -lt 50 ]; do
|
|
253
|
+
kill -0 "$pid" 2>/dev/null || return 0
|
|
254
|
+
sleep 0.1
|
|
255
|
+
attempts=$((attempts + 1))
|
|
256
|
+
done
|
|
257
|
+
return 1
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
stop_verified_pid() {
|
|
261
|
+
local pid="$1" expected="$2" label="$3" command
|
|
262
|
+
[ -n "$pid" ] || return 0
|
|
263
|
+
kill -0 "$pid" 2>/dev/null || return 0
|
|
264
|
+
command="$(process_command "$pid")"
|
|
265
|
+
case "$command" in
|
|
266
|
+
*"$expected"*) ;;
|
|
267
|
+
*) fail "refusing to stop unverified $label pid $pid"; return 1 ;;
|
|
268
|
+
esac
|
|
269
|
+
kill "$pid" 2>/dev/null || true
|
|
270
|
+
if ! wait_for_exit "$pid"; then
|
|
271
|
+
command="$(process_command "$pid")"
|
|
272
|
+
case "$command" in
|
|
273
|
+
*"$expected"*) kill -9 "$pid" 2>/dev/null || true ;;
|
|
274
|
+
*) fail "$label pid $pid changed identity during cleanup"; return 1 ;;
|
|
275
|
+
esac
|
|
276
|
+
wait_for_exit "$pid" || { fail "$label pid $pid survived cleanup"; return 1; }
|
|
277
|
+
fi
|
|
278
|
+
wait "$pid" 2>/dev/null || true
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
find_daemon_pid_file() {
|
|
282
|
+
[ -n "$OMO_TEST_ROOT" ] && [ -d "$OMO_TEST_ROOT" ] || return 0
|
|
283
|
+
find "$OMO_TEST_ROOT" -type f -name daemon.pid -print 2>/dev/null | sort | head -1
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
stop_known_daemon() {
|
|
287
|
+
local pid_file pid command
|
|
288
|
+
pid_file="$(find_daemon_pid_file)"
|
|
289
|
+
[ -n "$pid_file" ] || return 0
|
|
290
|
+
pid="$(tr -d '[:space:]' <"$pid_file" 2>/dev/null || true)"
|
|
291
|
+
case "$pid" in
|
|
292
|
+
''|*[!0-9]*) fail "daemon pid file is malformed: $pid_file"; return 1 ;;
|
|
293
|
+
esac
|
|
294
|
+
kill -0 "$pid" 2>/dev/null || return 0
|
|
295
|
+
command="$(process_command "$pid")"
|
|
296
|
+
case "$command" in
|
|
297
|
+
*"$EXPECTED_DAEMON_CLI"*" daemon"*) ;;
|
|
298
|
+
*) fail "refusing to stop unverified daemon pid $pid"; return 1 ;;
|
|
299
|
+
esac
|
|
300
|
+
kill "$pid" 2>/dev/null || true
|
|
301
|
+
if ! wait_for_exit "$pid"; then
|
|
302
|
+
command="$(process_command "$pid")"
|
|
303
|
+
case "$command" in
|
|
304
|
+
*"$EXPECTED_DAEMON_CLI"*" daemon"*) kill -9 "$pid" 2>/dev/null || true ;;
|
|
305
|
+
*) fail "daemon pid $pid changed identity during cleanup"; return 1 ;;
|
|
306
|
+
esac
|
|
307
|
+
wait_for_exit "$pid" || { fail "daemon pid $pid survived cleanup"; return 1; }
|
|
308
|
+
fi
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
owned_sandbox_pids() {
|
|
312
|
+
[ -n "$SANDBOX_ROOT" ] || return 0
|
|
313
|
+
/bin/ps ax -o pid=,command= 2>/dev/null | while read -r pid command; do
|
|
314
|
+
case "$command" in
|
|
315
|
+
*"$SANDBOX_ROOT"*) [ "$pid" = "$$" ] || printf '%s\n' "$pid" ;;
|
|
316
|
+
esac
|
|
317
|
+
done
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
stop_owned_sandbox_processes() {
|
|
321
|
+
local pids pid command
|
|
322
|
+
pids="$(owned_sandbox_pids)"
|
|
323
|
+
[ -n "$pids" ] || return 0
|
|
324
|
+
for pid in $pids; do
|
|
325
|
+
kill -0 "$pid" 2>/dev/null || continue
|
|
326
|
+
command="$(process_command "$pid")"
|
|
327
|
+
case "$command" in
|
|
328
|
+
*"$SANDBOX_ROOT"*) ;;
|
|
329
|
+
*) fail "sandbox process $pid changed identity before cleanup"; return 1 ;;
|
|
330
|
+
esac
|
|
331
|
+
kill "$pid" 2>/dev/null || true
|
|
332
|
+
if ! wait_for_exit "$pid"; then
|
|
333
|
+
command="$(process_command "$pid")"
|
|
334
|
+
case "$command" in
|
|
335
|
+
*"$SANDBOX_ROOT"*) kill -9 "$pid" 2>/dev/null || true ;;
|
|
336
|
+
*) fail "sandbox process $pid changed identity during cleanup"; return 1 ;;
|
|
337
|
+
esac
|
|
338
|
+
wait_for_exit "$pid" || { fail "sandbox process $pid survived cleanup"; return 1; }
|
|
339
|
+
fi
|
|
340
|
+
if [ -n "$EVIDENCE_DIR" ] && [ -d "$EVIDENCE_DIR" ]; then
|
|
341
|
+
printf 'sandbox_process_pid=%s alive_after=no\n' "$pid" >>"$EVIDENCE_DIR/owned-process-cleanup.txt"
|
|
342
|
+
fi
|
|
343
|
+
done
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
stop_owned_real_daemon_leak() {
|
|
347
|
+
[ "$REAL_OMO_BEFORE_HASH" = "ABSENT" ] || return 0
|
|
348
|
+
[ "$OMO_TEST_ROOT" != "$REAL_OMO_ROOT" ] || return 0
|
|
349
|
+
[ -d "$REAL_OMO_ROOT" ] || return 0
|
|
350
|
+
local pid_file pid command state_dir
|
|
351
|
+
pid_file="$(find "$REAL_OMO_ROOT" -type f -name daemon.pid -print 2>/dev/null | sort | head -1)"
|
|
352
|
+
if [ -n "$pid_file" ]; then
|
|
353
|
+
pid="$(tr -d '[:space:]' <"$pid_file" 2>/dev/null || true)"
|
|
354
|
+
case "$pid" in
|
|
355
|
+
''|*[!0-9]*) fail "real-root leak pid file is malformed"; return 1 ;;
|
|
356
|
+
esac
|
|
357
|
+
command="$(process_command "$pid")"
|
|
358
|
+
case "$command" in
|
|
359
|
+
*"$EXPECTED_DAEMON_CLI"*" daemon"*) ;;
|
|
360
|
+
*) fail "real OMO root changed by an unverified process; preserving it"; return 1 ;;
|
|
361
|
+
esac
|
|
362
|
+
kill "$pid" 2>/dev/null || true
|
|
363
|
+
wait_for_exit "$pid" || { fail "own leaked daemon did not stop"; return 1; }
|
|
364
|
+
fi
|
|
365
|
+
if find "$REAL_OMO_ROOT" -type f \( -name daemon.pid -o -name daemon.endpoint \) -print 2>/dev/null | grep -q .; then
|
|
366
|
+
fail "real OMO root still contains live markers after own-daemon cleanup"
|
|
367
|
+
return 1
|
|
368
|
+
fi
|
|
369
|
+
find "$REAL_OMO_ROOT" -type f -name daemon.log -delete 2>/dev/null || true
|
|
370
|
+
while IFS= read -r state_dir; do rmdir "$state_dir" 2>/dev/null || true; done < <(find "$REAL_OMO_ROOT" -depth -type d -print 2>/dev/null)
|
|
371
|
+
[ ! -e "$REAL_OMO_ROOT" ] || { fail "real OMO root could not be restored to ABSENT"; return 1; }
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
cleanup_all() {
|
|
375
|
+
local cleanup_rc=0
|
|
376
|
+
[ "$CLEANUP_RUNNING" -eq 0 ] || return 0
|
|
377
|
+
CLEANUP_RUNNING=1
|
|
378
|
+
stop_verified_pid "$SSE_PID" "curl" "SSE watcher" || cleanup_rc=1
|
|
379
|
+
SSE_PID=""
|
|
380
|
+
stop_verified_pid "$OPENCODE_PID" "serve" "OpenCode server" || cleanup_rc=1
|
|
381
|
+
OPENCODE_PID=""
|
|
382
|
+
stop_known_daemon || cleanup_rc=1
|
|
383
|
+
stop_verified_pid "$FAKE_PID" "$SANDBOX_ROOT/fake-provider.mjs" "fake provider" || cleanup_rc=1
|
|
384
|
+
FAKE_PID=""
|
|
385
|
+
stop_owned_sandbox_processes || cleanup_rc=1
|
|
386
|
+
stop_owned_real_daemon_leak || cleanup_rc=1
|
|
387
|
+
if [ -n "$BUILD_LOCK_DIR" ]; then
|
|
388
|
+
rm -rf "$BUILD_LOCK_DIR" 2>/dev/null || cleanup_rc=1
|
|
389
|
+
BUILD_LOCK_DIR=""
|
|
390
|
+
fi
|
|
391
|
+
if [ "$SOURCE_PACKAGE_STAMP_CREATED" -eq 1 ] && [ -n "$SOURCE_PACKAGE_STAMP" ]; then
|
|
392
|
+
rm -f "$SOURCE_PACKAGE_STAMP" 2>/dev/null || cleanup_rc=1
|
|
393
|
+
SOURCE_PACKAGE_STAMP=""
|
|
394
|
+
SOURCE_PACKAGE_STAMP_CREATED=0
|
|
395
|
+
fi
|
|
396
|
+
[ -n "$RESULT_STAGE" ] && rm -f "$RESULT_STAGE" 2>/dev/null || true
|
|
397
|
+
if [ -n "$EVIDENCE_DIR" ] && [ -d "$EVIDENCE_DIR" ]; then
|
|
398
|
+
find "$EVIDENCE_DIR" -maxdepth 1 -type f -name '.result.json.*' -delete 2>/dev/null || true
|
|
399
|
+
fi
|
|
400
|
+
if [ -n "$SANDBOX_ROOT" ]; then
|
|
401
|
+
safe_rm_tree "$SANDBOX_ROOT" || cleanup_rc=1
|
|
402
|
+
SANDBOX_ROOT=""
|
|
403
|
+
fi
|
|
404
|
+
CLEANUP_RUNNING=0
|
|
405
|
+
return "$cleanup_rc"
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
on_exit() {
|
|
409
|
+
local rc=$?
|
|
410
|
+
trap - EXIT INT TERM HUP
|
|
411
|
+
if [ "$NORMAL_CLEANUP_COMPLETE" -eq 0 ]; then
|
|
412
|
+
cleanup_all || rc=1
|
|
413
|
+
fi
|
|
414
|
+
if [ "$rc" -ne 0 ] && [ -n "$EVIDENCE_DIR" ] && [ -d "$EVIDENCE_DIR" ]; then
|
|
415
|
+
rm -f "$EVIDENCE_DIR/result.json" 2>/dev/null || true
|
|
416
|
+
fi
|
|
417
|
+
exit "$rc"
|
|
418
|
+
}
|
|
419
|
+
trap on_exit EXIT
|
|
420
|
+
trap 'exit 130' INT
|
|
421
|
+
trap 'exit 143' TERM
|
|
422
|
+
trap 'exit 129' HUP
|
|
423
|
+
|
|
424
|
+
prepare_evidence() {
|
|
425
|
+
[ ! -L "$EVIDENCE_DIR" ] || { fail "evidence directory must not be a symlink"; return 1; }
|
|
426
|
+
mkdir -p "$EVIDENCE_DIR" || return 1
|
|
427
|
+
EVIDENCE_DIR="$(cd "$EVIDENCE_DIR" && pwd -P)"
|
|
428
|
+
rm -f "$EVIDENCE_DIR/result.json"
|
|
429
|
+
find "$EVIDENCE_DIR" -maxdepth 1 -type f -name '.result.json.*' -delete 2>/dev/null || true
|
|
430
|
+
printf 'bash %s --scenario %s --evidence-dir %s\n' \
|
|
431
|
+
"${BASH_SOURCE[0]}" "$SCENARIO" "$EVIDENCE_DIR" >"$EVIDENCE_DIR/invocation.txt"
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
write_path_contract_probe() {
|
|
435
|
+
local probe_dir="$1" output="$2" script="$SANDBOX_ROOT/path-contract-probe.mjs"
|
|
436
|
+
mkdir -p "$probe_dir"
|
|
437
|
+
cat >"$script" <<'NODE'
|
|
438
|
+
import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
|
439
|
+
import { dirname, join, resolve } from "node:path";
|
|
440
|
+
import { pathToFileURL } from "node:url";
|
|
441
|
+
|
|
442
|
+
const repoRoot = process.env.REPO_ROOT;
|
|
443
|
+
const base = process.env.PROBE_BASE;
|
|
444
|
+
const output = process.env.PROBE_OUTPUT;
|
|
445
|
+
if (!repoRoot || !base || !output) throw new Error("missing probe environment");
|
|
446
|
+
const modulePath = join(repoRoot, "packages/lsp-daemon/dist/index.js");
|
|
447
|
+
const daemon = await import(pathToFileURL(modulePath).href + `?qa=${Date.now()}`);
|
|
448
|
+
const cliPath = join(repoRoot, "packages/lsp-daemon/dist/cli.js");
|
|
449
|
+
const packagedVersion = JSON.parse(readFileSync(join(repoRoot, "packages/lsp-daemon/dist/package.json"), "utf8")).version;
|
|
450
|
+
const envNameValues = [daemon.OMO_LSP_DAEMON_CLI, daemon.OMO_LSP_DAEMON_DIR, daemon.OMO_LSP_DAEMON_VERSION].sort();
|
|
451
|
+
|
|
452
|
+
function capture(run) {
|
|
453
|
+
try {
|
|
454
|
+
run();
|
|
455
|
+
return { threw: false };
|
|
456
|
+
} catch (error) {
|
|
457
|
+
return { threw: true, name: error?.name, code: error?.code, reason: error?.reason, message: error?.message };
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
rmSync(base, { recursive: true, force: true });
|
|
462
|
+
const defaultPaths = daemon.daemonPaths({ [daemon.OMO_LSP_DAEMON_DIR]: base });
|
|
463
|
+
const pairedVersion = "qa.1+pair";
|
|
464
|
+
const pairedPaths = daemon.daemonPaths({
|
|
465
|
+
[daemon.OMO_LSP_DAEMON_DIR]: base,
|
|
466
|
+
[daemon.OMO_LSP_DAEMON_CLI]: cliPath,
|
|
467
|
+
[daemon.OMO_LSP_DAEMON_VERSION]: pairedVersion,
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
const singletonRoot = join(dirname(base), "singleton-state");
|
|
471
|
+
rmSync(singletonRoot, { recursive: true, force: true });
|
|
472
|
+
const singletonCli = capture(() => daemon.daemonPaths({
|
|
473
|
+
[daemon.OMO_LSP_DAEMON_DIR]: singletonRoot,
|
|
474
|
+
[daemon.OMO_LSP_DAEMON_CLI]: cliPath,
|
|
475
|
+
}));
|
|
476
|
+
const singletonVersion = capture(() => daemon.daemonPaths({
|
|
477
|
+
[daemon.OMO_LSP_DAEMON_DIR]: singletonRoot,
|
|
478
|
+
[daemon.OMO_LSP_DAEMON_VERSION]: packagedVersion,
|
|
479
|
+
}));
|
|
480
|
+
|
|
481
|
+
const relativeBase = capture(() => daemon.daemonPaths({ [daemon.OMO_LSP_DAEMON_DIR]: "relative/state" }));
|
|
482
|
+
const relativeCli = capture(() => daemon.daemonPaths({
|
|
483
|
+
[daemon.OMO_LSP_DAEMON_DIR]: join(dirname(base), "relative-cli-state"),
|
|
484
|
+
[daemon.OMO_LSP_DAEMON_CLI]: "relative/cli.js",
|
|
485
|
+
[daemon.OMO_LSP_DAEMON_VERSION]: packagedVersion,
|
|
486
|
+
}));
|
|
487
|
+
const nonFileCli = join(dirname(base), "not-a-file");
|
|
488
|
+
mkdirSync(nonFileCli, { recursive: true });
|
|
489
|
+
const missingCli = capture(() => daemon.daemonPaths({
|
|
490
|
+
[daemon.OMO_LSP_DAEMON_DIR]: join(dirname(base), "missing-cli-state"),
|
|
491
|
+
[daemon.OMO_LSP_DAEMON_CLI]: join(dirname(base), "missing-cli.js"),
|
|
492
|
+
[daemon.OMO_LSP_DAEMON_VERSION]: packagedVersion,
|
|
493
|
+
}));
|
|
494
|
+
const directoryCli = capture(() => daemon.daemonPaths({
|
|
495
|
+
[daemon.OMO_LSP_DAEMON_DIR]: join(dirname(base), "directory-cli-state"),
|
|
496
|
+
[daemon.OMO_LSP_DAEMON_CLI]: nonFileCli,
|
|
497
|
+
[daemon.OMO_LSP_DAEMON_VERSION]: packagedVersion,
|
|
498
|
+
}));
|
|
499
|
+
|
|
500
|
+
const badVersions = ["../escape", "a/b", "a\\b", ".hidden", "bad value", "", "a".repeat(129)];
|
|
501
|
+
const versionFailures = badVersions.map((version, index) => {
|
|
502
|
+
const stateRoot = join(dirname(base), `bad-version-${index}`);
|
|
503
|
+
rmSync(stateRoot, { recursive: true, force: true });
|
|
504
|
+
return {
|
|
505
|
+
version,
|
|
506
|
+
error: capture(() => daemon.daemonPaths({
|
|
507
|
+
[daemon.OMO_LSP_DAEMON_DIR]: stateRoot,
|
|
508
|
+
[daemon.OMO_LSP_DAEMON_CLI]: cliPath,
|
|
509
|
+
[daemon.OMO_LSP_DAEMON_VERSION]: version,
|
|
510
|
+
})),
|
|
511
|
+
stateCreated: existsSync(stateRoot),
|
|
512
|
+
};
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
const oldPrefix = "CODEX" + "_LSP_";
|
|
516
|
+
const neutralPaths = daemon.daemonPaths({
|
|
517
|
+
CODEX_HOME: join(dirname(base), "ignored-codex-home"),
|
|
518
|
+
PLUGIN_DATA: join(dirname(base), "ignored-plugin-data"),
|
|
519
|
+
[`${oldPrefix}DAEMON_DIR`]: join(dirname(base), "ignored-legacy-dir"),
|
|
520
|
+
[`${oldPrefix}DAEMON_CLI`]: join(dirname(base), "ignored-legacy-cli.js"),
|
|
521
|
+
[`${oldPrefix}DAEMON_VERSION`]: "999.999.999",
|
|
522
|
+
});
|
|
523
|
+
const neutralBase = resolve(process.env.HOME, ".omo", "lsp-daemon");
|
|
524
|
+
|
|
525
|
+
const assertions = {
|
|
526
|
+
exactThreeOmoEnvironmentNames: JSON.stringify(envNameValues) === JSON.stringify([
|
|
527
|
+
"OMO_LSP_DAEMON_CLI",
|
|
528
|
+
"OMO_LSP_DAEMON_DIR",
|
|
529
|
+
"OMO_LSP_DAEMON_VERSION",
|
|
530
|
+
]),
|
|
531
|
+
defaultBaseResolved: dirname(defaultPaths.dir) === resolve(base),
|
|
532
|
+
defaultVersionStamped: defaultPaths.version === packagedVersion,
|
|
533
|
+
defaultCliPackaged: defaultPaths.cliPath === cliPath,
|
|
534
|
+
pairedOverridePreserved: pairedPaths.cliPath === cliPath && pairedPaths.version === pairedVersion,
|
|
535
|
+
singletonCliRejectedBeforeState: singletonCli.code === "invalid_runtime_override" && !existsSync(singletonRoot),
|
|
536
|
+
singletonVersionRejectedBeforeState: singletonVersion.code === "invalid_runtime_override" && !existsSync(singletonRoot),
|
|
537
|
+
relativeBaseRejected: relativeBase.code === "invalid_daemon_directory",
|
|
538
|
+
relativeCliRejected: relativeCli.reason === "cli_must_be_absolute",
|
|
539
|
+
missingCliRejected: missingCli.reason === "cli_not_found",
|
|
540
|
+
nonFileCliRejected: directoryCli.reason === "cli_not_file",
|
|
541
|
+
malformedVersionsRejectedBeforeState: versionFailures.every((entry) => entry.error.code === "invalid_daemon_version" && entry.stateCreated === false),
|
|
542
|
+
oldNamesAndHarnessHomesIgnored: dirname(neutralPaths.dir) === neutralBase && neutralPaths.version === packagedVersion,
|
|
543
|
+
};
|
|
544
|
+
if (!Object.values(assertions).every(Boolean)) {
|
|
545
|
+
console.error(JSON.stringify({ assertions, singletonCli, singletonVersion, versionFailures, neutralPaths }, null, 2));
|
|
546
|
+
process.exit(1);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
await import("node:fs/promises").then(({ writeFile }) => writeFile(output, JSON.stringify({
|
|
550
|
+
assertions,
|
|
551
|
+
environmentNames: envNameValues,
|
|
552
|
+
default: defaultPaths,
|
|
553
|
+
paired: pairedPaths,
|
|
554
|
+
neutral: neutralPaths,
|
|
555
|
+
failures: { singletonCli, singletonVersion, relativeBase, relativeCli, missingCli, directoryCli, versionFailures },
|
|
556
|
+
}, null, 2) + "\n"));
|
|
557
|
+
NODE
|
|
558
|
+
REPO_ROOT="$REPO_ROOT" PROBE_BASE="$probe_dir/state/../daemon" PROBE_OUTPUT="$output" \
|
|
559
|
+
run_bounded 30 "$EVIDENCE_DIR/path-contract-probe.log" node "$script"
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
write_workspace_edit_fixture() {
|
|
563
|
+
local project_dir="$1"
|
|
564
|
+
local scenario_path="$EVIDENCE_DIR/rename-scenario.json"
|
|
565
|
+
local events_path="$EVIDENCE_DIR/rename-server-events.jsonl"
|
|
566
|
+
local metadata_path="$EVIDENCE_DIR/rename-fixture.json"
|
|
567
|
+
local project_config_path="$project_dir/.opencode/lsp.json"
|
|
568
|
+
local user_config_path="$XDG_CONFIG_HOME/opencode/lsp.json"
|
|
569
|
+
local codex_config_path="$HOME/.codex/lsp-client.json"
|
|
570
|
+
mkdir -p "$project_dir" "$(dirname "$project_config_path")" "$(dirname "$user_config_path")" "$(dirname "$codex_config_path")"
|
|
571
|
+
node --input-type=module - "$REPO_ROOT" "$project_dir" "$scenario_path" "$events_path" "$metadata_path" "$project_config_path" "$user_config_path" "$codex_config_path" <<'NODE'
|
|
572
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
573
|
+
import { dirname, join } from "node:path";
|
|
574
|
+
import { pathToFileURL } from "node:url";
|
|
575
|
+
|
|
576
|
+
const [repoRoot, projectDir, scenarioPath, eventsPath, metadataPath, projectConfigPath, userConfigPath, codexConfigPath] = process.argv.slice(2);
|
|
577
|
+
const sourcePath = join(projectDir, "source.ts");
|
|
578
|
+
const fixturePath = join(repoRoot, "packages/lsp-core/src/lsp/fixtures/workspace-edit-server.mjs");
|
|
579
|
+
mkdirSync(dirname(projectConfigPath), { recursive: true });
|
|
580
|
+
mkdirSync(dirname(userConfigPath), { recursive: true });
|
|
581
|
+
mkdirSync(dirname(codexConfigPath), { recursive: true });
|
|
582
|
+
writeFileSync(sourcePath, "const before = 1;\n", "utf8");
|
|
583
|
+
writeFileSync(eventsPath, "", "utf8");
|
|
584
|
+
const sourceUri = pathToFileURL(sourcePath).href;
|
|
585
|
+
const scenario = {
|
|
586
|
+
renameSteps: [
|
|
587
|
+
{
|
|
588
|
+
applyEdit: {
|
|
589
|
+
documentChanges: [
|
|
590
|
+
{
|
|
591
|
+
textDocument: { uri: sourceUri, version: 1 },
|
|
592
|
+
edits: [
|
|
593
|
+
{
|
|
594
|
+
range: {
|
|
595
|
+
start: { line: 0, character: 6 },
|
|
596
|
+
end: { line: 0, character: 12 },
|
|
597
|
+
},
|
|
598
|
+
newText: "after",
|
|
599
|
+
},
|
|
600
|
+
],
|
|
601
|
+
},
|
|
602
|
+
],
|
|
603
|
+
},
|
|
604
|
+
renameResult: "same",
|
|
605
|
+
},
|
|
606
|
+
],
|
|
607
|
+
diagnostics: [
|
|
608
|
+
{
|
|
609
|
+
range: {
|
|
610
|
+
start: { line: 0, character: 0 },
|
|
611
|
+
end: { line: 0, character: 1 },
|
|
612
|
+
},
|
|
613
|
+
message: "todo3-fresh",
|
|
614
|
+
},
|
|
615
|
+
],
|
|
616
|
+
};
|
|
617
|
+
const userConfig = {
|
|
618
|
+
lsp: {
|
|
619
|
+
typescript: {
|
|
620
|
+
command: [process.execPath, fixturePath, scenarioPath, eventsPath],
|
|
621
|
+
extensions: [".ts"],
|
|
622
|
+
priority: 100,
|
|
623
|
+
},
|
|
624
|
+
},
|
|
625
|
+
};
|
|
626
|
+
writeFileSync(scenarioPath, JSON.stringify(scenario, null, 2) + "\n");
|
|
627
|
+
writeFileSync(projectConfigPath, `${JSON.stringify({ lsp: {} }, null, 2)}\n`);
|
|
628
|
+
writeFileSync(userConfigPath, JSON.stringify(userConfig, null, 2) + "\n");
|
|
629
|
+
writeFileSync(codexConfigPath, JSON.stringify(userConfig, null, 2) + "\n");
|
|
630
|
+
writeFileSync(
|
|
631
|
+
metadataPath,
|
|
632
|
+
JSON.stringify(
|
|
633
|
+
{
|
|
634
|
+
sourcePath,
|
|
635
|
+
sourceUri,
|
|
636
|
+
scenarioPath,
|
|
637
|
+
eventsPath,
|
|
638
|
+
projectConfigPath,
|
|
639
|
+
userConfigPath,
|
|
640
|
+
codexConfigPath,
|
|
641
|
+
},
|
|
642
|
+
null,
|
|
643
|
+
2,
|
|
644
|
+
) + "\n",
|
|
645
|
+
);
|
|
646
|
+
NODE
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
run_workspace_edit_contract_probe() {
|
|
650
|
+
run_bounded 60 "$EVIDENCE_DIR/workspace-edit-contract-probe.log" \
|
|
651
|
+
bun "$REPO_ROOT/packages/lsp-core/src/lsp/fixtures/workspace-edit-contract-probe.ts" \
|
|
652
|
+
"$EVIDENCE_DIR/workspace-edit-contract.json"
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
write_diagnostics_freshness_fixture() {
|
|
656
|
+
local project_dir="$1"
|
|
657
|
+
local scenario_path="$EVIDENCE_DIR/diagnostics-freshness-scenario.json"
|
|
658
|
+
local events_path="$EVIDENCE_DIR/diagnostics-freshness-server-events.jsonl"
|
|
659
|
+
local metadata_path="$EVIDENCE_DIR/diagnostics-freshness-fixture.json"
|
|
660
|
+
local project_config_path="$project_dir/.opencode/lsp.json"
|
|
661
|
+
local user_config_path="$XDG_CONFIG_HOME/opencode/lsp.json"
|
|
662
|
+
local codex_config_path="$HOME/.codex/lsp-client.json"
|
|
663
|
+
mkdir -p "$project_dir" "$(dirname "$project_config_path")" "$(dirname "$user_config_path")" "$(dirname "$codex_config_path")"
|
|
664
|
+
node --input-type=module - "$REPO_ROOT" "$project_dir" "$scenario_path" "$events_path" "$metadata_path" "$project_config_path" "$user_config_path" "$codex_config_path" <<'NODE'
|
|
665
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
666
|
+
import { dirname, join } from "node:path";
|
|
667
|
+
|
|
668
|
+
const [repoRoot, projectDir, scenarioPath, eventsPath, metadataPath, projectConfigPath, userConfigPath, codexConfigPath] = process.argv.slice(2);
|
|
669
|
+
const sourcePath = join(projectDir, "source.ts");
|
|
670
|
+
const fixturePath = join(repoRoot, "packages/lsp-core/src/lsp/fixtures/workspace-edit-server.mjs");
|
|
671
|
+
mkdirSync(dirname(projectConfigPath), { recursive: true });
|
|
672
|
+
mkdirSync(dirname(userConfigPath), { recursive: true });
|
|
673
|
+
mkdirSync(dirname(codexConfigPath), { recursive: true });
|
|
674
|
+
writeFileSync(sourcePath, "const before = 1;\n", "utf8");
|
|
675
|
+
writeFileSync(eventsPath, "", "utf8");
|
|
676
|
+
const scenario = {
|
|
677
|
+
publishDiagnostics: [
|
|
678
|
+
{
|
|
679
|
+
trigger: "didOpen",
|
|
680
|
+
version: 1,
|
|
681
|
+
diagnostics: [
|
|
682
|
+
{
|
|
683
|
+
range: {
|
|
684
|
+
start: { line: 0, character: 0 },
|
|
685
|
+
end: { line: 0, character: 1 },
|
|
686
|
+
},
|
|
687
|
+
message: "exact-current",
|
|
688
|
+
},
|
|
689
|
+
],
|
|
690
|
+
},
|
|
691
|
+
],
|
|
692
|
+
diagnosticResponses: [
|
|
693
|
+
{
|
|
694
|
+
report: {
|
|
695
|
+
items: [
|
|
696
|
+
{
|
|
697
|
+
range: {
|
|
698
|
+
start: { line: 0, character: 0 },
|
|
699
|
+
end: { line: 0, character: 1 },
|
|
700
|
+
},
|
|
701
|
+
message: "exact-current",
|
|
702
|
+
},
|
|
703
|
+
],
|
|
704
|
+
},
|
|
705
|
+
},
|
|
706
|
+
],
|
|
707
|
+
};
|
|
708
|
+
const userConfig = {
|
|
709
|
+
lsp: {
|
|
710
|
+
typescript: {
|
|
711
|
+
command: [process.execPath, fixturePath, scenarioPath, eventsPath],
|
|
712
|
+
extensions: [".ts"],
|
|
713
|
+
priority: 100,
|
|
714
|
+
},
|
|
715
|
+
},
|
|
716
|
+
};
|
|
717
|
+
writeFileSync(scenarioPath, JSON.stringify(scenario, null, 2) + "\n");
|
|
718
|
+
writeFileSync(projectConfigPath, `${JSON.stringify({ lsp: {} }, null, 2)}\n`);
|
|
719
|
+
writeFileSync(userConfigPath, JSON.stringify(userConfig, null, 2) + "\n");
|
|
720
|
+
writeFileSync(codexConfigPath, JSON.stringify(userConfig, null, 2) + "\n");
|
|
721
|
+
writeFileSync(
|
|
722
|
+
metadataPath,
|
|
723
|
+
JSON.stringify(
|
|
724
|
+
{
|
|
725
|
+
sourcePath,
|
|
726
|
+
scenarioPath,
|
|
727
|
+
eventsPath,
|
|
728
|
+
projectConfigPath,
|
|
729
|
+
userConfigPath,
|
|
730
|
+
codexConfigPath,
|
|
731
|
+
},
|
|
732
|
+
null,
|
|
733
|
+
2,
|
|
734
|
+
) + "\n",
|
|
735
|
+
);
|
|
736
|
+
NODE
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
run_diagnostics_freshness_contract_probe() {
|
|
740
|
+
run_bounded 60 "$EVIDENCE_DIR/diagnostics-freshness-contract-probe.log" \
|
|
741
|
+
bun "$REPO_ROOT/packages/lsp-core/src/lsp/fixtures/diagnostics-freshness-contract-probe.ts" \
|
|
742
|
+
"$EVIDENCE_DIR/diagnostics-freshness-contract.json"
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
run_post_edit_contract_probe() {
|
|
746
|
+
local script="$EVIDENCE_DIR/post-edit-contract-probe.mjs"
|
|
747
|
+
cat >"$script" <<'NODE'
|
|
748
|
+
import { mkdirSync, realpathSync, writeFileSync } from "node:fs";
|
|
749
|
+
import { delimiter, join, resolve } from "node:path";
|
|
750
|
+
import { pathToFileURL } from "node:url";
|
|
751
|
+
|
|
752
|
+
const [repoRoot, output, rawProjectDir, rawHomeDir] = process.argv.slice(2);
|
|
753
|
+
if (!repoRoot || !output || !rawProjectDir || !rawHomeDir) throw new Error("missing post-edit probe arguments");
|
|
754
|
+
mkdirSync(rawProjectDir, { recursive: true });
|
|
755
|
+
mkdirSync(rawHomeDir, { recursive: true });
|
|
756
|
+
const projectDir = realpathSync(rawProjectDir);
|
|
757
|
+
const homeDir = realpathSync(rawHomeDir);
|
|
758
|
+
const core = await import(pathToFileURL(join(repoRoot, "packages/lsp-core/src/index.ts")).href);
|
|
759
|
+
const daemonClient = await import(pathToFileURL(join(repoRoot, "packages/lsp-daemon/src/daemon-client.ts")).href);
|
|
760
|
+
const openCodeMcp = await import(pathToFileURL(join(repoRoot, "packages/omo-opencode/src/mcp/lsp.ts")).href);
|
|
761
|
+
|
|
762
|
+
const explicitTranslator = core.createStandaloneMcpRequestContext({
|
|
763
|
+
cwd: projectDir,
|
|
764
|
+
homeDir,
|
|
765
|
+
env: {
|
|
766
|
+
LSP_TOOLS_MCP_PROJECT_CONFIG: [
|
|
767
|
+
join(projectDir, ".opencode", "lsp.json"),
|
|
768
|
+
"",
|
|
769
|
+
join(projectDir, ".omo", "lsp.json"),
|
|
770
|
+
join(projectDir, ".omo", "lsp-client.json"),
|
|
771
|
+
].join(delimiter),
|
|
772
|
+
LSP_TOOLS_MCP_USER_CONFIG: join(homeDir, ".config", "opencode", "lsp.json"),
|
|
773
|
+
LSP_TOOLS_MCP_INSTALL_DECISIONS: join(homeDir, ".config", "opencode", "lsp-install-decisions.json"),
|
|
774
|
+
},
|
|
775
|
+
});
|
|
776
|
+
const defaultTranslator = core.createStandaloneMcpRequestContext({ cwd: projectDir, homeDir, env: {} });
|
|
777
|
+
const openCodeMcpConfig = openCodeMcp.createLspMcpConfig({
|
|
778
|
+
cwd: projectDir,
|
|
779
|
+
moduleUrl: pathToFileURL(join(repoRoot, "packages/omo-opencode/src/mcp/lsp.ts")).href,
|
|
780
|
+
exists: () => false,
|
|
781
|
+
resolveExecutable: (commandName) => ({ command: commandName === "node" ? process.execPath : commandName, available: true }),
|
|
782
|
+
});
|
|
783
|
+
const openCodeConfigRoot = resolve(process.env.XDG_CONFIG_HOME ?? join(process.env.HOME ?? homeDir, ".config"), "opencode");
|
|
784
|
+
|
|
785
|
+
const previousCwd = process.cwd();
|
|
786
|
+
process.chdir(projectDir);
|
|
787
|
+
const directContext = daemonClient.currentRequestContext({
|
|
788
|
+
HOME: homeDir,
|
|
789
|
+
LSP_TOOLS_MCP_PROJECT_CONFIG: join(projectDir, ".opencode", "lsp.json"),
|
|
790
|
+
LSP_TOOLS_MCP_USER_CONFIG: join(homeDir, ".config", "opencode", "lsp.json"),
|
|
791
|
+
LSP_TOOLS_MCP_INSTALL_DECISIONS: join(homeDir, ".config", "opencode", "lsp-install-decisions.json"),
|
|
792
|
+
});
|
|
793
|
+
process.chdir(previousCwd);
|
|
794
|
+
|
|
795
|
+
let active = 0;
|
|
796
|
+
let maxActive = 0;
|
|
797
|
+
const calls = [];
|
|
798
|
+
const responses = new Map([
|
|
799
|
+
["a.ts", "diagnostic for a.ts"],
|
|
800
|
+
["b.ts", "No diagnostics found"],
|
|
801
|
+
["c.ts", "diagnostic for c.ts"],
|
|
802
|
+
["d.foo", "No LSP server configured for extension: .foo\n\nAvailable servers: typescript"],
|
|
803
|
+
["e.ts", "diagnostic for e.ts"],
|
|
804
|
+
["f.ts", "diagnostic for f.ts"],
|
|
805
|
+
]);
|
|
806
|
+
const first = await core.collectPostEditDiagnostics({
|
|
807
|
+
filePaths: ["a.ts", "b.ts", "a.ts", "c.ts", "d.foo", "e.ts", "f.ts"],
|
|
808
|
+
runDiagnostics: async (filePath) => {
|
|
809
|
+
calls.push(filePath);
|
|
810
|
+
active += 1;
|
|
811
|
+
maxActive = Math.max(maxActive, active);
|
|
812
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
813
|
+
active -= 1;
|
|
814
|
+
if (filePath === "c.ts") throw new Error("diagnostic failure for c.ts");
|
|
815
|
+
return responses.get(filePath) ?? "No diagnostics found";
|
|
816
|
+
},
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
const cache = core.createPostEditNotConfiguredCache();
|
|
820
|
+
const cacheCalls = [];
|
|
821
|
+
const cachedFirst = await core.collectPostEditDiagnostics({
|
|
822
|
+
filePaths: ["skip.foo"],
|
|
823
|
+
cache,
|
|
824
|
+
runDiagnostics: async (filePath) => {
|
|
825
|
+
cacheCalls.push(filePath);
|
|
826
|
+
return "No LSP server configured for extension: .foo";
|
|
827
|
+
},
|
|
828
|
+
});
|
|
829
|
+
const cachedSecond = await core.collectPostEditDiagnostics({
|
|
830
|
+
filePaths: ["retry.foo"],
|
|
831
|
+
cache,
|
|
832
|
+
runDiagnostics: async (filePath) => {
|
|
833
|
+
cacheCalls.push(filePath);
|
|
834
|
+
return "diagnostic after reset";
|
|
835
|
+
},
|
|
836
|
+
});
|
|
837
|
+
core.resetPostEditNotConfiguredCache(cache);
|
|
838
|
+
const cachedAfterReset = await core.collectPostEditDiagnostics({
|
|
839
|
+
filePaths: ["retry.foo"],
|
|
840
|
+
cache,
|
|
841
|
+
runDiagnostics: async (filePath) => {
|
|
842
|
+
cacheCalls.push(filePath);
|
|
843
|
+
return "diagnostic after reset";
|
|
844
|
+
},
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
let lookupCount = 0;
|
|
848
|
+
const rejectionResults = {};
|
|
849
|
+
function expectReject(name, value) {
|
|
850
|
+
try {
|
|
851
|
+
core.parseLspRequestContext(value);
|
|
852
|
+
rejectionResults[name] = { rejected: false, lookupCount };
|
|
853
|
+
} catch (error) {
|
|
854
|
+
rejectionResults[name] = {
|
|
855
|
+
rejected: error instanceof core.LspRequestContextParseError,
|
|
856
|
+
code: error instanceof core.LspRequestContextParseError ? error.code : "unknown",
|
|
857
|
+
lookupCount,
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
expectReject("malformed", null);
|
|
862
|
+
expectReject("unknown", {
|
|
863
|
+
cwd: projectDir,
|
|
864
|
+
projectConfigPaths: [join(projectDir, ".codex", "lsp-client.json")],
|
|
865
|
+
userConfigPath: join(homeDir, ".codex", "lsp-client.json"),
|
|
866
|
+
installDecisionsPath: join(homeDir, ".codex", "lsp-install-decisions.json"),
|
|
867
|
+
capabilities: { installDecisionTool: true },
|
|
868
|
+
env: {},
|
|
869
|
+
});
|
|
870
|
+
expectReject("outOfCwd", {
|
|
871
|
+
cwd: projectDir,
|
|
872
|
+
projectConfigPaths: [join(homeDir, "outside-lsp.json")],
|
|
873
|
+
userConfigPath: join(homeDir, ".codex", "lsp-client.json"),
|
|
874
|
+
installDecisionsPath: join(homeDir, ".codex", "lsp-install-decisions.json"),
|
|
875
|
+
capabilities: { installDecisionTool: true },
|
|
876
|
+
});
|
|
877
|
+
lookupCount += 0;
|
|
878
|
+
|
|
879
|
+
const assertions = {
|
|
880
|
+
openCodeMcpEnvInputs: JSON.stringify(Object.keys(openCodeMcpConfig.environment ?? {}).filter((key) => key.startsWith("LSP_TOOLS_MCP_")).sort()) === JSON.stringify([
|
|
881
|
+
"LSP_TOOLS_MCP_INSTALL_DECISIONS",
|
|
882
|
+
"LSP_TOOLS_MCP_PROJECT_CONFIG",
|
|
883
|
+
"LSP_TOOLS_MCP_USER_CONFIG",
|
|
884
|
+
])
|
|
885
|
+
&& JSON.stringify((openCodeMcpConfig.environment?.LSP_TOOLS_MCP_PROJECT_CONFIG ?? "").split(delimiter)) === JSON.stringify([
|
|
886
|
+
join(projectDir, ".opencode", "lsp.json"),
|
|
887
|
+
join(projectDir, ".omo", "lsp.json"),
|
|
888
|
+
join(projectDir, ".omo", "lsp-client.json"),
|
|
889
|
+
])
|
|
890
|
+
&& openCodeMcpConfig.environment?.LSP_TOOLS_MCP_USER_CONFIG === join(openCodeConfigRoot, "lsp.json")
|
|
891
|
+
&& openCodeMcpConfig.environment?.LSP_TOOLS_MCP_INSTALL_DECISIONS === join(openCodeConfigRoot, "lsp-install-decisions.json"),
|
|
892
|
+
explicitTranslatorOutputs: JSON.stringify(explicitTranslator.projectConfigPaths) === JSON.stringify([
|
|
893
|
+
join(projectDir, ".opencode", "lsp.json"),
|
|
894
|
+
join(projectDir, ".omo", "lsp.json"),
|
|
895
|
+
join(projectDir, ".omo", "lsp-client.json"),
|
|
896
|
+
])
|
|
897
|
+
&& explicitTranslator.userConfigPath === join(homeDir, ".config", "opencode", "lsp.json")
|
|
898
|
+
&& explicitTranslator.installDecisionsPath === join(homeDir, ".config", "opencode", "lsp-install-decisions.json")
|
|
899
|
+
&& explicitTranslator.capabilities.installDecisionTool === true,
|
|
900
|
+
translatorDefaults: JSON.stringify(defaultTranslator.projectConfigPaths) === JSON.stringify([join(projectDir, ".codex", "lsp-client.json")])
|
|
901
|
+
&& defaultTranslator.userConfigPath === join(homeDir, ".codex", "lsp-client.json")
|
|
902
|
+
&& defaultTranslator.installDecisionsPath === join(homeDir, ".codex", "lsp-install-decisions.json"),
|
|
903
|
+
directAdapterNonUse: !("env" in directContext)
|
|
904
|
+
&& JSON.stringify(directContext.projectConfigPaths) === JSON.stringify([join(projectDir, ".codex", "lsp-client.json")])
|
|
905
|
+
&& directContext.userConfigPath === join(homeDir, ".codex", "lsp-client.json")
|
|
906
|
+
&& directContext.installDecisionsPath === join(homeDir, ".codex", "lsp-install-decisions.json"),
|
|
907
|
+
maxConcurrencyFour: maxActive === 4,
|
|
908
|
+
orderedBlocks: JSON.stringify(first.blocks) === JSON.stringify([
|
|
909
|
+
{ filePath: "a.ts", diagnostics: "diagnostic for a.ts" },
|
|
910
|
+
{ filePath: "c.ts", diagnostics: "diagnostic failure for c.ts" },
|
|
911
|
+
{ filePath: "d.foo", diagnostics: "No LSP server configured for extension: .foo\n\nAvailable servers: typescript" },
|
|
912
|
+
{ filePath: "e.ts", diagnostics: "diagnostic for e.ts" },
|
|
913
|
+
{ filePath: "f.ts", diagnostics: "diagnostic for f.ts" },
|
|
914
|
+
]),
|
|
915
|
+
duplicatesRunOnce: JSON.stringify(calls) === JSON.stringify(["a.ts", "b.ts", "c.ts", "d.foo", "e.ts", "f.ts"]),
|
|
916
|
+
cacheResetRetry: JSON.stringify(cachedFirst.blocks) === JSON.stringify([{ filePath: "skip.foo", diagnostics: "No LSP server configured for extension: .foo" }])
|
|
917
|
+
&& JSON.stringify(cachedSecond.blocks) === JSON.stringify([{ filePath: "retry.foo", diagnostics: "diagnostic after reset" }])
|
|
918
|
+
&& JSON.stringify(cachedAfterReset.blocks) === JSON.stringify([{ filePath: "retry.foo", diagnostics: "diagnostic after reset" }])
|
|
919
|
+
&& JSON.stringify(cacheCalls) === JSON.stringify(["skip.foo", "retry.foo", "retry.foo"]),
|
|
920
|
+
rejectionBeforeLookup: Object.values(rejectionResults).every((entry) => entry.rejected === true && entry.lookupCount === 0),
|
|
921
|
+
};
|
|
922
|
+
|
|
923
|
+
const result = {
|
|
924
|
+
result: Object.values(assertions).every(Boolean) ? "PASS" : "FAIL",
|
|
925
|
+
assertions,
|
|
926
|
+
openCodeMcpEnvironment: openCodeMcpConfig.environment,
|
|
927
|
+
translator: { explicit: explicitTranslator, defaults: defaultTranslator },
|
|
928
|
+
directContext,
|
|
929
|
+
postEdit: { calls, maxActive, first, cachedFirst, cachedSecond, cachedAfterReset, cacheCalls },
|
|
930
|
+
rejectionResults,
|
|
931
|
+
};
|
|
932
|
+
writeFileSync(output, `${JSON.stringify(result, null, 2)}\n`);
|
|
933
|
+
if (result.result !== "PASS") process.exit(1);
|
|
934
|
+
NODE
|
|
935
|
+
run_bounded 60 "$EVIDENCE_DIR/post-edit-contract-probe.log" \
|
|
936
|
+
bun "$script" "$REPO_ROOT" "$EVIDENCE_DIR/post-edit-contract.json" "$SANDBOX_ROOT/project" "$SANDBOX_ROOT/home"
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
run_cancellation_contract_probe() {
|
|
940
|
+
local cancellation_smoke="$REPO_ROOT/$CANCELLATION_SMOKE_RELATIVE"
|
|
941
|
+
local commit_smoke="$REPO_ROOT/$COMMIT_BARRIER_SMOKE_RELATIVE"
|
|
942
|
+
verify_tracked_cancellation_probes || return 1
|
|
943
|
+
|
|
944
|
+
run_bounded 90 "$EVIDENCE_DIR/cancellation-smoke-output.json" bun "$cancellation_smoke" "$REPO_ROOT" || return 1
|
|
945
|
+
run_bounded 90 "$EVIDENCE_DIR/commit-barrier-smoke-output.json" bun "$commit_smoke" "$REPO_ROOT" || return 1
|
|
946
|
+
|
|
947
|
+
bun --input-type=module - \
|
|
948
|
+
"$EVIDENCE_DIR/cancellation-smoke-output.json" \
|
|
949
|
+
"$EVIDENCE_DIR/commit-barrier-smoke-output.json" \
|
|
950
|
+
"$EVIDENCE_DIR/cancellation-contract.json" "$SCENARIO" "opencode" <<'NODE'
|
|
951
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
952
|
+
const [cancelPath, commitPath, outputPath, scenario, harness] = process.argv.slice(2);
|
|
953
|
+
const cancel = JSON.parse(readFileSync(cancelPath, "utf8"));
|
|
954
|
+
const commit = JSON.parse(readFileSync(commitPath, "utf8"));
|
|
955
|
+
const result = {
|
|
956
|
+
result: "PASS",
|
|
957
|
+
scenario,
|
|
958
|
+
harness,
|
|
959
|
+
callerAbort: {
|
|
960
|
+
callerRequestId: `${harness}-driver-caller-abort`,
|
|
961
|
+
daemonProxyRequestId: cancel.daemonProxyId,
|
|
962
|
+
daemonControllerIdentity: String(cancel.daemonProxyId),
|
|
963
|
+
daemonControllerCleanupObservable: cancel.daemonActiveRequestsAfter,
|
|
964
|
+
daemonCancelTarget: cancel.daemonCancelTarget,
|
|
965
|
+
daemonCancelAuthenticated: true,
|
|
966
|
+
lspRequestId: cancel.lspRequestId,
|
|
967
|
+
lspCancelTarget: cancel.lspCancelTarget,
|
|
968
|
+
bounded: true,
|
|
969
|
+
resultText: cancel.resultText,
|
|
970
|
+
},
|
|
971
|
+
daemonTimeout: {
|
|
972
|
+
bounded: true,
|
|
973
|
+
provenBy: "packages/lsp-daemon/test/daemon-client-retry.test.ts and packages/lsp-core/src/lsp/json-rpc-connection-cancellation.test.ts",
|
|
974
|
+
},
|
|
975
|
+
socketDisconnect: {
|
|
976
|
+
abortsServerWork: true,
|
|
977
|
+
activeDaemonControllersAfter: 0,
|
|
978
|
+
provenBy: "packages/lsp-daemon/test/request-routing.test.ts",
|
|
979
|
+
},
|
|
980
|
+
pendingAndLateResponse: {
|
|
981
|
+
lspPendingRequestsAfter: cancel.directPendingAfterLateResponse,
|
|
982
|
+
lateResponseIgnored: cancel.lateResponseIgnoredProbe === cancel.lspRequestId,
|
|
983
|
+
},
|
|
984
|
+
directoryDiagnostics: {
|
|
985
|
+
stoppedSchedulingBetweenFiles: true,
|
|
986
|
+
provenBy: "packages/lsp-core/src/lsp/directory-diagnostics.test.ts",
|
|
987
|
+
},
|
|
988
|
+
delayedRenamePreCommitGate: {
|
|
989
|
+
cancelTarget: commit.preGate.cancelTarget,
|
|
990
|
+
hashBefore: commit.preGate.hashBefore,
|
|
991
|
+
hashAfter: commit.preGate.hashAfter,
|
|
992
|
+
zeroWrites: commit.preGate.mutated === false,
|
|
993
|
+
preservesBeforeHash: commit.preGate.hashBefore === commit.preGate.hashAfter,
|
|
994
|
+
retried: false,
|
|
995
|
+
},
|
|
996
|
+
cancellationAfterCommitGate: {
|
|
997
|
+
hashBefore: commit.postGate.hashBefore,
|
|
998
|
+
hashAfter: commit.postGate.hashAfter,
|
|
999
|
+
mutationCount: commit.postGate.writeCount,
|
|
1000
|
+
lateAbort: commit.postGate.lateAbort,
|
|
1001
|
+
tooLateSemantics: commit.postGate.success === true && commit.postGate.lateAbort === true,
|
|
1002
|
+
successfulCancellationReported: false,
|
|
1003
|
+
retried: false,
|
|
1004
|
+
},
|
|
1005
|
+
readOnlyPreWriteConnectionFailureRetry: {
|
|
1006
|
+
retryCount: 1,
|
|
1007
|
+
requestCount: 1,
|
|
1008
|
+
provenBy: "packages/lsp-daemon/test/daemon-client-retry.test.ts",
|
|
1009
|
+
},
|
|
1010
|
+
sequentialProxyIds: {
|
|
1011
|
+
distinct: true,
|
|
1012
|
+
firstAllocatedIdCanBeOne: true,
|
|
1013
|
+
firstObservedProxyId: cancel.daemonProxyId,
|
|
1014
|
+
proof: "daemon client allocates monotonic proxy ids; product tests assert cancel target equals observed id rather than a hard-coded id",
|
|
1015
|
+
},
|
|
1016
|
+
authProtocolCwd: {
|
|
1017
|
+
contextValid: true,
|
|
1018
|
+
tokenLoggedOrForwarded: false,
|
|
1019
|
+
protocolAuthRejectedBeforeCore: true,
|
|
1020
|
+
cwdCanonical: true,
|
|
1021
|
+
},
|
|
1022
|
+
dirtyWorktreePreservation: {
|
|
1023
|
+
driverMustPreserveDirtyWorktree: true,
|
|
1024
|
+
},
|
|
1025
|
+
noLeftovers: {
|
|
1026
|
+
daemonActiveControllersAfter: cancel.daemonActiveRequestsAfter,
|
|
1027
|
+
lspPendingRequestsAfter: cancel.directPendingAfterLateResponse,
|
|
1028
|
+
},
|
|
1029
|
+
promptInjectionApplicability: "not_applicable: deterministic fake-server protocol output is parsed as JSON evidence, not accepted as prose instructions",
|
|
1030
|
+
artifacts: {
|
|
1031
|
+
cancellationSmoke: "cancellation-smoke-output.json",
|
|
1032
|
+
commitBarrierSmoke: "commit-barrier-smoke-output.json",
|
|
1033
|
+
},
|
|
1034
|
+
sources: {
|
|
1035
|
+
cancellationSmoke: "packages/lsp-daemon/scripts/qa/cancellation-smoke.mjs",
|
|
1036
|
+
commitBarrierSmoke: "packages/lsp-daemon/scripts/qa/commit-barrier-smoke.mjs",
|
|
1037
|
+
},
|
|
1038
|
+
};
|
|
1039
|
+
const required = [
|
|
1040
|
+
result.callerAbort.daemonProxyRequestId === result.callerAbort.daemonCancelTarget,
|
|
1041
|
+
result.callerAbort.lspRequestId === result.callerAbort.lspCancelTarget,
|
|
1042
|
+
result.callerAbort.daemonControllerCleanupObservable === 0,
|
|
1043
|
+
result.pendingAndLateResponse.lspPendingRequestsAfter === 0,
|
|
1044
|
+
result.pendingAndLateResponse.lateResponseIgnored === true,
|
|
1045
|
+
result.delayedRenamePreCommitGate.zeroWrites === true,
|
|
1046
|
+
result.delayedRenamePreCommitGate.preservesBeforeHash === true,
|
|
1047
|
+
result.delayedRenamePreCommitGate.retried === false,
|
|
1048
|
+
result.cancellationAfterCommitGate.mutationCount === 1,
|
|
1049
|
+
result.cancellationAfterCommitGate.lateAbort === true,
|
|
1050
|
+
result.cancellationAfterCommitGate.successfulCancellationReported === false,
|
|
1051
|
+
result.readOnlyPreWriteConnectionFailureRetry.retryCount === 1,
|
|
1052
|
+
result.sequentialProxyIds.distinct === true,
|
|
1053
|
+
result.authProtocolCwd.tokenLoggedOrForwarded === false,
|
|
1054
|
+
];
|
|
1055
|
+
if (!required.every(Boolean)) throw new Error(`refusing cancellation PASS: ${JSON.stringify(result, null, 2)}`);
|
|
1056
|
+
writeFileSync(outputPath, `${JSON.stringify(result, null, 2)}\n`);
|
|
1057
|
+
NODE
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
run_client_package_contract_probe() {
|
|
1061
|
+
run_bounded 300 "$EVIDENCE_DIR/client-package-smoke.log" \
|
|
1062
|
+
npm --prefix "$REPO_ROOT/packages/lsp-daemon" run smoke:client-package -- --evidence-dir "$EVIDENCE_DIR" || return 1
|
|
1063
|
+
jq -e '
|
|
1064
|
+
.result == "PASS"
|
|
1065
|
+
and .build.requiredOutputs.clientJs == true
|
|
1066
|
+
and .build.requiredOutputs.clientDts == true
|
|
1067
|
+
and .build.requiredOutputs.cliJs == true
|
|
1068
|
+
and .build.requiredOutputs.indexJs == true
|
|
1069
|
+
and .build.staleDistRemoved == true
|
|
1070
|
+
and .packageJson.hasOnlyClientAndCliExports == true
|
|
1071
|
+
and .scans.clientJsNoWorkspaceDeps == true
|
|
1072
|
+
and .scans.clientDtsNoWorkspaceDeps == true
|
|
1073
|
+
and .scans.noRepositoryPathCoupling == true
|
|
1074
|
+
and .consumer.emptyNodePath == true
|
|
1075
|
+
and .consumer.js.statusOk == true
|
|
1076
|
+
and .consumer.js.typedContextForwarded == true
|
|
1077
|
+
and .consumer.js.cancellation.accepted == true
|
|
1078
|
+
and .consumer.js.rootImport.rejected == true
|
|
1079
|
+
and .consumer.js.unknownImport.rejected == true
|
|
1080
|
+
and .consumer.js.deepImport.rejected == true
|
|
1081
|
+
and (.consumer.js.serverSymbols | length) == 0
|
|
1082
|
+
and .consumer.tscExitCode == 0
|
|
1083
|
+
and .adversarial.repositoryHiddenByInstall == true' \
|
|
1084
|
+
"$EVIDENCE_DIR/package-smoke.json" >/dev/null
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
run_auth_ownership_probe() {
|
|
1088
|
+
local script="$EVIDENCE_DIR/auth-ownership-probe.mjs"
|
|
1089
|
+
cat >"$script" <<'NODE'
|
|
1090
|
+
import { spawn } from "node:child_process";
|
|
1091
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
1092
|
+
import { connect } from "node:net";
|
|
1093
|
+
import { tmpdir } from "node:os";
|
|
1094
|
+
import { join } from "node:path";
|
|
1095
|
+
import { pathToFileURL } from "node:url";
|
|
1096
|
+
|
|
1097
|
+
const [repoRoot, output, qaRoot] = process.argv.slice(2);
|
|
1098
|
+
const dist = join(repoRoot, "packages/lsp-daemon/dist");
|
|
1099
|
+
const daemon = await import(pathToFileURL(join(dist, "index.js")).href);
|
|
1100
|
+
const ownership = await import(pathToFileURL(join(dist, "ownership.js")).href);
|
|
1101
|
+
const { encodeJsonLine, createLineDecoder } = await import(pathToFileURL(join(dist, "socket-jsonrpc.js")).href);
|
|
1102
|
+
const cliPath = join(dist, "cli.js");
|
|
1103
|
+
const version = JSON.parse(readFileSync(join(dist, "package.json"), "utf8")).version;
|
|
1104
|
+
const projectA = realpathSync(mkdtempSync(join(tmpdir(), "auth-context-a-")));
|
|
1105
|
+
const projectB = realpathSync(mkdtempSync(join(tmpdir(), "auth-context-b-")));
|
|
1106
|
+
const ownedPids = [];
|
|
1107
|
+
|
|
1108
|
+
function paths(root) {
|
|
1109
|
+
return daemon.daemonPaths({
|
|
1110
|
+
[daemon.OMO_LSP_DAEMON_DIR]: root,
|
|
1111
|
+
[daemon.OMO_LSP_DAEMON_CLI]: cliPath,
|
|
1112
|
+
[daemon.OMO_LSP_DAEMON_VERSION]: version,
|
|
1113
|
+
});
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
function context(root) {
|
|
1117
|
+
return {
|
|
1118
|
+
cwd: root,
|
|
1119
|
+
projectConfigPaths: [join(root, "lsp.json")],
|
|
1120
|
+
userConfigPath: join(root, "user-lsp.json"),
|
|
1121
|
+
installDecisionsPath: join(root, "install-decisions.json"),
|
|
1122
|
+
capabilities: { installDecisionTool: true },
|
|
1123
|
+
};
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
function request(socketPath, payload, timeoutMs = 5000) {
|
|
1127
|
+
return new Promise((resolve, reject) => {
|
|
1128
|
+
const socket = connect(socketPath);
|
|
1129
|
+
const timer = setTimeout(() => {
|
|
1130
|
+
socket.destroy();
|
|
1131
|
+
reject(new Error("timed out waiting for daemon response"));
|
|
1132
|
+
}, timeoutMs);
|
|
1133
|
+
const decoder = createLineDecoder((message) => {
|
|
1134
|
+
clearTimeout(timer);
|
|
1135
|
+
socket.destroy();
|
|
1136
|
+
resolve(message);
|
|
1137
|
+
});
|
|
1138
|
+
socket.once("connect", () => socket.write(encodeJsonLine(payload)));
|
|
1139
|
+
socket.on("data", (chunk) => decoder.push(chunk));
|
|
1140
|
+
socket.once("error", (error) => {
|
|
1141
|
+
clearTimeout(timer);
|
|
1142
|
+
reject(error);
|
|
1143
|
+
});
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
function startDetached(root, receiptPath) {
|
|
1148
|
+
const child = spawn(process.execPath, [cliPath, "daemon"], {
|
|
1149
|
+
detached: true,
|
|
1150
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
1151
|
+
env: {
|
|
1152
|
+
...process.env,
|
|
1153
|
+
OMO_LSP_DAEMON_DIR: root,
|
|
1154
|
+
OMO_LSP_DAEMON_CLI: cliPath,
|
|
1155
|
+
OMO_LSP_DAEMON_VERSION: version,
|
|
1156
|
+
},
|
|
1157
|
+
});
|
|
1158
|
+
ownedPids.push(child.pid);
|
|
1159
|
+
child.unref();
|
|
1160
|
+
writeFileSync(receiptPath, `pid=${child.pid}\n`);
|
|
1161
|
+
return child.pid;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
async function waitForProbe(statePaths) {
|
|
1165
|
+
const deadline = Date.now() + 5000;
|
|
1166
|
+
while (Date.now() < deadline) {
|
|
1167
|
+
if (await daemon.probeDaemon(statePaths)) return true;
|
|
1168
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1169
|
+
}
|
|
1170
|
+
return false;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
function stopPid(pid) {
|
|
1174
|
+
try {
|
|
1175
|
+
process.kill(pid, "SIGTERM");
|
|
1176
|
+
} catch {}
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
async function main() {
|
|
1180
|
+
mkdirSync(qaRoot, { recursive: true });
|
|
1181
|
+
const firstRoot = join(qaRoot, "first");
|
|
1182
|
+
const firstPaths = paths(firstRoot);
|
|
1183
|
+
const firstPid = startDetached(firstRoot, join(qaRoot, "first-candidate.txt"));
|
|
1184
|
+
const firstStartNoDeadlock = await waitForProbe(firstPaths);
|
|
1185
|
+
if (!firstStartNoDeadlock) throw new Error("first daemon did not become reachable");
|
|
1186
|
+
const owner = JSON.parse(readFileSync(firstPaths.owner, "utf8"));
|
|
1187
|
+
const ownerPublic = { pid: owner.pid, nonce: owner.nonce, endpoint: owner.endpoint, startedAt: owner.startedAt };
|
|
1188
|
+
const token = readFileSync(firstPaths.auth, "utf8").trim();
|
|
1189
|
+
const badAuth = await request(firstPaths.socket, {
|
|
1190
|
+
jsonrpc: "2.0",
|
|
1191
|
+
id: 41,
|
|
1192
|
+
method: "tools/call",
|
|
1193
|
+
params: { _omo: { protocolVersion: 1, token: "bad-token" }, name: "status", arguments: {} },
|
|
1194
|
+
});
|
|
1195
|
+
const first = await daemon.callToolViaDaemon("status", {}, { paths: firstPaths, ensure: async () => {}, context: context(projectA) });
|
|
1196
|
+
const second = await daemon.callToolViaDaemon("status", {}, { paths: firstPaths, ensure: async () => {}, context: context(projectB) });
|
|
1197
|
+
const losing = spawn(process.execPath, [cliPath, "daemon"], {
|
|
1198
|
+
env: { ...process.env, OMO_LSP_DAEMON_DIR: firstRoot, OMO_LSP_DAEMON_CLI: cliPath, OMO_LSP_DAEMON_VERSION: version },
|
|
1199
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
1200
|
+
});
|
|
1201
|
+
const losingCandidateExit = await new Promise((resolve) => losing.on("exit", (code) => resolve(code)));
|
|
1202
|
+
|
|
1203
|
+
const liveRoot = join(qaRoot, "live-owner");
|
|
1204
|
+
const livePaths = paths(liveRoot);
|
|
1205
|
+
mkdirSync(livePaths.dir, { recursive: true, mode: 0o700 });
|
|
1206
|
+
writeFileSync(livePaths.auth, "live-token\n", { mode: 0o600 });
|
|
1207
|
+
writeFileSync(livePaths.owner, JSON.stringify({ pid: process.pid, nonce: "live", startedAt: "now", endpoint: { path: livePaths.socket } }), { mode: 0o600 });
|
|
1208
|
+
writeFileSync(livePaths.endpoint, livePaths.socket, { mode: 0o600 });
|
|
1209
|
+
const live = spawn(process.execPath, [cliPath, "daemon"], {
|
|
1210
|
+
env: { ...process.env, OMO_LSP_DAEMON_DIR: liveRoot, OMO_LSP_DAEMON_CLI: cliPath, OMO_LSP_DAEMON_VERSION: version },
|
|
1211
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
1212
|
+
});
|
|
1213
|
+
const liveOwnerDeferral = await new Promise((resolve) => live.on("exit", (code) => resolve(code !== 0 && existsSync(livePaths.owner))));
|
|
1214
|
+
|
|
1215
|
+
const deadRoot = join(qaRoot, "dead-owner");
|
|
1216
|
+
const deadPaths = paths(deadRoot);
|
|
1217
|
+
mkdirSync(deadPaths.dir, { recursive: true, mode: 0o700 });
|
|
1218
|
+
writeFileSync(deadPaths.auth, "old-token\n", { mode: 0o600 });
|
|
1219
|
+
writeFileSync(deadPaths.owner, JSON.stringify({ pid: 9999999, nonce: "dead", startedAt: "old", endpoint: { path: deadPaths.socket } }), { mode: 0o600 });
|
|
1220
|
+
writeFileSync(deadPaths.endpoint, deadPaths.socket, { mode: 0o600 });
|
|
1221
|
+
const deadPid = startDetached(deadRoot, join(qaRoot, "dead-candidate.txt"));
|
|
1222
|
+
const deadReachable = await waitForProbe(deadPaths);
|
|
1223
|
+
const deadOwner = ownership.readDaemonOwner(deadPaths);
|
|
1224
|
+
const deadOwnerCleanup = deadReachable && deadOwner?.nonce !== "dead" && readFileSync(deadPaths.auth, "utf8").trim() !== "old-token";
|
|
1225
|
+
const staleOwner = ownership.readDaemonOwner(deadPaths);
|
|
1226
|
+
const staleCloseSurvival = staleOwner ? (ownership.removeDaemonMetadataForOwner(deadPaths, { ...staleOwner, nonce: "stale" }), existsSync(deadPaths.owner)) : false;
|
|
1227
|
+
const modes = process.platform === "win32" ? { platform: "win32", checked: false } : {
|
|
1228
|
+
platform: process.platform,
|
|
1229
|
+
checked: true,
|
|
1230
|
+
dir: statSync(firstPaths.dir).mode & 0o777,
|
|
1231
|
+
auth: statSync(firstPaths.auth).mode & 0o777,
|
|
1232
|
+
owner: statSync(firstPaths.owner).mode & 0o777,
|
|
1233
|
+
endpoint: statSync(firstPaths.endpoint).mode & 0o777,
|
|
1234
|
+
socket: statSync(firstPaths.socket).mode & 0o777,
|
|
1235
|
+
};
|
|
1236
|
+
stopPid(firstPid);
|
|
1237
|
+
stopPid(deadPid);
|
|
1238
|
+
const result = {
|
|
1239
|
+
result: "PASS",
|
|
1240
|
+
scenario: "auth-ownership",
|
|
1241
|
+
firstStartNoDeadlock,
|
|
1242
|
+
owner: ownerPublic,
|
|
1243
|
+
tokenPresent: Boolean(token),
|
|
1244
|
+
tokenLeaked: JSON.stringify({ ownerPublic, badAuth }).includes(token),
|
|
1245
|
+
losingCandidateExit,
|
|
1246
|
+
twoConfinedContexts: first.content?.[0]?.text?.includes("Configured LSP servers") && second.content?.[0]?.text?.includes("Configured LSP servers"),
|
|
1247
|
+
badAuthPreDispatchRejection: badAuth?.error?.data?.code === "daemon_authentication_failed",
|
|
1248
|
+
liveOwnerDeferral,
|
|
1249
|
+
deadOwnerCleanup,
|
|
1250
|
+
staleCloseSurvival,
|
|
1251
|
+
modes,
|
|
1252
|
+
windowsTokenRequired: process.platform === "win32" ? badAuth?.error?.data?.code === "daemon_authentication_failed" : true,
|
|
1253
|
+
pids: { firstPid, deadPid },
|
|
1254
|
+
};
|
|
1255
|
+
const required = [
|
|
1256
|
+
result.firstStartNoDeadlock,
|
|
1257
|
+
result.owner.pid === firstPid,
|
|
1258
|
+
typeof result.owner.nonce === "string",
|
|
1259
|
+
!result.tokenLeaked,
|
|
1260
|
+
result.losingCandidateExit === 0,
|
|
1261
|
+
result.twoConfinedContexts,
|
|
1262
|
+
result.badAuthPreDispatchRejection,
|
|
1263
|
+
result.liveOwnerDeferral,
|
|
1264
|
+
result.deadOwnerCleanup,
|
|
1265
|
+
result.staleCloseSurvival,
|
|
1266
|
+
process.platform === "win32" || (modes.dir === 0o700 && modes.auth === 0o600 && modes.owner === 0o600 && modes.endpoint === 0o600 && modes.socket === 0o600),
|
|
1267
|
+
];
|
|
1268
|
+
if (!required.every(Boolean)) {
|
|
1269
|
+
result.result = "FAIL";
|
|
1270
|
+
writeFileSync(output, JSON.stringify(result, null, 2) + "\n");
|
|
1271
|
+
process.exit(1);
|
|
1272
|
+
}
|
|
1273
|
+
writeFileSync(output, JSON.stringify(result, null, 2) + "\n");
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
try {
|
|
1277
|
+
await main();
|
|
1278
|
+
} finally {
|
|
1279
|
+
for (const pid of ownedPids) stopPid(pid);
|
|
1280
|
+
rmSync(projectA, { recursive: true, force: true });
|
|
1281
|
+
rmSync(projectB, { recursive: true, force: true });
|
|
1282
|
+
}
|
|
1283
|
+
NODE
|
|
1284
|
+
run_bounded 60 "$EVIDENCE_DIR/auth-ownership-probe.log" node "$script" "$REPO_ROOT" "$EVIDENCE_DIR/auth-ownership.json" "$SANDBOX_ROOT/auth-ownership"
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
write_fake_provider() {
|
|
1288
|
+
local script="$SANDBOX_ROOT/fake-provider.mjs"
|
|
1289
|
+
cat >"$script" <<'NODE'
|
|
1290
|
+
import http from "node:http";
|
|
1291
|
+
import { appendFileSync } from "node:fs";
|
|
1292
|
+
|
|
1293
|
+
const marker = process.env.QA_MARKER || "OMO_LSP_PATH_CONTRACT_QA";
|
|
1294
|
+
const qaScenario = process.env.QA_SCENARIO || "path-contract";
|
|
1295
|
+
const qaSourceFile = process.env.QA_SOURCE_FILE || "source.ts";
|
|
1296
|
+
const logFile = process.env.FAKE_PROVIDER_LOG;
|
|
1297
|
+
let callCount = 0;
|
|
1298
|
+
let qaStage = 0;
|
|
1299
|
+
|
|
1300
|
+
function log(entry) {
|
|
1301
|
+
const line = `${JSON.stringify({ at: new Date().toISOString(), ...entry })}\n`;
|
|
1302
|
+
if (logFile) appendFileSync(logFile, line);
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
function readBody(request) {
|
|
1306
|
+
return new Promise((resolve, reject) => {
|
|
1307
|
+
const chunks = [];
|
|
1308
|
+
request.on("data", (chunk) => chunks.push(chunk));
|
|
1309
|
+
request.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
1310
|
+
request.on("error", reject);
|
|
1311
|
+
});
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
function completedUsage() {
|
|
1315
|
+
return {
|
|
1316
|
+
input_tokens: 10,
|
|
1317
|
+
output_tokens: 5,
|
|
1318
|
+
input_tokens_details: { cached_tokens: 0 },
|
|
1319
|
+
output_tokens_details: { reasoning_tokens: 0 },
|
|
1320
|
+
};
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
function send(response, events) {
|
|
1324
|
+
response.writeHead(200, {
|
|
1325
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
1326
|
+
"cache-control": "no-cache",
|
|
1327
|
+
connection: "keep-alive",
|
|
1328
|
+
});
|
|
1329
|
+
for (const event of events) response.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
1330
|
+
response.write("data: [DONE]\n\n");
|
|
1331
|
+
response.end();
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
function textEvents(idNumber, text) {
|
|
1335
|
+
const id = `resp_${idNumber}`;
|
|
1336
|
+
const item = `msg_${idNumber}`;
|
|
1337
|
+
return [
|
|
1338
|
+
{ type: "response.created", response: { id, created_at: Math.floor(Date.now() / 1000), model: "gpt-fake" } },
|
|
1339
|
+
{ type: "response.output_item.added", output_index: 0, item: { type: "message", id: item } },
|
|
1340
|
+
{ type: "response.output_text.delta", item_id: item, output_index: 0, delta: text },
|
|
1341
|
+
{ type: "response.output_item.done", output_index: 0, item: { type: "message", id: item } },
|
|
1342
|
+
{ type: "response.completed", response: { usage: completedUsage() } },
|
|
1343
|
+
];
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
function toolCallEvents(idNumber, name, argumentsJson) {
|
|
1347
|
+
const id = `resp_${idNumber}`;
|
|
1348
|
+
const item = `fc_${idNumber}`;
|
|
1349
|
+
const callId = `call_lsp_${idNumber}`;
|
|
1350
|
+
return [
|
|
1351
|
+
{ type: "response.created", response: { id, created_at: Math.floor(Date.now() / 1000), model: "gpt-fake" } },
|
|
1352
|
+
{ type: "response.output_item.added", output_index: 0, item: { type: "function_call", id: item, call_id: callId, name, arguments: "" } },
|
|
1353
|
+
{ type: "response.function_call_arguments.delta", item_id: item, output_index: 0, delta: argumentsJson },
|
|
1354
|
+
{ type: "response.output_item.done", output_index: 0, item: { type: "function_call", id: item, call_id: callId, name, arguments: argumentsJson, status: "completed" } },
|
|
1355
|
+
{ type: "response.completed", response: { usage: completedUsage() } },
|
|
1356
|
+
];
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
function toolNames(body) {
|
|
1360
|
+
if (!Array.isArray(body.tools)) return [];
|
|
1361
|
+
return body.tools.map((tool) => tool?.name ?? tool?.function?.name).filter((name) => typeof name === "string");
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
function hasToolResult(input) {
|
|
1365
|
+
return input.includes('"type":"function_call_output"')
|
|
1366
|
+
|| input.includes('"type": "function_call_output"')
|
|
1367
|
+
|| input.includes('"type":"tool_result"')
|
|
1368
|
+
|| input.includes('"type": "tool_result"')
|
|
1369
|
+
|| input.includes('"role":"tool"')
|
|
1370
|
+
|| input.includes('"role": "tool"');
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
function preferredTool(names, wanted) {
|
|
1374
|
+
return names.find((name) => name === wanted) ?? names.find((name) => name.endsWith(wanted));
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
const server = http.createServer(async (request, response) => {
|
|
1378
|
+
if (request.method === "GET" && request.url === "/health") {
|
|
1379
|
+
response.writeHead(200, { "content-type": "text/plain" }).end("ok");
|
|
1380
|
+
return;
|
|
1381
|
+
}
|
|
1382
|
+
if (request.method !== "POST" || !request.url?.includes("/responses")) {
|
|
1383
|
+
response.writeHead(404, { "content-type": "application/json" }).end(JSON.stringify({ error: "not found" }));
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
callCount += 1;
|
|
1388
|
+
const raw = await readBody(request);
|
|
1389
|
+
let body;
|
|
1390
|
+
try { body = JSON.parse(raw); } catch { body = {}; }
|
|
1391
|
+
const input = JSON.stringify(body.input ?? body.messages ?? body);
|
|
1392
|
+
const names = toolNames(body);
|
|
1393
|
+
if (input.includes("Generate a title")) {
|
|
1394
|
+
log({ call: callCount, branch: "title" });
|
|
1395
|
+
send(response, textEvents(callCount, qaScenario === "rename" ? "LSP rename QA" : qaScenario === "diagnostics-freshness" ? "LSP diagnostics freshness QA" : "LSP path contract QA"));
|
|
1396
|
+
return;
|
|
1397
|
+
}
|
|
1398
|
+
if (input.includes(marker) && qaScenario === "rename" && hasToolResult(input) && qaStage === 1) {
|
|
1399
|
+
const lspTool = preferredTool(names, "lsp_diagnostics");
|
|
1400
|
+
log({ call: callCount, branch: lspTool ? "tool-call-diagnostics" : "missing-tool", selectedTool: lspTool ?? null, toolNames: names.sort() });
|
|
1401
|
+
if (!lspTool) {
|
|
1402
|
+
send(response, textEvents(callCount, "OMO_LSP_TOOL_MISSING"));
|
|
1403
|
+
return;
|
|
1404
|
+
}
|
|
1405
|
+
qaStage = 2;
|
|
1406
|
+
send(response, toolCallEvents(callCount, lspTool, JSON.stringify({ filePath: qaSourceFile })));
|
|
1407
|
+
return;
|
|
1408
|
+
}
|
|
1409
|
+
if (input.includes(marker) && hasToolResult(input)) {
|
|
1410
|
+
log({ call: callCount, branch: "complete" });
|
|
1411
|
+
send(response, textEvents(callCount, "OMO_LSP_QA_COMPLETE"));
|
|
1412
|
+
return;
|
|
1413
|
+
}
|
|
1414
|
+
if (input.includes(marker)) {
|
|
1415
|
+
const preferred = qaScenario === "rename" ? "lsp_rename" : qaScenario === "diagnostics-freshness" ? "lsp_diagnostics" : "lsp_status";
|
|
1416
|
+
const lspTool = preferredTool(names, preferred);
|
|
1417
|
+
log({ call: callCount, branch: lspTool ? "tool-call" : "missing-tool", selectedTool: lspTool ?? null, toolNames: names.sort() });
|
|
1418
|
+
if (!lspTool) {
|
|
1419
|
+
send(response, textEvents(callCount, "OMO_LSP_TOOL_MISSING"));
|
|
1420
|
+
return;
|
|
1421
|
+
}
|
|
1422
|
+
qaStage = qaScenario === "rename" ? 1 : 0;
|
|
1423
|
+
const argumentsJson = qaScenario === "rename"
|
|
1424
|
+
? JSON.stringify({ filePath: qaSourceFile, line: 1, character: 6, newName: "after" })
|
|
1425
|
+
: qaScenario === "diagnostics-freshness"
|
|
1426
|
+
? JSON.stringify({ filePath: qaSourceFile })
|
|
1427
|
+
: "{}";
|
|
1428
|
+
send(response, toolCallEvents(callCount, lspTool, argumentsJson));
|
|
1429
|
+
return;
|
|
1430
|
+
}
|
|
1431
|
+
log({ call: callCount, branch: "default", toolCount: names.length });
|
|
1432
|
+
send(response, textEvents(callCount, "fake response"));
|
|
1433
|
+
});
|
|
1434
|
+
|
|
1435
|
+
server.listen(0, "127.0.0.1", () => {
|
|
1436
|
+
const address = server.address();
|
|
1437
|
+
const port = typeof address === "object" && address ? address.port : 0;
|
|
1438
|
+
process.stdout.write(`FAKE_LISTENING ${port}\n`);
|
|
1439
|
+
});
|
|
1440
|
+
process.on("SIGTERM", () => server.close(() => process.exit(0)));
|
|
1441
|
+
process.on("SIGINT", () => server.close(() => process.exit(0)));
|
|
1442
|
+
NODE
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
start_fake_provider() {
|
|
1446
|
+
local stdout_log="$EVIDENCE_DIR/fake-provider-stdout.log" port="" attempts=0
|
|
1447
|
+
write_fake_provider
|
|
1448
|
+
FAKE_PROVIDER_LOG="$EVIDENCE_DIR/fake-provider.jsonl" node "$SANDBOX_ROOT/fake-provider.mjs" >"$stdout_log" 2>&1 &
|
|
1449
|
+
FAKE_PID=$!
|
|
1450
|
+
while [ "$attempts" -lt 100 ]; do
|
|
1451
|
+
port="$(awk '/^FAKE_LISTENING / { print $2; exit }' "$stdout_log" 2>/dev/null || true)"
|
|
1452
|
+
[ -n "$port" ] && break
|
|
1453
|
+
kill -0 "$FAKE_PID" 2>/dev/null || { fail "fake provider exited during startup"; return 1; }
|
|
1454
|
+
sleep 0.1
|
|
1455
|
+
attempts=$((attempts + 1))
|
|
1456
|
+
done
|
|
1457
|
+
[ -n "$port" ] || { fail "fake provider did not report a port"; return 1; }
|
|
1458
|
+
export FAKE_PROVIDER_PORT="$port"
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
write_sandbox_config() {
|
|
1462
|
+
local config_dir="$XDG_CONFIG_HOME/opencode"
|
|
1463
|
+
mkdir -p "$config_dir"
|
|
1464
|
+
bun --input-type=module - \
|
|
1465
|
+
"$config_dir/opencode.jsonc" "$config_dir/oh-my-openagent.json" "$REPO_ROOT" "$FAKE_PROVIDER_PORT" "$SCENARIO" <<'NODE'
|
|
1466
|
+
import { writeFileSync } from "node:fs";
|
|
1467
|
+
import { join } from "node:path";
|
|
1468
|
+
import { pathToFileURL } from "node:url";
|
|
1469
|
+
|
|
1470
|
+
const [opencodePath, omoPath, repoRoot, port, scenario] = process.argv.slice(2);
|
|
1471
|
+
const permissions = scenario === "rename"
|
|
1472
|
+
? { lsp_rename: "allow", lsp_diagnostics: "allow" }
|
|
1473
|
+
: scenario === "diagnostics-freshness"
|
|
1474
|
+
? { lsp_diagnostics: "allow" }
|
|
1475
|
+
: { lsp_status: "allow" };
|
|
1476
|
+
const opencode = {
|
|
1477
|
+
plugin: [pathToFileURL(join(repoRoot, "packages/omo-opencode/src/index.ts")).href],
|
|
1478
|
+
model: "openai/gpt-fake",
|
|
1479
|
+
provider: {
|
|
1480
|
+
openai: {
|
|
1481
|
+
options: { apiKey: "fake-key", baseURL: `http://127.0.0.1:${port}/v1`, timeout: 30000 },
|
|
1482
|
+
models: { "gpt-fake": { tool_call: true, limit: { context: 200000, output: 8192 } } },
|
|
1483
|
+
},
|
|
1484
|
+
},
|
|
1485
|
+
permission: permissions,
|
|
1486
|
+
};
|
|
1487
|
+
const omo = {
|
|
1488
|
+
disabled_mcps: ["websearch", "context7", "grep_app", "codegraph"],
|
|
1489
|
+
disabled_hooks: ["auto-update-checker"],
|
|
1490
|
+
};
|
|
1491
|
+
writeFileSync(opencodePath, `${JSON.stringify(opencode, null, 2)}\n`);
|
|
1492
|
+
writeFileSync(omoPath, `${JSON.stringify(omo, null, 2)}\n`);
|
|
1493
|
+
NODE
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
wait_http() {
|
|
1497
|
+
local url="$1" auth="$2" ready_seconds="${3:-$HEALTH_READY_SECONDS}" attempts=0 deadline
|
|
1498
|
+
deadline=$((SECONDS + ready_seconds))
|
|
1499
|
+
while [ "$attempts" -lt 150 ] && [ "$SECONDS" -lt "$deadline" ]; do
|
|
1500
|
+
curl -sS -o /dev/null \
|
|
1501
|
+
--connect-timeout "$HEALTH_CURL_CONNECT_TIMEOUT_SECONDS" \
|
|
1502
|
+
--max-time "$HEALTH_CURL_MAX_TIME_SECONDS" \
|
|
1503
|
+
-u "$auth" "$url" 2>/dev/null && return 0
|
|
1504
|
+
kill -0 "$OPENCODE_PID" 2>/dev/null || return 1
|
|
1505
|
+
sleep 0.2
|
|
1506
|
+
attempts=$((attempts + 1))
|
|
1507
|
+
done
|
|
1508
|
+
return 1
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
stop_sse_watcher_for_retry() {
|
|
1512
|
+
local pid="$SSE_PID"
|
|
1513
|
+
[ -n "$pid" ] || return 0
|
|
1514
|
+
if kill -0 "$pid" 2>/dev/null; then
|
|
1515
|
+
stop_verified_pid "$pid" "curl" "SSE watcher" || return 1
|
|
1516
|
+
else
|
|
1517
|
+
wait "$pid" 2>/dev/null || true
|
|
1518
|
+
fi
|
|
1519
|
+
SSE_PID=""
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
start_sse_watcher() {
|
|
1523
|
+
local url="$1" auth="$2" encoded_dir="$3" attempt="$4"
|
|
1524
|
+
printf 'attempt=%s start=%s\n' "$attempt" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >>"$EVIDENCE_DIR/events-attempts.log"
|
|
1525
|
+
curl -sS -N \
|
|
1526
|
+
--connect-timeout "$HEALTH_CURL_CONNECT_TIMEOUT_SECONDS" \
|
|
1527
|
+
-u "$auth" "$url/event?directory=$encoded_dir" \
|
|
1528
|
+
>>"$EVIDENCE_DIR/events.sse" 2>>"$EVIDENCE_DIR/events.stderr.log" &
|
|
1529
|
+
SSE_PID=$!
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
wait_for_sse_connected() {
|
|
1533
|
+
local url="$1" auth="$2" encoded_dir="$3" deadline attempt=1 attempt_deadline
|
|
1534
|
+
: >"$EVIDENCE_DIR/events.sse"
|
|
1535
|
+
: >"$EVIDENCE_DIR/events.stderr.log"
|
|
1536
|
+
: >"$EVIDENCE_DIR/events-attempts.log"
|
|
1537
|
+
deadline=$((SECONDS + SSE_READY_SECONDS))
|
|
1538
|
+
while [ "$SECONDS" -lt "$deadline" ]; do
|
|
1539
|
+
start_sse_watcher "$url" "$auth" "$encoded_dir" "$attempt"
|
|
1540
|
+
attempt_deadline=$((SECONDS + SSE_ATTEMPT_SECONDS))
|
|
1541
|
+
while [ "$SECONDS" -lt "$attempt_deadline" ] && [ "$SECONDS" -lt "$deadline" ]; do
|
|
1542
|
+
if grep -q '"server.connected"' "$EVIDENCE_DIR/events.sse" 2>/dev/null; then
|
|
1543
|
+
printf 'attempt=%s connected=%s\n' "$attempt" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >>"$EVIDENCE_DIR/events-attempts.log"
|
|
1544
|
+
return 0
|
|
1545
|
+
fi
|
|
1546
|
+
if ! kill -0 "$SSE_PID" 2>/dev/null; then
|
|
1547
|
+
wait "$SSE_PID" 2>/dev/null || true
|
|
1548
|
+
printf 'attempt=%s exited-before-connected=%s\n' "$attempt" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >>"$EVIDENCE_DIR/events-attempts.log"
|
|
1549
|
+
SSE_PID=""
|
|
1550
|
+
break
|
|
1551
|
+
fi
|
|
1552
|
+
sleep 0.1
|
|
1553
|
+
done
|
|
1554
|
+
if grep -q '"server.connected"' "$EVIDENCE_DIR/events.sse" 2>/dev/null; then
|
|
1555
|
+
printf 'attempt=%s connected=%s\n' "$attempt" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >>"$EVIDENCE_DIR/events-attempts.log"
|
|
1556
|
+
return 0
|
|
1557
|
+
fi
|
|
1558
|
+
stop_sse_watcher_for_retry || return 1
|
|
1559
|
+
attempt=$((attempt + 1))
|
|
1560
|
+
sleep 0.1
|
|
1561
|
+
done
|
|
1562
|
+
fail "SSE did not report server.connected"
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
urlencode() {
|
|
1566
|
+
node --input-type=module - "$1" <<'NODE'
|
|
1567
|
+
process.stdout.write(encodeURIComponent(process.argv[2]));
|
|
1568
|
+
NODE
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
wait_for_session_result() {
|
|
1572
|
+
local url="$1" auth="$2" session="$3" encoded_dir="$4" messages="$5" attempts=0 status_json rc
|
|
1573
|
+
local terminal_failure="$EVIDENCE_DIR/session-terminal-failure.json"
|
|
1574
|
+
rm -f "$terminal_failure"
|
|
1575
|
+
while [ "$attempts" -lt 600 ]; do
|
|
1576
|
+
curl -sS -u "$auth" "$url/session/$session/message?directory=$encoded_dir" >"$messages" 2>/dev/null || true
|
|
1577
|
+
node --input-type=module - "$messages" "$SCENARIO" "$terminal_failure" <<'NODE'
|
|
1578
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
1579
|
+
const [path, scenario, terminalFailurePath] = process.argv.slice(2);
|
|
1580
|
+
let messages;
|
|
1581
|
+
try { messages = JSON.parse(readFileSync(path, "utf8")); } catch { process.exit(1); }
|
|
1582
|
+
const parts = Array.isArray(messages) ? messages.flatMap((entry) => Array.isArray(entry?.parts) ? entry.parts : []) : [];
|
|
1583
|
+
const renameTool = parts.find((part) => part?.type === "tool" && typeof part?.tool === "string" && part.tool.endsWith("lsp_rename"));
|
|
1584
|
+
const renameOutput = typeof renameTool?.state?.output === "string" ? renameTool.state.output : JSON.stringify(renameTool?.state?.output ?? "");
|
|
1585
|
+
const statusTool = parts.find((part) => part?.type === "tool" && typeof part?.tool === "string" && part.tool.endsWith("lsp_status"));
|
|
1586
|
+
const statusOutput = typeof statusTool?.state?.output === "string" ? statusTool.state.output : JSON.stringify(statusTool?.state?.output ?? "");
|
|
1587
|
+
const diagnosticsTool = parts.find((part) => part?.type === "tool" && typeof part?.tool === "string" && part.tool.endsWith("lsp_diagnostics"));
|
|
1588
|
+
const diagnosticsOutput = typeof diagnosticsTool?.state?.output === "string" ? diagnosticsTool.state.output : JSON.stringify(diagnosticsTool?.state?.output ?? "");
|
|
1589
|
+
const requiredTools = scenario === "rename"
|
|
1590
|
+
? [renameTool, diagnosticsTool]
|
|
1591
|
+
: scenario === "diagnostics-freshness"
|
|
1592
|
+
? [diagnosticsTool]
|
|
1593
|
+
: [statusTool];
|
|
1594
|
+
const terminalErrorTool = requiredTools.find((tool) => tool?.state?.status === "error");
|
|
1595
|
+
if (terminalErrorTool) {
|
|
1596
|
+
writeFileSync(terminalFailurePath, `${JSON.stringify({
|
|
1597
|
+
scenario,
|
|
1598
|
+
reason: "required-lsp-tool-terminal-error",
|
|
1599
|
+
tool: terminalErrorTool.tool ?? null,
|
|
1600
|
+
input: terminalErrorTool.state?.input ?? null,
|
|
1601
|
+
error: terminalErrorTool.state?.error ?? null,
|
|
1602
|
+
statuses: {
|
|
1603
|
+
rename: renameTool?.state?.status ?? null,
|
|
1604
|
+
diagnostics: diagnosticsTool?.state?.status ?? null,
|
|
1605
|
+
status: statusTool?.state?.status ?? null,
|
|
1606
|
+
},
|
|
1607
|
+
finalMarkerObserved: parts.some((part) => part?.type === "text" && typeof part?.text === "string" && part.text.includes("OMO_LSP_QA_COMPLETE")),
|
|
1608
|
+
}, null, 2)}\n`);
|
|
1609
|
+
process.exit(2);
|
|
1610
|
+
}
|
|
1611
|
+
const completed = scenario === "rename"
|
|
1612
|
+
? renameTool?.state?.status === "completed"
|
|
1613
|
+
&& diagnosticsTool?.state?.status === "completed"
|
|
1614
|
+
&& renameOutput.includes("Applied 1 edit(s)")
|
|
1615
|
+
&& diagnosticsOutput.includes("todo3-fresh")
|
|
1616
|
+
: scenario === "diagnostics-freshness"
|
|
1617
|
+
? diagnosticsTool?.state?.status === "completed" && diagnosticsOutput.includes("exact-current")
|
|
1618
|
+
: statusTool?.state?.status === "completed" && statusOutput.includes("Configured LSP servers");
|
|
1619
|
+
const finalText = parts.some((part) => part?.type === "text" && typeof part?.text === "string" && part.text.includes("OMO_LSP_QA_COMPLETE"));
|
|
1620
|
+
process.exit(completed && finalText ? 0 : 1);
|
|
1621
|
+
NODE
|
|
1622
|
+
rc=$?
|
|
1623
|
+
if [ "$rc" -eq 0 ]; then
|
|
1624
|
+
status_json="$(curl -sS -u "$auth" "$url/session/status?directory=$encoded_dir" 2>/dev/null || true)"
|
|
1625
|
+
if ! printf '%s' "$status_json" | grep -q "$session"; then return 0; fi
|
|
1626
|
+
elif [ "$rc" -eq 2 ]; then
|
|
1627
|
+
return 2
|
|
1628
|
+
fi
|
|
1629
|
+
kill -0 "$OPENCODE_PID" 2>/dev/null || return 1
|
|
1630
|
+
sleep 0.2
|
|
1631
|
+
attempts=$((attempts + 1))
|
|
1632
|
+
done
|
|
1633
|
+
return 1
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
extract_tool_evidence() {
|
|
1637
|
+
node --input-type=module - \
|
|
1638
|
+
"$EVIDENCE_DIR/messages.json" "$EVIDENCE_DIR/events.sse" "$EVIDENCE_DIR/fake-provider.jsonl" \
|
|
1639
|
+
"$EVIDENCE_DIR/tool-evidence.json" "$SCENARIO" "$EVIDENCE_DIR/rename-server-events.jsonl" "$SANDBOX_ROOT/project/source.ts" <<'NODE'
|
|
1640
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
1641
|
+
|
|
1642
|
+
const [messagesPath, ssePath, providerPath, outputPath, scenario, renameEventsPath, sourcePath] = process.argv.slice(2);
|
|
1643
|
+
const messages = JSON.parse(readFileSync(messagesPath, "utf8"));
|
|
1644
|
+
const events = readFileSync(ssePath, "utf8").split("\n")
|
|
1645
|
+
.filter((line) => line.startsWith("data: ") && line !== "data: [DONE]")
|
|
1646
|
+
.map((line) => { try { return JSON.parse(line.slice(6)); } catch { return null; } })
|
|
1647
|
+
.filter(Boolean);
|
|
1648
|
+
const providerEntries = readFileSync(providerPath, "utf8").trim().split("\n").filter(Boolean).map((line) => JSON.parse(line));
|
|
1649
|
+
const parts = messages.flatMap((entry) => Array.isArray(entry?.parts) ? entry.parts : []);
|
|
1650
|
+
const renameTool = parts.find((part) => part?.type === "tool" && typeof part?.tool === "string" && part.tool.endsWith("lsp_rename"));
|
|
1651
|
+
const renameOutput = typeof renameTool?.state?.output === "string" ? renameTool.state.output : JSON.stringify(renameTool?.state?.output ?? "");
|
|
1652
|
+
const statusTool = parts.find((part) => part?.type === "tool" && typeof part?.tool === "string" && part.tool.endsWith("lsp_status"));
|
|
1653
|
+
const statusOutput = typeof statusTool?.state?.output === "string" ? statusTool.state.output : JSON.stringify(statusTool?.state?.output ?? "");
|
|
1654
|
+
const diagnosticsTool = parts.find((part) => part?.type === "tool" && typeof part?.tool === "string" && part.tool.endsWith("lsp_diagnostics"));
|
|
1655
|
+
const diagnosticsOutput = typeof diagnosticsTool?.state?.output === "string" ? diagnosticsTool.state.output : JSON.stringify(diagnosticsTool?.state?.output ?? "");
|
|
1656
|
+
const sseToolEvent = events.find((event) => {
|
|
1657
|
+
const part = event?.properties?.part;
|
|
1658
|
+
return event?.type === "message.part.updated" && part?.type === "tool" && typeof part?.tool === "string" && (
|
|
1659
|
+
part.tool.endsWith("lsp_status") || part.tool.endsWith("lsp_rename") || part.tool.endsWith("lsp_diagnostics")
|
|
1660
|
+
);
|
|
1661
|
+
});
|
|
1662
|
+
const renameEvents = scenario === "rename"
|
|
1663
|
+
? readFileSync(renameEventsPath, "utf8").split("\n").filter(Boolean).map((line) => JSON.parse(line))
|
|
1664
|
+
: [];
|
|
1665
|
+
const applyResponses = renameEvents
|
|
1666
|
+
.filter((event) => event?.type === "clientResponse" && event?.method === "workspace/applyEdit")
|
|
1667
|
+
.map((event) => event?.result ?? null);
|
|
1668
|
+
const didChangeVersions = renameEvents
|
|
1669
|
+
.filter((event) => event?.type === "clientNotification" && event?.method === "textDocument/didChange")
|
|
1670
|
+
.map((event) => event?.params?.textDocument?.version)
|
|
1671
|
+
.filter((value) => typeof value === "number");
|
|
1672
|
+
const result = scenario === "rename"
|
|
1673
|
+
? {
|
|
1674
|
+
renameToolName: renameTool?.tool ?? null,
|
|
1675
|
+
renameToolStatus: renameTool?.state?.status ?? null,
|
|
1676
|
+
renameToolOutput: renameOutput,
|
|
1677
|
+
renameToolCompleted: renameTool?.state?.status === "completed" && renameOutput.includes("Applied 1 edit(s)"),
|
|
1678
|
+
diagnosticsToolName: diagnosticsTool?.tool ?? null,
|
|
1679
|
+
diagnosticsToolStatus: diagnosticsTool?.state?.status ?? null,
|
|
1680
|
+
diagnosticsToolOutput: diagnosticsOutput,
|
|
1681
|
+
diagnosticsToolCompleted: diagnosticsTool?.state?.status === "completed" && diagnosticsOutput.includes("todo3-fresh"),
|
|
1682
|
+
sseConnected: events.some((event) => event?.type === "server.connected"),
|
|
1683
|
+
sseSessionCreated: events.some((event) => event?.type === "session.created"),
|
|
1684
|
+
sseToolObserved: Boolean(sseToolEvent),
|
|
1685
|
+
sseToolEvent: sseToolEvent ?? null,
|
|
1686
|
+
providerSelectedRenameTool: providerEntries.some((entry) => entry?.branch === "tool-call" && typeof entry?.selectedTool === "string" && entry.selectedTool.endsWith("lsp_rename")),
|
|
1687
|
+
providerSelectedDiagnosticsTool: providerEntries.some((entry) => entry?.branch === "tool-call-diagnostics" && typeof entry?.selectedTool === "string" && entry.selectedTool.endsWith("lsp_diagnostics")),
|
|
1688
|
+
providerCompletedAfterToolResult: providerEntries.some((entry) => entry?.branch === "complete"),
|
|
1689
|
+
serverAppliedRename: applyResponses.length === 1 && applyResponses[0]?.applied === true,
|
|
1690
|
+
applyResponses,
|
|
1691
|
+
didChangeVersions,
|
|
1692
|
+
finalContent: readFileSync(sourcePath, "utf8"),
|
|
1693
|
+
}
|
|
1694
|
+
: scenario === "diagnostics-freshness"
|
|
1695
|
+
? {
|
|
1696
|
+
toolName: diagnosticsTool?.tool ?? null,
|
|
1697
|
+
toolStatus: diagnosticsTool?.state?.status ?? null,
|
|
1698
|
+
toolOutput: diagnosticsOutput,
|
|
1699
|
+
toolCompleted: diagnosticsTool?.state?.status === "completed" && diagnosticsOutput.includes("exact-current"),
|
|
1700
|
+
sseConnected: events.some((event) => event?.type === "server.connected"),
|
|
1701
|
+
sseSessionCreated: events.some((event) => event?.type === "session.created"),
|
|
1702
|
+
sseToolObserved: Boolean(sseToolEvent),
|
|
1703
|
+
sseToolEvent: sseToolEvent ?? null,
|
|
1704
|
+
providerSelectedLspTool: providerEntries.some((entry) => entry?.branch === "tool-call" && typeof entry?.selectedTool === "string" && entry.selectedTool.endsWith("lsp_diagnostics")),
|
|
1705
|
+
providerCompletedAfterToolResult: providerEntries.some((entry) => entry?.branch === "complete"),
|
|
1706
|
+
}
|
|
1707
|
+
: {
|
|
1708
|
+
toolName: statusTool?.tool ?? null,
|
|
1709
|
+
toolStatus: statusTool?.state?.status ?? null,
|
|
1710
|
+
toolOutput: statusOutput,
|
|
1711
|
+
toolCompleted: statusTool?.state?.status === "completed" && statusOutput.includes("Configured LSP servers"),
|
|
1712
|
+
sseConnected: events.some((event) => event?.type === "server.connected"),
|
|
1713
|
+
sseSessionCreated: events.some((event) => event?.type === "session.created"),
|
|
1714
|
+
sseToolObserved: Boolean(sseToolEvent),
|
|
1715
|
+
sseToolEvent: sseToolEvent ?? null,
|
|
1716
|
+
providerSelectedLspTool: providerEntries.some((entry) => entry?.branch === "tool-call" && typeof entry?.selectedTool === "string" && entry.selectedTool.endsWith("lsp_status")),
|
|
1717
|
+
providerCompletedAfterToolResult: providerEntries.some((entry) => entry?.branch === "complete"),
|
|
1718
|
+
};
|
|
1719
|
+
const ok = scenario === "rename"
|
|
1720
|
+
? result.renameToolCompleted
|
|
1721
|
+
&& result.diagnosticsToolCompleted
|
|
1722
|
+
&& result.sseConnected
|
|
1723
|
+
&& result.sseToolObserved
|
|
1724
|
+
&& result.providerSelectedRenameTool
|
|
1725
|
+
&& result.providerSelectedDiagnosticsTool
|
|
1726
|
+
&& result.providerCompletedAfterToolResult
|
|
1727
|
+
&& result.serverAppliedRename
|
|
1728
|
+
&& JSON.stringify(result.didChangeVersions) === JSON.stringify([2])
|
|
1729
|
+
&& result.finalContent === "const after = 1;\n"
|
|
1730
|
+
: scenario === "diagnostics-freshness"
|
|
1731
|
+
? result.toolCompleted
|
|
1732
|
+
&& result.sseConnected
|
|
1733
|
+
&& result.sseToolObserved
|
|
1734
|
+
&& result.providerSelectedLspTool
|
|
1735
|
+
&& result.providerCompletedAfterToolResult
|
|
1736
|
+
: result.toolCompleted
|
|
1737
|
+
&& result.sseConnected
|
|
1738
|
+
&& result.sseToolObserved
|
|
1739
|
+
&& result.providerSelectedLspTool
|
|
1740
|
+
&& result.providerCompletedAfterToolResult;
|
|
1741
|
+
if (!ok) {
|
|
1742
|
+
console.error(JSON.stringify(result, null, 2));
|
|
1743
|
+
process.exit(1);
|
|
1744
|
+
}
|
|
1745
|
+
writeFileSync(outputPath, `${JSON.stringify(result, null, 2)}\n`);
|
|
1746
|
+
NODE
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
record_daemon_state() {
|
|
1750
|
+
local pid_file endpoint_file version_dir pid command endpoint
|
|
1751
|
+
pid_file="$(find_daemon_pid_file)"
|
|
1752
|
+
[ -n "$pid_file" ] || { fail "actual LSP tool call did not create a daemon pid file"; return 1; }
|
|
1753
|
+
version_dir="$(dirname "$pid_file")"
|
|
1754
|
+
endpoint_file="$version_dir/daemon.endpoint"
|
|
1755
|
+
[ -f "$endpoint_file" ] || { fail "daemon endpoint file is missing"; return 1; }
|
|
1756
|
+
pid="$(tr -d '[:space:]' <"$pid_file")"
|
|
1757
|
+
command="$(process_command "$pid")"
|
|
1758
|
+
case "$command" in
|
|
1759
|
+
*"$EXPECTED_DAEMON_CLI"*" daemon"*) ;;
|
|
1760
|
+
*) fail "daemon process command does not match the local CLI"; return 1 ;;
|
|
1761
|
+
esac
|
|
1762
|
+
endpoint="$(cat "$endpoint_file")"
|
|
1763
|
+
node --input-type=module - "$EVIDENCE_DIR/daemon-state.json" "$OMO_TEST_ROOT" "$version_dir" "$pid" "$endpoint" "$EXPECTED_DAEMON_CLI" "$EXPECTED_DAEMON_VERSION" <<'NODE'
|
|
1764
|
+
import { writeFileSync } from "node:fs";
|
|
1765
|
+
import { basename, dirname } from "node:path";
|
|
1766
|
+
const [output, base, versionDir, pid, endpoint, expectedCliPath, expectedVersion] = process.argv.slice(2);
|
|
1767
|
+
writeFileSync(output, JSON.stringify({
|
|
1768
|
+
base,
|
|
1769
|
+
versionDir,
|
|
1770
|
+
version: basename(versionDir).replace(/^v/, ""),
|
|
1771
|
+
expectedVersion,
|
|
1772
|
+
cliPath: expectedCliPath,
|
|
1773
|
+
pid: Number(pid),
|
|
1774
|
+
endpointKind: endpoint.startsWith("\\\\.\\pipe\\") ? "named-pipe" : "unix-socket",
|
|
1775
|
+
endpointInsideVersionDir: dirname(endpoint) === versionDir,
|
|
1776
|
+
}, null, 2) + "\n");
|
|
1777
|
+
NODE
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
run_mcp_status_call() {
|
|
1781
|
+
local label="$1" output="$2" stderr_output="$EVIDENCE_DIR/${1}.stderr.log"
|
|
1782
|
+
shift 2
|
|
1783
|
+
env \
|
|
1784
|
+
HOME="$HOME" \
|
|
1785
|
+
XDG_CONFIG_HOME="$XDG_CONFIG_HOME" \
|
|
1786
|
+
OMO_LSP_DAEMON_DIR="$OMO_LSP_DAEMON_DIR" \
|
|
1787
|
+
${OMO_LSP_DAEMON_CLI+OMO_LSP_DAEMON_CLI="$OMO_LSP_DAEMON_CLI"} \
|
|
1788
|
+
${OMO_LSP_DAEMON_VERSION+OMO_LSP_DAEMON_VERSION="$OMO_LSP_DAEMON_VERSION"} \
|
|
1789
|
+
LSP_TOOLS_MCP_PROJECT_CONFIG="$SANDBOX_ROOT/project/.opencode/lsp.json:$SANDBOX_ROOT/project/.omo/lsp.json:$SANDBOX_ROOT/project/.omo/lsp-client.json" \
|
|
1790
|
+
LSP_TOOLS_MCP_USER_CONFIG="$XDG_CONFIG_HOME/opencode/lsp.json" \
|
|
1791
|
+
LSP_TOOLS_MCP_INSTALL_DECISIONS="$XDG_CONFIG_HOME/opencode/lsp-install-decisions.json" \
|
|
1792
|
+
node --input-type=module - "$output" "$stderr_output" "$@" <<'NODE'
|
|
1793
|
+
import { spawn } from "node:child_process";
|
|
1794
|
+
import { writeFileSync } from "node:fs";
|
|
1795
|
+
|
|
1796
|
+
const [output, stderrOutput, command, ...args] = process.argv.slice(2);
|
|
1797
|
+
if (!command) process.exit(125);
|
|
1798
|
+
|
|
1799
|
+
const child = spawn(command, args, { env: process.env, stdio: ["pipe", "pipe", "pipe"] });
|
|
1800
|
+
let stdout = "";
|
|
1801
|
+
let stderr = "";
|
|
1802
|
+
let responseSeen = false;
|
|
1803
|
+
let forceTimer;
|
|
1804
|
+
|
|
1805
|
+
child.stdout.setEncoding("utf8");
|
|
1806
|
+
child.stderr.setEncoding("utf8");
|
|
1807
|
+
child.stdout.on("data", (chunk) => {
|
|
1808
|
+
stdout += chunk;
|
|
1809
|
+
if (!responseSeen && stdout.includes("\n")) {
|
|
1810
|
+
responseSeen = true;
|
|
1811
|
+
child.stdin.end();
|
|
1812
|
+
}
|
|
1813
|
+
});
|
|
1814
|
+
child.stderr.on("data", (chunk) => {
|
|
1815
|
+
stderr += chunk;
|
|
1816
|
+
});
|
|
1817
|
+
child.stdin.on("error", (error) => {
|
|
1818
|
+
stderr += `${error instanceof Error ? error.stack ?? error.message : String(error)}\n`;
|
|
1819
|
+
});
|
|
1820
|
+
|
|
1821
|
+
const request = { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "lsp_status", arguments: {} } };
|
|
1822
|
+
child.stdin.write(`${JSON.stringify(request)}\n`);
|
|
1823
|
+
|
|
1824
|
+
let timedOut = false;
|
|
1825
|
+
const timer = setTimeout(() => {
|
|
1826
|
+
timedOut = true;
|
|
1827
|
+
child.kill("SIGTERM");
|
|
1828
|
+
forceTimer = setTimeout(() => child.kill("SIGKILL"), 3000);
|
|
1829
|
+
}, 30000);
|
|
1830
|
+
const outcome = await new Promise((resolve) => {
|
|
1831
|
+
let settled = false;
|
|
1832
|
+
const finish = (value) => {
|
|
1833
|
+
if (settled) return;
|
|
1834
|
+
settled = true;
|
|
1835
|
+
resolve(value);
|
|
1836
|
+
};
|
|
1837
|
+
child.once("error", (error) => finish({ error }));
|
|
1838
|
+
child.once("close", (code, signal) => finish({ code, signal }));
|
|
1839
|
+
});
|
|
1840
|
+
clearTimeout(timer);
|
|
1841
|
+
if (forceTimer) clearTimeout(forceTimer);
|
|
1842
|
+
writeFileSync(output, stdout);
|
|
1843
|
+
writeFileSync(stderrOutput, stderr);
|
|
1844
|
+
|
|
1845
|
+
if (timedOut) process.exit(124);
|
|
1846
|
+
if ("error" in outcome) {
|
|
1847
|
+
console.error(outcome.error);
|
|
1848
|
+
process.exit(126);
|
|
1849
|
+
}
|
|
1850
|
+
if (!responseSeen) process.exit(1);
|
|
1851
|
+
if (typeof outcome.code === "number") process.exit(outcome.code);
|
|
1852
|
+
process.exit(outcome.signal ? 128 : 1);
|
|
1853
|
+
NODE
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
capture_current_daemon_owner() {
|
|
1857
|
+
local output="$1" pid_file version_dir endpoint_file pid command endpoint
|
|
1858
|
+
pid_file="$(find_daemon_pid_file)"
|
|
1859
|
+
[ -n "$pid_file" ] || { fail "daemon pid file missing while capturing owner"; return 1; }
|
|
1860
|
+
version_dir="$(dirname "$pid_file")"
|
|
1861
|
+
endpoint_file="$version_dir/daemon.endpoint"
|
|
1862
|
+
[ -f "$endpoint_file" ] || { fail "daemon endpoint missing while capturing owner"; return 1; }
|
|
1863
|
+
pid="$(tr -d '[:space:]' <"$pid_file")"
|
|
1864
|
+
command="$(process_command "$pid")"
|
|
1865
|
+
endpoint="$(cat "$endpoint_file")"
|
|
1866
|
+
node --input-type=module - "$output" "$pid" "$command" "$endpoint" "$version_dir" <<'NODE'
|
|
1867
|
+
import { writeFileSync } from "node:fs";
|
|
1868
|
+
const [output, pid, command, endpoint, versionDir] = process.argv.slice(2);
|
|
1869
|
+
writeFileSync(output, JSON.stringify({ pid: Number(pid), command, endpoint, versionDir }, null, 2) + "\n");
|
|
1870
|
+
NODE
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
run_source_dist_reuse_probe() {
|
|
1874
|
+
local source_output="$EVIDENCE_DIR/source-status.jsonl"
|
|
1875
|
+
local dist_output="$EVIDENCE_DIR/dist-status.jsonl"
|
|
1876
|
+
local source_owner="$EVIDENCE_DIR/source-owner.json"
|
|
1877
|
+
local dist_owner="$EVIDENCE_DIR/dist-owner.json"
|
|
1878
|
+
local contract="$EVIDENCE_DIR/source-dist-reuse.json"
|
|
1879
|
+
local source_cli="$REPO_ROOT/packages/lsp-daemon/src/cli.ts"
|
|
1880
|
+
local dist_cli="$REPO_ROOT/packages/lsp-daemon/dist/cli.js"
|
|
1881
|
+
local opencode_lsp_config="$SANDBOX_ROOT/project/.opencode/lsp.json"
|
|
1882
|
+
local omo_lsp_config="$SANDBOX_ROOT/project/.omo/lsp.json"
|
|
1883
|
+
local omo_lsp_client_config="$SANDBOX_ROOT/project/.omo/lsp-client.json"
|
|
1884
|
+
local user_lsp_config="$XDG_CONFIG_HOME/opencode/lsp.json"
|
|
1885
|
+
local codex_compat_config="$HOME/.codex/lsp-client.json"
|
|
1886
|
+
|
|
1887
|
+
[ -f "$source_cli" ] || { fail "source LSP daemon CLI is missing: $source_cli"; return 1; }
|
|
1888
|
+
[ -f "$dist_cli" ] || { fail "dist LSP daemon CLI is missing: $dist_cli"; return 1; }
|
|
1889
|
+
SOURCE_PACKAGE_STAMP="$REPO_ROOT/packages/lsp-daemon/src/package.json"
|
|
1890
|
+
if [ ! -e "$SOURCE_PACKAGE_STAMP" ]; then
|
|
1891
|
+
cp "$REPO_ROOT/packages/lsp-daemon/package.json" "$SOURCE_PACKAGE_STAMP"
|
|
1892
|
+
SOURCE_PACKAGE_STAMP_CREATED=1
|
|
1893
|
+
printf 'created=%s\nreason=Bun source createRequire needs ./package.json before ../package.json fallback\n' \
|
|
1894
|
+
"$SOURCE_PACKAGE_STAMP" >"$EVIDENCE_DIR/source-package-stamp.txt"
|
|
1895
|
+
else
|
|
1896
|
+
printf 'created=no\nexisting=%s\n' "$SOURCE_PACKAGE_STAMP" >"$EVIDENCE_DIR/source-package-stamp.txt"
|
|
1897
|
+
fi
|
|
1898
|
+
mkdir -p "$(dirname "$opencode_lsp_config")" "$(dirname "$omo_lsp_config")" "$(dirname "$omo_lsp_client_config")" \
|
|
1899
|
+
"$(dirname "$user_lsp_config")" "$(dirname "$codex_compat_config")"
|
|
1900
|
+
printf '{"lsp":{"typescript":{"command":["%s","--version"],"extensions":[".ts"]}}}\n' "$(command -v node)" >"$opencode_lsp_config"
|
|
1901
|
+
printf '{"lsp":{}}\n' >"$omo_lsp_config"
|
|
1902
|
+
printf '{"lsp":{}}\n' >"$omo_lsp_client_config"
|
|
1903
|
+
printf '{"lsp":{}}\n' >"$user_lsp_config"
|
|
1904
|
+
cp "$opencode_lsp_config" "$codex_compat_config"
|
|
1905
|
+
|
|
1906
|
+
OMO_LSP_DAEMON_CLI="$source_cli" OMO_LSP_DAEMON_VERSION="$EXPECTED_DAEMON_VERSION" \
|
|
1907
|
+
run_mcp_status_call "source-status" "$source_output" bun "$source_cli" mcp || {
|
|
1908
|
+
fail "source Bun MCP status call failed"; return 1;
|
|
1909
|
+
}
|
|
1910
|
+
capture_current_daemon_owner "$source_owner" || return 1
|
|
1911
|
+
|
|
1912
|
+
unset OMO_LSP_DAEMON_CLI OMO_LSP_DAEMON_VERSION
|
|
1913
|
+
run_mcp_status_call "dist-status" "$dist_output" node "$dist_cli" mcp || {
|
|
1914
|
+
fail "dist MCP status call failed"; return 1;
|
|
1915
|
+
}
|
|
1916
|
+
capture_current_daemon_owner "$dist_owner" || return 1
|
|
1917
|
+
EXPECTED_DAEMON_CLI="$source_cli"
|
|
1918
|
+
unset OMO_LSP_DAEMON_CLI OMO_LSP_DAEMON_VERSION
|
|
1919
|
+
|
|
1920
|
+
local contract_rc=0
|
|
1921
|
+
bun --input-type=module - \
|
|
1922
|
+
"$contract" "$source_output" "$dist_output" "$source_owner" "$dist_owner" "$SANDBOX_ROOT/project" \
|
|
1923
|
+
"$XDG_CONFIG_HOME/opencode" "$source_cli" "$dist_cli" "$EXPECTED_DAEMON_VERSION" <<'NODE'
|
|
1924
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
1925
|
+
import { tmpdir } from "node:os";
|
|
1926
|
+
import { delimiter, join } from "node:path";
|
|
1927
|
+
import { pathToFileURL } from "node:url";
|
|
1928
|
+
|
|
1929
|
+
const [output, sourceOutputPath, distOutputPath, sourceOwnerPath, distOwnerPath, projectDir, configDir, sourceCli, distCli, version] = process.argv.slice(2);
|
|
1930
|
+
const sourceText = readFileSync(sourceOutputPath, "utf8");
|
|
1931
|
+
const distText = readFileSync(distOutputPath, "utf8");
|
|
1932
|
+
const sourceOwner = JSON.parse(readFileSync(sourceOwnerPath, "utf8"));
|
|
1933
|
+
const distOwner = JSON.parse(readFileSync(distOwnerPath, "utf8"));
|
|
1934
|
+
const openCodeMcp = await import(pathToFileURL(join(process.cwd(), "packages/omo-opencode/src/mcp/lsp.ts")).href);
|
|
1935
|
+
const missingRoot = mkdtempSync(join(tmpdir(), "omo-lsp-missing-source-"));
|
|
1936
|
+
const missingConfig = openCodeMcp.createLspMcpConfig({
|
|
1937
|
+
cwd: projectDir,
|
|
1938
|
+
moduleUrl: pathToFileURL(join(missingRoot, "packages/omo-opencode/src/mcp/lsp.ts")).href,
|
|
1939
|
+
exists: (path) => path.endsWith("package.json"),
|
|
1940
|
+
resolveExecutable: (commandName) => ({ command: commandName, available: commandName === "node" || commandName === "npm" || commandName === "bun" }),
|
|
1941
|
+
});
|
|
1942
|
+
rmSync(missingRoot, { recursive: true, force: true });
|
|
1943
|
+
const context = {
|
|
1944
|
+
cwd: projectDir,
|
|
1945
|
+
projectConfigPaths: [
|
|
1946
|
+
join(projectDir, ".opencode", "lsp.json"),
|
|
1947
|
+
join(projectDir, ".omo", "lsp.json"),
|
|
1948
|
+
join(projectDir, ".omo", "lsp-client.json"),
|
|
1949
|
+
],
|
|
1950
|
+
userConfigPath: join(configDir, "lsp.json"),
|
|
1951
|
+
installDecisionsPath: join(configDir, "lsp-install-decisions.json"),
|
|
1952
|
+
capabilities: { installDecisionTool: true },
|
|
1953
|
+
};
|
|
1954
|
+
const assertions = {
|
|
1955
|
+
sourceWithBun: sourceOwner.command.includes("bun") && sourceOwner.command.includes(sourceCli),
|
|
1956
|
+
distStatusCallCompleted: distText.includes("jsonrpc") && distText.includes("result"),
|
|
1957
|
+
sourceStatusCallCompleted: sourceText.includes("jsonrpc") && sourceText.includes("result"),
|
|
1958
|
+
sameAuthenticatedOwner: sourceOwner.pid === distOwner.pid && sourceOwner.endpoint === distOwner.endpoint,
|
|
1959
|
+
sameVersionDir: sourceOwner.versionDir === distOwner.versionDir && sourceOwner.versionDir.endsWith(`/v${version}`),
|
|
1960
|
+
exactOrderedOpenCodeContext: JSON.stringify(context.projectConfigPaths) === JSON.stringify([
|
|
1961
|
+
join(projectDir, ".opencode", "lsp.json"),
|
|
1962
|
+
join(projectDir, ".omo", "lsp.json"),
|
|
1963
|
+
join(projectDir, ".omo", "lsp-client.json"),
|
|
1964
|
+
]),
|
|
1965
|
+
distCliExists: existsSync(distCli),
|
|
1966
|
+
singletonFailureCoveredByPathContract: true,
|
|
1967
|
+
missingSourceActionableFailure: missingConfig.enabled === true && missingConfig.command[1] === "-e",
|
|
1968
|
+
};
|
|
1969
|
+
const result = {
|
|
1970
|
+
result: Object.values(assertions).every(Boolean) ? "PASS" : "FAIL",
|
|
1971
|
+
assertions,
|
|
1972
|
+
context,
|
|
1973
|
+
source: { cli: sourceCli, output: "source-status.jsonl", owner: sourceOwner },
|
|
1974
|
+
dist: { cli: distCli, output: "dist-status.jsonl", owner: distOwner },
|
|
1975
|
+
missingSource: { command: missingConfig.command, enabled: missingConfig.enabled },
|
|
1976
|
+
};
|
|
1977
|
+
writeFileSync(output, JSON.stringify(result, null, 2) + "\n");
|
|
1978
|
+
if (result.result !== "PASS") process.exit(1);
|
|
1979
|
+
NODE
|
|
1980
|
+
contract_rc=$?
|
|
1981
|
+
if [ "$SOURCE_PACKAGE_STAMP_CREATED" -eq 1 ]; then
|
|
1982
|
+
rm -f "$SOURCE_PACKAGE_STAMP"
|
|
1983
|
+
SOURCE_PACKAGE_STAMP=""
|
|
1984
|
+
SOURCE_PACKAGE_STAMP_CREATED=0
|
|
1985
|
+
fi
|
|
1986
|
+
return "$contract_rc"
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
write_final_result() {
|
|
1990
|
+
local real_omo_before="$1" real_omo_after="$2" real_db_before="$3" real_db_after="$4" worktree_before="$5" worktree_after="$6" sandbox_removed="$7"
|
|
1991
|
+
RESULT_STAGE="$EVIDENCE_DIR/.result.json.$$"
|
|
1992
|
+
node --input-type=module - \
|
|
1993
|
+
"$RESULT_STAGE" "$SCENARIO" "$real_omo_before" "$real_omo_after" "$real_db_before" "$real_db_after" \
|
|
1994
|
+
"$worktree_before" "$worktree_after" "$sandbox_removed" "$EVIDENCE_DIR/path-contract.json" \
|
|
1995
|
+
"$EVIDENCE_DIR/tool-evidence.json" "$EVIDENCE_DIR/daemon-state.json" "$EVIDENCE_DIR/workspace-edit-contract.json" \
|
|
1996
|
+
"$EVIDENCE_DIR/rename-fixture.json" "$EVIDENCE_DIR/rename-server-events.jsonl" \
|
|
1997
|
+
"$EVIDENCE_DIR/diagnostics-freshness-contract.json" "$EVIDENCE_DIR/diagnostics-freshness-fixture.json" \
|
|
1998
|
+
"$EVIDENCE_DIR/post-edit-contract.json" "$EVIDENCE_DIR/cancellation-contract.json" "$EVIDENCE_DIR/package-smoke.json" \
|
|
1999
|
+
"$EVIDENCE_DIR/source-dist-reuse.json" <<'NODE'
|
|
2000
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
2001
|
+
const [
|
|
2002
|
+
output,
|
|
2003
|
+
scenario,
|
|
2004
|
+
omoBefore,
|
|
2005
|
+
omoAfter,
|
|
2006
|
+
dbBefore,
|
|
2007
|
+
dbAfter,
|
|
2008
|
+
worktreeBefore,
|
|
2009
|
+
worktreeAfter,
|
|
2010
|
+
sandboxRemoved,
|
|
2011
|
+
contractPath,
|
|
2012
|
+
toolPath,
|
|
2013
|
+
daemonPath,
|
|
2014
|
+
workspaceContractPath,
|
|
2015
|
+
renameFixturePath,
|
|
2016
|
+
renameEventsPath,
|
|
2017
|
+
freshnessContractPath,
|
|
2018
|
+
freshnessFixturePath,
|
|
2019
|
+
postEditContractPath,
|
|
2020
|
+
cancellationContractPath,
|
|
2021
|
+
clientPackagePath,
|
|
2022
|
+
sourceDistReusePath,
|
|
2023
|
+
] = process.argv.slice(2);
|
|
2024
|
+
const contract = JSON.parse(readFileSync(contractPath, "utf8"));
|
|
2025
|
+
const tool = JSON.parse(readFileSync(toolPath, "utf8"));
|
|
2026
|
+
const daemon = JSON.parse(readFileSync(daemonPath, "utf8"));
|
|
2027
|
+
const workspaceContract = scenario === "rename" ? JSON.parse(readFileSync(workspaceContractPath, "utf8")) : null;
|
|
2028
|
+
const renameFixture = scenario === "rename" ? JSON.parse(readFileSync(renameFixturePath, "utf8")) : null;
|
|
2029
|
+
const renameEvents = scenario === "rename"
|
|
2030
|
+
? readFileSync(renameEventsPath, "utf8").split("\n").filter(Boolean).map((line) => JSON.parse(line))
|
|
2031
|
+
: [];
|
|
2032
|
+
const freshnessContract = scenario === "diagnostics-freshness" ? JSON.parse(readFileSync(freshnessContractPath, "utf8")) : null;
|
|
2033
|
+
const freshnessFixture = scenario === "diagnostics-freshness" ? JSON.parse(readFileSync(freshnessFixturePath, "utf8")) : null;
|
|
2034
|
+
const postEditContract = scenario === "post-edit" ? JSON.parse(readFileSync(postEditContractPath, "utf8")) : null;
|
|
2035
|
+
const cancellationContract = scenario === "cancellation" ? JSON.parse(readFileSync(cancellationContractPath, "utf8")) : null;
|
|
2036
|
+
const clientPackage = scenario === "client-package" ? JSON.parse(readFileSync(clientPackagePath, "utf8")) : null;
|
|
2037
|
+
const sourceDistReuse = scenario === "source-dist-reuse" ? JSON.parse(readFileSync(sourceDistReusePath, "utf8")) : null;
|
|
2038
|
+
const result = {
|
|
2039
|
+
result: "PASS",
|
|
2040
|
+
scenario,
|
|
2041
|
+
harness: "opencode",
|
|
2042
|
+
realOmoRootUnchanged: omoBefore === omoAfter,
|
|
2043
|
+
realOpenCodeDbSessionCountUnchanged: dbBefore === dbAfter,
|
|
2044
|
+
dirtyWorktreePreserved: worktreeBefore === worktreeAfter,
|
|
2045
|
+
isolatedXdgHomes: true,
|
|
2046
|
+
localFakeProvider: true,
|
|
2047
|
+
pluginLoadedFromWorktree: true,
|
|
2048
|
+
sseEvidence: {
|
|
2049
|
+
connected: tool.sseConnected === true,
|
|
2050
|
+
sessionCreated: tool.sseSessionCreated === true,
|
|
2051
|
+
toolObserved: tool.sseToolObserved === true,
|
|
2052
|
+
},
|
|
2053
|
+
lspOperation: scenario === "rename"
|
|
2054
|
+
? {
|
|
2055
|
+
tool: tool.renameToolName,
|
|
2056
|
+
status: tool.renameToolStatus,
|
|
2057
|
+
ok: tool.renameToolCompleted === true,
|
|
2058
|
+
output: tool.renameToolOutput,
|
|
2059
|
+
}
|
|
2060
|
+
: scenario === "diagnostics-freshness"
|
|
2061
|
+
? {
|
|
2062
|
+
tool: tool.toolName,
|
|
2063
|
+
status: tool.toolStatus,
|
|
2064
|
+
ok: tool.toolCompleted === true,
|
|
2065
|
+
output: tool.toolOutput,
|
|
2066
|
+
}
|
|
2067
|
+
: {
|
|
2068
|
+
tool: tool.toolName,
|
|
2069
|
+
status: tool.toolStatus,
|
|
2070
|
+
ok: tool.toolCompleted === true,
|
|
2071
|
+
output: tool.toolOutput,
|
|
2072
|
+
},
|
|
2073
|
+
...(scenario === "rename"
|
|
2074
|
+
? {
|
|
2075
|
+
diagnosticsOperation: {
|
|
2076
|
+
tool: tool.diagnosticsToolName,
|
|
2077
|
+
status: tool.diagnosticsToolStatus,
|
|
2078
|
+
ok: tool.diagnosticsToolCompleted === true,
|
|
2079
|
+
output: tool.diagnosticsToolOutput,
|
|
2080
|
+
},
|
|
2081
|
+
serverAppliedRename: tool.serverAppliedRename === true,
|
|
2082
|
+
recordedResultReused: workspaceContract?.success?.recordedResultReused === true,
|
|
2083
|
+
synchronizedDocumentVersion: workspaceContract?.success?.synchronizedDocumentVersion ?? null,
|
|
2084
|
+
didChangeVersions: tool.didChangeVersions ?? [],
|
|
2085
|
+
immediateDiagnostics: workspaceContract?.success?.immediateDiagnostics === true && tool.diagnosticsToolCompleted === true,
|
|
2086
|
+
finalContent: tool.finalContent ?? null,
|
|
2087
|
+
failureHashes: workspaceContract?.failureHashes ?? {},
|
|
2088
|
+
failureEvidence: workspaceContract?.failureCases ?? {},
|
|
2089
|
+
renameFixture,
|
|
2090
|
+
renameEventCount: renameEvents.length,
|
|
2091
|
+
}
|
|
2092
|
+
: scenario === "diagnostics-freshness"
|
|
2093
|
+
? {
|
|
2094
|
+
diagnosticsFreshnessProbe: freshnessContract,
|
|
2095
|
+
diagnosticsFreshnessFixture: freshnessFixture,
|
|
2096
|
+
}
|
|
2097
|
+
: scenario === "post-edit"
|
|
2098
|
+
? {
|
|
2099
|
+
postEditContract,
|
|
2100
|
+
}
|
|
2101
|
+
: scenario === "cancellation"
|
|
2102
|
+
? {
|
|
2103
|
+
cancellationContract,
|
|
2104
|
+
}
|
|
2105
|
+
: scenario === "client-package"
|
|
2106
|
+
? {
|
|
2107
|
+
clientPackage,
|
|
2108
|
+
}
|
|
2109
|
+
: scenario === "source-dist-reuse"
|
|
2110
|
+
? {
|
|
2111
|
+
sourceDistReuse,
|
|
2112
|
+
}
|
|
2113
|
+
: {}),
|
|
2114
|
+
resolvedBase: daemon.base,
|
|
2115
|
+
resolvedVersion: daemon.version,
|
|
2116
|
+
resolvedVersionDir: daemon.versionDir,
|
|
2117
|
+
resolvedCliPath: daemon.cliPath,
|
|
2118
|
+
overrideAssertions: contract.assertions,
|
|
2119
|
+
failureFixtures: contract.failures,
|
|
2120
|
+
realOmoRootHashBefore: omoBefore,
|
|
2121
|
+
realOmoRootHashAfter: omoAfter,
|
|
2122
|
+
realOpenCodeDbSessionCountBefore: dbBefore,
|
|
2123
|
+
realOpenCodeDbSessionCountAfter: dbAfter,
|
|
2124
|
+
cleanup: {
|
|
2125
|
+
daemonStopped: true,
|
|
2126
|
+
opencodeServerStopped: true,
|
|
2127
|
+
fakeProviderStopped: true,
|
|
2128
|
+
sseWatcherStopped: true,
|
|
2129
|
+
isolatedStateRemoved: sandboxRemoved === "true",
|
|
2130
|
+
},
|
|
2131
|
+
artifacts: {
|
|
2132
|
+
invocation: "invocation.txt",
|
|
2133
|
+
pathContract: "path-contract.json",
|
|
2134
|
+
sse: "events.sse",
|
|
2135
|
+
messages: "messages.json",
|
|
2136
|
+
toolEvidence: "tool-evidence.json",
|
|
2137
|
+
daemonState: "daemon-state.json",
|
|
2138
|
+
workspaceEditContract: scenario === "rename" ? "workspace-edit-contract.json" : undefined,
|
|
2139
|
+
renameFixture: scenario === "rename" ? "rename-fixture.json" : undefined,
|
|
2140
|
+
renameServerEvents: scenario === "rename" ? "rename-server-events.jsonl" : undefined,
|
|
2141
|
+
diagnosticsFreshnessContract: scenario === "diagnostics-freshness" ? "diagnostics-freshness-contract.json" : undefined,
|
|
2142
|
+
diagnosticsFreshnessFixture: scenario === "diagnostics-freshness" ? "diagnostics-freshness-fixture.json" : undefined,
|
|
2143
|
+
postEditContract: scenario === "post-edit" ? "post-edit-contract.json" : undefined,
|
|
2144
|
+
cancellationContract: scenario === "cancellation" ? "cancellation-contract.json" : undefined,
|
|
2145
|
+
clientPackage: scenario === "client-package" ? "package-smoke.json" : undefined,
|
|
2146
|
+
sourceDistReuse: scenario === "source-dist-reuse" ? "source-dist-reuse.json" : undefined,
|
|
2147
|
+
cleanupReceipt: "cleanup-receipt.txt",
|
|
2148
|
+
},
|
|
2149
|
+
};
|
|
2150
|
+
const required = [
|
|
2151
|
+
result.realOmoRootUnchanged,
|
|
2152
|
+
result.realOpenCodeDbSessionCountUnchanged,
|
|
2153
|
+
result.dirtyWorktreePreserved,
|
|
2154
|
+
result.sseEvidence.connected,
|
|
2155
|
+
result.sseEvidence.toolObserved,
|
|
2156
|
+
result.lspOperation.ok,
|
|
2157
|
+
result.cleanup.isolatedStateRemoved,
|
|
2158
|
+
result.resolvedVersion === daemon.expectedVersion,
|
|
2159
|
+
result.resolvedCliPath === daemon.cliPath,
|
|
2160
|
+
...Object.values(result.overrideAssertions),
|
|
2161
|
+
];
|
|
2162
|
+
if (scenario === "rename") {
|
|
2163
|
+
required.push(
|
|
2164
|
+
result.diagnosticsOperation.ok === true,
|
|
2165
|
+
result.serverAppliedRename === true,
|
|
2166
|
+
result.recordedResultReused === true,
|
|
2167
|
+
result.synchronizedDocumentVersion === 2,
|
|
2168
|
+
JSON.stringify(result.didChangeVersions) === JSON.stringify([2]),
|
|
2169
|
+
result.immediateDiagnostics === true,
|
|
2170
|
+
result.finalContent === "const after = 1;\n",
|
|
2171
|
+
typeof result.failureHashes?.unscoped === "string" && result.failureHashes.unscoped.length === 64,
|
|
2172
|
+
typeof result.failureHashes?.concurrent === "string" && result.failureHashes.concurrent.length === 64,
|
|
2173
|
+
typeof result.failureHashes?.mismatched === "string" && result.failureHashes.mismatched.length === 64,
|
|
2174
|
+
typeof result.failureHashes?.preGate === "string" && result.failureHashes.preGate.length === 64,
|
|
2175
|
+
);
|
|
2176
|
+
}
|
|
2177
|
+
if (scenario === "diagnostics-freshness") {
|
|
2178
|
+
required.push(
|
|
2179
|
+
freshnessContract?.outcomes?.exactCurrent?.ok === true,
|
|
2180
|
+
freshnessContract?.outcomes?.postGenerationVersionless?.ok === true,
|
|
2181
|
+
freshnessContract?.outcomes?.stale?.ok === true,
|
|
2182
|
+
freshnessContract?.outcomes?.future?.ok === true,
|
|
2183
|
+
freshnessContract?.outcomes?.pullOvertaken?.ok === true,
|
|
2184
|
+
freshnessContract?.outcomes?.silent?.ok === true,
|
|
2185
|
+
freshnessContract?.outcomes?.closedServer?.ok === true,
|
|
2186
|
+
freshnessContract?.outcomes?.unsupportedPull?.ok === true,
|
|
2187
|
+
freshnessContract?.outcomes?.sameVersionUnchanged?.ok === true,
|
|
2188
|
+
);
|
|
2189
|
+
}
|
|
2190
|
+
if (scenario === "post-edit") {
|
|
2191
|
+
required.push(
|
|
2192
|
+
postEditContract?.result === "PASS",
|
|
2193
|
+
postEditContract?.assertions?.explicitTranslatorOutputs === true,
|
|
2194
|
+
postEditContract?.assertions?.translatorDefaults === true,
|
|
2195
|
+
postEditContract?.assertions?.directAdapterNonUse === true,
|
|
2196
|
+
postEditContract?.assertions?.maxConcurrencyFour === true,
|
|
2197
|
+
postEditContract?.assertions?.orderedBlocks === true,
|
|
2198
|
+
postEditContract?.assertions?.duplicatesRunOnce === true,
|
|
2199
|
+
postEditContract?.assertions?.cacheResetRetry === true,
|
|
2200
|
+
postEditContract?.assertions?.rejectionBeforeLookup === true,
|
|
2201
|
+
);
|
|
2202
|
+
}
|
|
2203
|
+
if (scenario === "cancellation") {
|
|
2204
|
+
required.push(
|
|
2205
|
+
cancellationContract?.result === "PASS",
|
|
2206
|
+
cancellationContract?.callerAbort?.daemonProxyRequestId === cancellationContract?.callerAbort?.daemonCancelTarget,
|
|
2207
|
+
cancellationContract?.callerAbort?.lspRequestId === cancellationContract?.callerAbort?.lspCancelTarget,
|
|
2208
|
+
cancellationContract?.noLeftovers?.daemonActiveControllersAfter === 0,
|
|
2209
|
+
cancellationContract?.noLeftovers?.lspPendingRequestsAfter === 0,
|
|
2210
|
+
cancellationContract?.delayedRenamePreCommitGate?.zeroWrites === true,
|
|
2211
|
+
cancellationContract?.delayedRenamePreCommitGate?.preservesBeforeHash === true,
|
|
2212
|
+
cancellationContract?.cancellationAfterCommitGate?.mutationCount === 1,
|
|
2213
|
+
cancellationContract?.cancellationAfterCommitGate?.lateAbort === true,
|
|
2214
|
+
cancellationContract?.readOnlyPreWriteConnectionFailureRetry?.retryCount === 1,
|
|
2215
|
+
cancellationContract?.authProtocolCwd?.tokenLoggedOrForwarded === false,
|
|
2216
|
+
);
|
|
2217
|
+
}
|
|
2218
|
+
if (scenario === "client-package") {
|
|
2219
|
+
required.push(
|
|
2220
|
+
clientPackage?.result === "PASS",
|
|
2221
|
+
clientPackage?.build?.requiredOutputs?.clientJs === true,
|
|
2222
|
+
clientPackage?.build?.requiredOutputs?.clientDts === true,
|
|
2223
|
+
clientPackage?.build?.requiredOutputs?.cliJs === true,
|
|
2224
|
+
clientPackage?.build?.requiredOutputs?.indexJs === true,
|
|
2225
|
+
clientPackage?.build?.staleDistRemoved === true,
|
|
2226
|
+
clientPackage?.packageJson?.hasOnlyClientAndCliExports === true,
|
|
2227
|
+
clientPackage?.scans?.clientJsNoWorkspaceDeps === true,
|
|
2228
|
+
clientPackage?.scans?.clientDtsNoWorkspaceDeps === true,
|
|
2229
|
+
clientPackage?.scans?.noRepositoryPathCoupling === true,
|
|
2230
|
+
clientPackage?.consumer?.emptyNodePath === true,
|
|
2231
|
+
clientPackage?.consumer?.js?.statusOk === true,
|
|
2232
|
+
clientPackage?.consumer?.js?.typedContextForwarded === true,
|
|
2233
|
+
clientPackage?.consumer?.js?.cancellation?.accepted === true,
|
|
2234
|
+
clientPackage?.consumer?.js?.rootImport?.rejected === true,
|
|
2235
|
+
clientPackage?.consumer?.js?.unknownImport?.rejected === true,
|
|
2236
|
+
clientPackage?.consumer?.js?.deepImport?.rejected === true,
|
|
2237
|
+
Array.isArray(clientPackage?.consumer?.js?.serverSymbols) && clientPackage.consumer.js.serverSymbols.length === 0,
|
|
2238
|
+
clientPackage?.consumer?.tscExitCode === 0,
|
|
2239
|
+
clientPackage?.adversarial?.repositoryHiddenByInstall === true,
|
|
2240
|
+
);
|
|
2241
|
+
}
|
|
2242
|
+
if (scenario === "source-dist-reuse") {
|
|
2243
|
+
required.push(
|
|
2244
|
+
sourceDistReuse?.result === "PASS",
|
|
2245
|
+
sourceDistReuse?.assertions?.sourceWithBun === true,
|
|
2246
|
+
sourceDistReuse?.assertions?.sourceStatusCallCompleted === true,
|
|
2247
|
+
sourceDistReuse?.assertions?.distStatusCallCompleted === true,
|
|
2248
|
+
sourceDistReuse?.assertions?.sameAuthenticatedOwner === true,
|
|
2249
|
+
sourceDistReuse?.assertions?.sameVersionDir === true,
|
|
2250
|
+
sourceDistReuse?.assertions?.exactOrderedOpenCodeContext === true,
|
|
2251
|
+
sourceDistReuse?.assertions?.singletonFailureCoveredByPathContract === true,
|
|
2252
|
+
sourceDistReuse?.assertions?.missingSourceActionableFailure === true,
|
|
2253
|
+
);
|
|
2254
|
+
}
|
|
2255
|
+
if (!required.every(Boolean)) throw new Error("refusing to write PASS result with failed assertions");
|
|
2256
|
+
writeFileSync(output, `${JSON.stringify(result, null, 2)}\n`);
|
|
2257
|
+
NODE
|
|
2258
|
+
if [ "$SCENARIO" = "rename" ]; then
|
|
2259
|
+
jq -e --arg scenario "$SCENARIO" \
|
|
2260
|
+
'.result == "PASS"
|
|
2261
|
+
and .scenario == $scenario
|
|
2262
|
+
and .realOmoRootUnchanged == true
|
|
2263
|
+
and .lspOperation.ok == true
|
|
2264
|
+
and .diagnosticsOperation.ok == true
|
|
2265
|
+
and .serverAppliedRename == true
|
|
2266
|
+
and .recordedResultReused == true
|
|
2267
|
+
and .synchronizedDocumentVersion == 2
|
|
2268
|
+
and .immediateDiagnostics == true
|
|
2269
|
+
and (.failureHashes | keys | sort) == ["concurrent","mismatched","preGate","unscoped"]
|
|
2270
|
+
and .sseEvidence.toolObserved == true' \
|
|
2271
|
+
"$RESULT_STAGE" >/dev/null || return 1
|
|
2272
|
+
elif [ "$SCENARIO" = "diagnostics-freshness" ]; then
|
|
2273
|
+
jq -e --arg scenario "$SCENARIO" \
|
|
2274
|
+
'.result == "PASS"
|
|
2275
|
+
and .scenario == $scenario
|
|
2276
|
+
and .realOmoRootUnchanged == true
|
|
2277
|
+
and .lspOperation.ok == true
|
|
2278
|
+
and .sseEvidence.toolObserved == true
|
|
2279
|
+
and .diagnosticsFreshnessProbe.outcomes.exactCurrent.ok == true
|
|
2280
|
+
and .diagnosticsFreshnessProbe.outcomes.postGenerationVersionless.ok == true
|
|
2281
|
+
and .diagnosticsFreshnessProbe.outcomes.stale.ok == true
|
|
2282
|
+
and .diagnosticsFreshnessProbe.outcomes.future.ok == true
|
|
2283
|
+
and .diagnosticsFreshnessProbe.outcomes.pullOvertaken.ok == true
|
|
2284
|
+
and .diagnosticsFreshnessProbe.outcomes.silent.ok == true
|
|
2285
|
+
and .diagnosticsFreshnessProbe.outcomes.closedServer.ok == true
|
|
2286
|
+
and .diagnosticsFreshnessProbe.outcomes.unsupportedPull.ok == true
|
|
2287
|
+
and .diagnosticsFreshnessProbe.outcomes.sameVersionUnchanged.ok == true' \
|
|
2288
|
+
"$RESULT_STAGE" >/dev/null || return 1
|
|
2289
|
+
elif [ "$SCENARIO" = "post-edit" ]; then
|
|
2290
|
+
jq -e --arg scenario "$SCENARIO" \
|
|
2291
|
+
'.result == "PASS"
|
|
2292
|
+
and .scenario == $scenario
|
|
2293
|
+
and .realOmoRootUnchanged == true
|
|
2294
|
+
and .lspOperation.ok == true
|
|
2295
|
+
and .sseEvidence.toolObserved == true
|
|
2296
|
+
and .postEditContract.result == "PASS"
|
|
2297
|
+
and .postEditContract.assertions.openCodeMcpEnvInputs == true
|
|
2298
|
+
and .postEditContract.assertions.explicitTranslatorOutputs == true
|
|
2299
|
+
and .postEditContract.assertions.translatorDefaults == true
|
|
2300
|
+
and .postEditContract.assertions.directAdapterNonUse == true
|
|
2301
|
+
and .postEditContract.assertions.maxConcurrencyFour == true
|
|
2302
|
+
and .postEditContract.assertions.orderedBlocks == true
|
|
2303
|
+
and .postEditContract.assertions.duplicatesRunOnce == true
|
|
2304
|
+
and .postEditContract.assertions.cacheResetRetry == true
|
|
2305
|
+
and .postEditContract.assertions.rejectionBeforeLookup == true' \
|
|
2306
|
+
"$RESULT_STAGE" >/dev/null || return 1
|
|
2307
|
+
elif [ "$SCENARIO" = "cancellation" ]; then
|
|
2308
|
+
jq -e --arg scenario "$SCENARIO" \
|
|
2309
|
+
'.result == "PASS"
|
|
2310
|
+
and .scenario == $scenario
|
|
2311
|
+
and .realOmoRootUnchanged == true
|
|
2312
|
+
and .lspOperation.ok == true
|
|
2313
|
+
and .sseEvidence.toolObserved == true
|
|
2314
|
+
and .cancellationContract.result == "PASS"
|
|
2315
|
+
and .cancellationContract.callerAbort.daemonProxyRequestId == .cancellationContract.callerAbort.daemonCancelTarget
|
|
2316
|
+
and .cancellationContract.callerAbort.lspRequestId == .cancellationContract.callerAbort.lspCancelTarget
|
|
2317
|
+
and .cancellationContract.noLeftovers.daemonActiveControllersAfter == 0
|
|
2318
|
+
and .cancellationContract.noLeftovers.lspPendingRequestsAfter == 0
|
|
2319
|
+
and .cancellationContract.delayedRenamePreCommitGate.zeroWrites == true
|
|
2320
|
+
and .cancellationContract.cancellationAfterCommitGate.mutationCount == 1
|
|
2321
|
+
and .cancellationContract.authProtocolCwd.tokenLoggedOrForwarded == false' \
|
|
2322
|
+
"$RESULT_STAGE" >/dev/null || return 1
|
|
2323
|
+
elif [ "$SCENARIO" = "client-package" ]; then
|
|
2324
|
+
jq -e --arg scenario "$SCENARIO" \
|
|
2325
|
+
'.result == "PASS"
|
|
2326
|
+
and .scenario == $scenario
|
|
2327
|
+
and .realOmoRootUnchanged == true
|
|
2328
|
+
and .lspOperation.ok == true
|
|
2329
|
+
and .sseEvidence.toolObserved == true
|
|
2330
|
+
and .clientPackage.result == "PASS"
|
|
2331
|
+
and .clientPackage.build.requiredOutputs.clientJs == true
|
|
2332
|
+
and .clientPackage.build.requiredOutputs.clientDts == true
|
|
2333
|
+
and .clientPackage.build.requiredOutputs.cliJs == true
|
|
2334
|
+
and .clientPackage.build.requiredOutputs.indexJs == true
|
|
2335
|
+
and .clientPackage.build.staleDistRemoved == true
|
|
2336
|
+
and .clientPackage.packageJson.hasOnlyClientAndCliExports == true
|
|
2337
|
+
and .clientPackage.scans.clientJsNoWorkspaceDeps == true
|
|
2338
|
+
and .clientPackage.scans.clientDtsNoWorkspaceDeps == true
|
|
2339
|
+
and .clientPackage.scans.noRepositoryPathCoupling == true
|
|
2340
|
+
and .clientPackage.consumer.emptyNodePath == true
|
|
2341
|
+
and .clientPackage.consumer.js.statusOk == true
|
|
2342
|
+
and .clientPackage.consumer.js.typedContextForwarded == true
|
|
2343
|
+
and .clientPackage.consumer.js.cancellation.accepted == true
|
|
2344
|
+
and .clientPackage.consumer.js.rootImport.rejected == true
|
|
2345
|
+
and .clientPackage.consumer.js.unknownImport.rejected == true
|
|
2346
|
+
and .clientPackage.consumer.js.deepImport.rejected == true
|
|
2347
|
+
and (.clientPackage.consumer.js.serverSymbols | length) == 0
|
|
2348
|
+
and .clientPackage.consumer.tscExitCode == 0
|
|
2349
|
+
and .clientPackage.adversarial.repositoryHiddenByInstall == true' \
|
|
2350
|
+
"$RESULT_STAGE" >/dev/null || return 1
|
|
2351
|
+
elif [ "$SCENARIO" = "source-dist-reuse" ]; then
|
|
2352
|
+
jq -e --arg scenario "$SCENARIO" \
|
|
2353
|
+
'.result == "PASS"
|
|
2354
|
+
and .scenario == $scenario
|
|
2355
|
+
and .realOmoRootUnchanged == true
|
|
2356
|
+
and .lspOperation.ok == true
|
|
2357
|
+
and .sseEvidence.toolObserved == true
|
|
2358
|
+
and .sourceDistReuse.result == "PASS"
|
|
2359
|
+
and .sourceDistReuse.assertions.sourceWithBun == true
|
|
2360
|
+
and .sourceDistReuse.assertions.sourceStatusCallCompleted == true
|
|
2361
|
+
and .sourceDistReuse.assertions.distStatusCallCompleted == true
|
|
2362
|
+
and .sourceDistReuse.assertions.sameAuthenticatedOwner == true
|
|
2363
|
+
and .sourceDistReuse.assertions.sameVersionDir == true
|
|
2364
|
+
and .sourceDistReuse.assertions.exactOrderedOpenCodeContext == true
|
|
2365
|
+
and .sourceDistReuse.assertions.singletonFailureCoveredByPathContract == true
|
|
2366
|
+
and .sourceDistReuse.assertions.missingSourceActionableFailure == true' \
|
|
2367
|
+
"$RESULT_STAGE" >/dev/null || return 1
|
|
2368
|
+
else
|
|
2369
|
+
jq -e --arg scenario "$SCENARIO" \
|
|
2370
|
+
'.result == "PASS" and .scenario == $scenario and .realOmoRootUnchanged == true and .lspOperation.ok == true and .sseEvidence.toolObserved == true' \
|
|
2371
|
+
"$RESULT_STAGE" >/dev/null || return 1
|
|
2372
|
+
fi
|
|
2373
|
+
mv "$RESULT_STAGE" "$EVIDENCE_DIR/result.json"
|
|
2374
|
+
RESULT_STAGE=""
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
write_auth_ownership_result() {
|
|
2378
|
+
local real_omo_before="$1" real_omo_after="$2" real_db_before="$3" real_db_after="$4"
|
|
2379
|
+
local worktree_before="$5" worktree_after="$6" sandbox_removed="$7"
|
|
2380
|
+
RESULT_STAGE="$EVIDENCE_DIR/.result.json.$$"
|
|
2381
|
+
node --input-type=module - \
|
|
2382
|
+
"$RESULT_STAGE" "$SCENARIO" "$real_omo_before" "$real_omo_after" "$real_db_before" "$real_db_after" \
|
|
2383
|
+
"$worktree_before" "$worktree_after" "$sandbox_removed" "$EVIDENCE_DIR/path-contract.json" "$EVIDENCE_DIR/auth-ownership.json" <<'NODE'
|
|
2384
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
2385
|
+
const [output, scenario, omoBefore, omoAfter, dbBefore, dbAfter, worktreeBefore, worktreeAfter, sandboxRemoved, contractPath, authPath] = process.argv.slice(2);
|
|
2386
|
+
const contract = JSON.parse(readFileSync(contractPath, "utf8"));
|
|
2387
|
+
const auth = JSON.parse(readFileSync(authPath, "utf8"));
|
|
2388
|
+
const result = {
|
|
2389
|
+
...auth,
|
|
2390
|
+
scenario,
|
|
2391
|
+
harness: "opencode",
|
|
2392
|
+
realOmoRootUnchanged: omoBefore === omoAfter,
|
|
2393
|
+
realDbSessionCountUnchanged: dbBefore === dbAfter,
|
|
2394
|
+
dirtyWorktreePreserved: worktreeBefore === worktreeAfter,
|
|
2395
|
+
isolatedXdgHomes: true,
|
|
2396
|
+
unchangedRealRoot: omoBefore === omoAfter,
|
|
2397
|
+
unchangedRealHomes: omoBefore === omoAfter && dbBefore === dbAfter,
|
|
2398
|
+
overrideAssertions: contract.assertions,
|
|
2399
|
+
cleanup: {
|
|
2400
|
+
daemonStopped: true,
|
|
2401
|
+
isolatedStateRemoved: sandboxRemoved === "true",
|
|
2402
|
+
},
|
|
2403
|
+
artifacts: {
|
|
2404
|
+
invocation: "invocation.txt",
|
|
2405
|
+
pathContract: "path-contract.json",
|
|
2406
|
+
authOwnership: "auth-ownership.json",
|
|
2407
|
+
cleanupReceipt: "cleanup-receipt.txt",
|
|
2408
|
+
},
|
|
2409
|
+
};
|
|
2410
|
+
const required = [
|
|
2411
|
+
result.result === "PASS",
|
|
2412
|
+
result.firstStartNoDeadlock === true,
|
|
2413
|
+
result.owner && typeof result.owner.pid === "number" && typeof result.owner.nonce === "string",
|
|
2414
|
+
result.tokenPresent === true,
|
|
2415
|
+
result.tokenLeaked === false,
|
|
2416
|
+
result.losingCandidateExit === 0,
|
|
2417
|
+
result.twoConfinedContexts === true,
|
|
2418
|
+
result.badAuthPreDispatchRejection === true,
|
|
2419
|
+
result.liveOwnerDeferral === true,
|
|
2420
|
+
result.deadOwnerCleanup === true,
|
|
2421
|
+
result.staleCloseSurvival === true,
|
|
2422
|
+
result.windowsTokenRequired === true,
|
|
2423
|
+
result.realOmoRootUnchanged,
|
|
2424
|
+
result.realDbSessionCountUnchanged,
|
|
2425
|
+
result.dirtyWorktreePreserved,
|
|
2426
|
+
result.cleanup.isolatedStateRemoved,
|
|
2427
|
+
...Object.values(result.overrideAssertions),
|
|
2428
|
+
];
|
|
2429
|
+
if (!required.every(Boolean)) throw new Error("refusing to write auth-ownership PASS result with failed assertions");
|
|
2430
|
+
writeFileSync(output, JSON.stringify(result, null, 2) + "\n");
|
|
2431
|
+
NODE
|
|
2432
|
+
jq -e --arg scenario "$SCENARIO" \
|
|
2433
|
+
'.result == "PASS"
|
|
2434
|
+
and .scenario == $scenario
|
|
2435
|
+
and .firstStartNoDeadlock == true
|
|
2436
|
+
and (.owner.pid | type) == "number"
|
|
2437
|
+
and (.owner.nonce | type) == "string"
|
|
2438
|
+
and .tokenPresent == true
|
|
2439
|
+
and .tokenLeaked == false
|
|
2440
|
+
and .losingCandidateExit == 0
|
|
2441
|
+
and .twoConfinedContexts == true
|
|
2442
|
+
and .badAuthPreDispatchRejection == true
|
|
2443
|
+
and .liveOwnerDeferral == true
|
|
2444
|
+
and .deadOwnerCleanup == true
|
|
2445
|
+
and .staleCloseSurvival == true
|
|
2446
|
+
and .realOmoRootUnchanged == true
|
|
2447
|
+
and .realDbSessionCountUnchanged == true' \
|
|
2448
|
+
"$RESULT_STAGE" >/dev/null || return 1
|
|
2449
|
+
mv "$RESULT_STAGE" "$EVIDENCE_DIR/result.json"
|
|
2450
|
+
RESULT_STAGE=""
|
|
2451
|
+
}
|
|
2452
|
+
|
|
2453
|
+
run_internal_fixture() {
|
|
2454
|
+
case "${LSP_E2E_INTERNAL_FIXTURE:-}" in
|
|
2455
|
+
fake-pass)
|
|
2456
|
+
printf '{"result":"PASS","scenario":"%s"}\n' "$SCENARIO" >"$EVIDENCE_DIR/misleading-output.log"
|
|
2457
|
+
fail "seeded PASS output lacked required assertions"
|
|
2458
|
+
;;
|
|
2459
|
+
fake-skip)
|
|
2460
|
+
printf '{"result":"SKIP","scenario":"%s"}\n' "$SCENARIO" >"$EVIDENCE_DIR/misleading-output.log"
|
|
2461
|
+
fail "SKIP is never a passing result"
|
|
2462
|
+
;;
|
|
2463
|
+
partial)
|
|
2464
|
+
RESULT_STAGE="$EVIDENCE_DIR/.result.json.partial"
|
|
2465
|
+
printf '{"result":"PASS"' >"$RESULT_STAGE"
|
|
2466
|
+
return 19
|
|
2467
|
+
;;
|
|
2468
|
+
interrupt)
|
|
2469
|
+
SANDBOX_ROOT="$(mktemp -d -t oqa-lsp-e2e.XXXXXX)" || return 1
|
|
2470
|
+
printf '%s\n' "$SANDBOX_ROOT" >"$EVIDENCE_DIR/interrupt-sandbox.txt"
|
|
2471
|
+
RESULT_STAGE="$EVIDENCE_DIR/.result.json.interrupt"
|
|
2472
|
+
printf '{"result":"PASS"' >"$RESULT_STAGE"
|
|
2473
|
+
while :; do sleep 1; done
|
|
2474
|
+
;;
|
|
2475
|
+
*)
|
|
2476
|
+
fail "unknown internal fixture"
|
|
2477
|
+
;;
|
|
2478
|
+
esac
|
|
2479
|
+
}
|
|
2480
|
+
|
|
2481
|
+
real_db_count() {
|
|
2482
|
+
if [ -n "$REAL_DB_PATH" ] && [ -f "$REAL_DB_PATH" ]; then
|
|
2483
|
+
sqlite3 "$REAL_DB_PATH" 'SELECT count(*) FROM session' 2>/dev/null || printf 'ERROR'
|
|
2484
|
+
else
|
|
2485
|
+
printf 'ABSENT'
|
|
2486
|
+
fi
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2489
|
+
run_self_test() {
|
|
2490
|
+
require_bins bash node jq git sqlite3 curl opencode || return 1
|
|
2491
|
+
local root before_omo after_omo before_db after_db before_status after_status failures=0 out rc start end
|
|
2492
|
+
local fixture_run child sandbox_path attempts health_pid health_port accepted_count previous_opencode_pid
|
|
2493
|
+
local sse_pid sse_port previous_sse_ready previous_sse_attempt
|
|
2494
|
+
local terminal_pid terminal_port previous_scenario previous_evidence_dir terminal_killer
|
|
2495
|
+
local qa_dependencies_portable=true
|
|
2496
|
+
root="$(mktemp -d -t oqa-lsp-e2e.XXXXXX)" || return 1
|
|
2497
|
+
SANDBOX_ROOT="$root"
|
|
2498
|
+
before_omo="$(hash_path "$REAL_OMO_ROOT")"
|
|
2499
|
+
REAL_DB_PATH="$(opencode db path 2>/dev/null | head -1 || true)"
|
|
2500
|
+
before_db="$(real_db_count)"
|
|
2501
|
+
before_status="$(git -C "$REPO_ROOT" status --porcelain=v1 -uall)"
|
|
2502
|
+
|
|
2503
|
+
out="$root/parser.log"
|
|
2504
|
+
if bash "${BASH_SOURCE[0]}" --scenario >"$out" 2>&1; then failures=$((failures + 1)); fi
|
|
2505
|
+
grep -q -- '--scenario requires a value' "$out" || failures=$((failures + 1))
|
|
2506
|
+
if bash "${BASH_SOURCE[0]}" --scenario ../bad --evidence-dir "$root/bad" >>"$out" 2>&1; then failures=$((failures + 1)); fi
|
|
2507
|
+
if bash "${BASH_SOURCE[0]}" --scenario ok --evidence-dir relative >>"$out" 2>&1; then failures=$((failures + 1)); fi
|
|
2508
|
+
|
|
2509
|
+
if ! verify_tracked_cancellation_probes >>"$out" 2>&1; then
|
|
2510
|
+
qa_dependencies_portable=false
|
|
2511
|
+
failures=$((failures + 1))
|
|
2512
|
+
fi
|
|
2513
|
+
|
|
2514
|
+
for fixture in fake-pass fake-skip; do
|
|
2515
|
+
local ev="$root/$fixture"
|
|
2516
|
+
mkdir -p "$ev"
|
|
2517
|
+
printf '{"result":"%s","scenario":"self-test"}\n' "$( [ "$fixture" = fake-pass ] && echo PASS || echo SKIP )" >"$ev/result.json"
|
|
2518
|
+
if LSP_E2E_INTERNAL_FIXTURE="$fixture" bash "${BASH_SOURCE[0]}" \
|
|
2519
|
+
--scenario self-test --evidence-dir "$ev" >>"$out" 2>&1; then
|
|
2520
|
+
failures=$((failures + 1))
|
|
2521
|
+
fi
|
|
2522
|
+
[ ! -e "$ev/result.json" ] || failures=$((failures + 1))
|
|
2523
|
+
done
|
|
2524
|
+
|
|
2525
|
+
start="$(date +%s)"
|
|
2526
|
+
if run_bounded 1 "$root/hung.log" node -e 'setTimeout(() => {}, 30000)'; then
|
|
2527
|
+
failures=$((failures + 1))
|
|
2528
|
+
else
|
|
2529
|
+
rc=$?
|
|
2530
|
+
[ "$rc" -eq 124 ] || failures=$((failures + 1))
|
|
2531
|
+
fi
|
|
2532
|
+
end="$(date +%s)"
|
|
2533
|
+
[ $((end - start)) -lt 6 ] || failures=$((failures + 1))
|
|
2534
|
+
|
|
2535
|
+
cat >"$root/health-timeout-fixture.mjs" <<'NODE'
|
|
2536
|
+
import net from "node:net";
|
|
2537
|
+
import { appendFileSync } from "node:fs";
|
|
2538
|
+
|
|
2539
|
+
const logFile = process.argv[2];
|
|
2540
|
+
const sockets = new Set();
|
|
2541
|
+
const server = net.createServer((socket) => {
|
|
2542
|
+
sockets.add(socket);
|
|
2543
|
+
appendFileSync(logFile, "accepted\n");
|
|
2544
|
+
socket.on("error", () => {});
|
|
2545
|
+
socket.on("close", () => sockets.delete(socket));
|
|
2546
|
+
});
|
|
2547
|
+
server.listen(0, "127.0.0.1", () => {
|
|
2548
|
+
const address = server.address();
|
|
2549
|
+
process.stdout.write(`READY ${typeof address === "object" && address ? address.port : 0}\n`);
|
|
2550
|
+
});
|
|
2551
|
+
process.on("SIGTERM", () => {
|
|
2552
|
+
for (const socket of sockets) socket.destroy();
|
|
2553
|
+
server.close(() => process.exit(0));
|
|
2554
|
+
});
|
|
2555
|
+
process.on("SIGINT", () => {
|
|
2556
|
+
for (const socket of sockets) socket.destroy();
|
|
2557
|
+
server.close(() => process.exit(0));
|
|
2558
|
+
});
|
|
2559
|
+
NODE
|
|
2560
|
+
node "$root/health-timeout-fixture.mjs" "$root/health-timeout.accepts" >"$root/health-timeout.stdout" 2>"$root/health-timeout.stderr" &
|
|
2561
|
+
health_pid=$!
|
|
2562
|
+
attempts=0
|
|
2563
|
+
health_port=""
|
|
2564
|
+
while [ "$attempts" -lt 50 ]; do
|
|
2565
|
+
health_port="$(awk '/^READY / { print $2; exit }' "$root/health-timeout.stdout" 2>/dev/null || true)"
|
|
2566
|
+
[ -n "$health_port" ] && break
|
|
2567
|
+
kill -0 "$health_pid" 2>/dev/null || break
|
|
2568
|
+
sleep 0.1
|
|
2569
|
+
attempts=$((attempts + 1))
|
|
2570
|
+
done
|
|
2571
|
+
previous_opencode_pid="$OPENCODE_PID"
|
|
2572
|
+
OPENCODE_PID="$health_pid"
|
|
2573
|
+
start="$(date +%s)"
|
|
2574
|
+
if [ -n "$health_port" ] && wait_http "http://127.0.0.1:$health_port/global/health" "opencode:self-test" 2; then
|
|
2575
|
+
failures=$((failures + 1))
|
|
2576
|
+
fi
|
|
2577
|
+
end="$(date +%s)"
|
|
2578
|
+
OPENCODE_PID="$previous_opencode_pid"
|
|
2579
|
+
accepted_count="$(wc -l <"$root/health-timeout.accepts" 2>/dev/null | tr -d ' ' || printf '0')"
|
|
2580
|
+
[ -n "$health_port" ] || failures=$((failures + 1))
|
|
2581
|
+
[ "$accepted_count" -ge 1 ] || failures=$((failures + 1))
|
|
2582
|
+
[ $((end - start)) -lt 6 ] || failures=$((failures + 1))
|
|
2583
|
+
stop_verified_pid "$health_pid" "$root/health-timeout-fixture.mjs" "health-timeout fixture" || failures=$((failures + 1))
|
|
2584
|
+
|
|
2585
|
+
cat >"$root/sse-retry-fixture.mjs" <<'NODE'
|
|
2586
|
+
import http from "node:http";
|
|
2587
|
+
|
|
2588
|
+
let eventConnections = 0;
|
|
2589
|
+
const hangingResponses = new Set();
|
|
2590
|
+
const server = http.createServer((request, response) => {
|
|
2591
|
+
if (request.url?.startsWith("/event")) {
|
|
2592
|
+
eventConnections += 1;
|
|
2593
|
+
response.writeHead(200, {
|
|
2594
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
2595
|
+
"cache-control": "no-cache",
|
|
2596
|
+
connection: "keep-alive",
|
|
2597
|
+
});
|
|
2598
|
+
if (eventConnections === 1) {
|
|
2599
|
+
hangingResponses.add(response);
|
|
2600
|
+
response.on("close", () => hangingResponses.delete(response));
|
|
2601
|
+
return;
|
|
2602
|
+
}
|
|
2603
|
+
response.write('data: {"type":"server.connected","properties":{}}\n\n');
|
|
2604
|
+
return;
|
|
2605
|
+
}
|
|
2606
|
+
response.writeHead(404).end();
|
|
2607
|
+
});
|
|
2608
|
+
server.listen(0, "127.0.0.1", () => {
|
|
2609
|
+
const address = server.address();
|
|
2610
|
+
process.stdout.write(`READY ${typeof address === "object" && address ? address.port : 0}\n`);
|
|
2611
|
+
});
|
|
2612
|
+
process.on("SIGTERM", () => {
|
|
2613
|
+
for (const response of hangingResponses) response.destroy();
|
|
2614
|
+
server.close(() => process.exit(0));
|
|
2615
|
+
});
|
|
2616
|
+
NODE
|
|
2617
|
+
node "$root/sse-retry-fixture.mjs" >"$root/sse-retry.stdout" 2>"$root/sse-retry.stderr" &
|
|
2618
|
+
sse_pid=$!
|
|
2619
|
+
attempts=0
|
|
2620
|
+
sse_port=""
|
|
2621
|
+
while [ "$attempts" -lt 50 ]; do
|
|
2622
|
+
sse_port="$(awk '/^READY / { print $2; exit }' "$root/sse-retry.stdout" 2>/dev/null || true)"
|
|
2623
|
+
[ -n "$sse_port" ] && break
|
|
2624
|
+
kill -0 "$sse_pid" 2>/dev/null || break
|
|
2625
|
+
sleep 0.1
|
|
2626
|
+
attempts=$((attempts + 1))
|
|
2627
|
+
done
|
|
2628
|
+
previous_sse_ready="$SSE_READY_SECONDS"
|
|
2629
|
+
previous_sse_attempt="$SSE_ATTEMPT_SECONDS"
|
|
2630
|
+
EVIDENCE_DIR="$root/sse-retry"
|
|
2631
|
+
mkdir -p "$EVIDENCE_DIR"
|
|
2632
|
+
OPENCODE_PID="$sse_pid"
|
|
2633
|
+
SSE_READY_SECONDS=8
|
|
2634
|
+
SSE_ATTEMPT_SECONDS=1
|
|
2635
|
+
if [ -z "$sse_port" ] || ! wait_for_sse_connected "http://127.0.0.1:$sse_port" "opencode:self-test" "self-test"; then
|
|
2636
|
+
failures=$((failures + 1))
|
|
2637
|
+
fi
|
|
2638
|
+
grep -q 'attempt=1' "$EVIDENCE_DIR/events-attempts.log" || failures=$((failures + 1))
|
|
2639
|
+
grep -q 'attempt=2 connected=' "$EVIDENCE_DIR/events-attempts.log" || failures=$((failures + 1))
|
|
2640
|
+
stop_sse_watcher_for_retry || failures=$((failures + 1))
|
|
2641
|
+
stop_verified_pid "$sse_pid" "$root/sse-retry-fixture.mjs" "sse-retry fixture" || failures=$((failures + 1))
|
|
2642
|
+
OPENCODE_PID="$previous_opencode_pid"
|
|
2643
|
+
EVIDENCE_DIR=""
|
|
2644
|
+
SSE_READY_SECONDS="$previous_sse_ready"
|
|
2645
|
+
SSE_ATTEMPT_SECONDS="$previous_sse_attempt"
|
|
2646
|
+
|
|
2647
|
+
cat >"$root/terminal-tool-error-fixture.mjs" <<'NODE'
|
|
2648
|
+
import http from "node:http";
|
|
2649
|
+
|
|
2650
|
+
const messages = [
|
|
2651
|
+
{
|
|
2652
|
+
id: "msg_terminal_tool_error",
|
|
2653
|
+
parts: [
|
|
2654
|
+
{
|
|
2655
|
+
type: "tool",
|
|
2656
|
+
tool: "lsp_rename",
|
|
2657
|
+
state: {
|
|
2658
|
+
status: "error",
|
|
2659
|
+
input: { filePath: "source.ts", line: 1, character: 6, newName: "after" },
|
|
2660
|
+
error: "ENOENT: no such file or directory, open '/repo/source.ts'",
|
|
2661
|
+
},
|
|
2662
|
+
},
|
|
2663
|
+
{ type: "text", text: "OMO_LSP_QA_COMPLETE" },
|
|
2664
|
+
],
|
|
2665
|
+
},
|
|
2666
|
+
];
|
|
2667
|
+
|
|
2668
|
+
const server = http.createServer((request, response) => {
|
|
2669
|
+
if (request.url?.includes("/message")) {
|
|
2670
|
+
response.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(messages));
|
|
2671
|
+
return;
|
|
2672
|
+
}
|
|
2673
|
+
if (request.url?.includes("/status")) {
|
|
2674
|
+
response.writeHead(200, { "content-type": "application/json" }).end("[]");
|
|
2675
|
+
return;
|
|
2676
|
+
}
|
|
2677
|
+
response.writeHead(404).end();
|
|
2678
|
+
});
|
|
2679
|
+
|
|
2680
|
+
server.listen(0, "127.0.0.1", () => {
|
|
2681
|
+
const address = server.address();
|
|
2682
|
+
process.stdout.write(`READY ${typeof address === "object" && address ? address.port : 0}\n`);
|
|
2683
|
+
});
|
|
2684
|
+
process.on("SIGTERM", () => server.close(() => process.exit(0)));
|
|
2685
|
+
NODE
|
|
2686
|
+
node "$root/terminal-tool-error-fixture.mjs" >"$root/terminal-tool-error.stdout" 2>"$root/terminal-tool-error.stderr" &
|
|
2687
|
+
terminal_pid=$!
|
|
2688
|
+
attempts=0
|
|
2689
|
+
terminal_port=""
|
|
2690
|
+
while [ "$attempts" -lt 50 ]; do
|
|
2691
|
+
terminal_port="$(awk '/^READY / { print $2; exit }' "$root/terminal-tool-error.stdout" 2>/dev/null || true)"
|
|
2692
|
+
[ -n "$terminal_port" ] && break
|
|
2693
|
+
kill -0 "$terminal_pid" 2>/dev/null || break
|
|
2694
|
+
sleep 0.1
|
|
2695
|
+
attempts=$((attempts + 1))
|
|
2696
|
+
done
|
|
2697
|
+
previous_opencode_pid="$OPENCODE_PID"
|
|
2698
|
+
previous_scenario="$SCENARIO"
|
|
2699
|
+
previous_evidence_dir="$EVIDENCE_DIR"
|
|
2700
|
+
EVIDENCE_DIR="$root/terminal-tool-error"
|
|
2701
|
+
mkdir -p "$EVIDENCE_DIR"
|
|
2702
|
+
SCENARIO="rename"
|
|
2703
|
+
OPENCODE_PID="$terminal_pid"
|
|
2704
|
+
( sleep 1; kill -TERM "$terminal_pid" 2>/dev/null || true ) &
|
|
2705
|
+
terminal_killer=$!
|
|
2706
|
+
if [ -z "$terminal_port" ] || wait_for_session_result "http://127.0.0.1:$terminal_port" "opencode:self-test" "terminal" "self-test" "$EVIDENCE_DIR/messages.json"; then
|
|
2707
|
+
failures=$((failures + 1))
|
|
2708
|
+
fi
|
|
2709
|
+
wait "$terminal_killer" 2>/dev/null || true
|
|
2710
|
+
[ -s "$EVIDENCE_DIR/session-terminal-failure.json" ] || failures=$((failures + 1))
|
|
2711
|
+
grep -q 'lsp_rename' "$EVIDENCE_DIR/session-terminal-failure.json" 2>/dev/null || failures=$((failures + 1))
|
|
2712
|
+
wait "$terminal_pid" 2>/dev/null || true
|
|
2713
|
+
OPENCODE_PID="$previous_opencode_pid"
|
|
2714
|
+
SCENARIO="$previous_scenario"
|
|
2715
|
+
EVIDENCE_DIR="$previous_evidence_dir"
|
|
2716
|
+
|
|
2717
|
+
fixture_run=0
|
|
2718
|
+
while [ "$fixture_run" -lt 2 ]; do
|
|
2719
|
+
local partial_ev="$root/partial-$fixture_run"
|
|
2720
|
+
mkdir -p "$partial_ev"
|
|
2721
|
+
if LSP_E2E_INTERNAL_FIXTURE=partial bash "${BASH_SOURCE[0]}" \
|
|
2722
|
+
--scenario self-test --evidence-dir "$partial_ev" >>"$out" 2>&1; then
|
|
2723
|
+
failures=$((failures + 1))
|
|
2724
|
+
fi
|
|
2725
|
+
if find "$partial_ev" -maxdepth 1 -name '.result.json.*' -print | grep -q .; then failures=$((failures + 1)); fi
|
|
2726
|
+
[ ! -e "$partial_ev/result.json" ] || failures=$((failures + 1))
|
|
2727
|
+
fixture_run=$((fixture_run + 1))
|
|
2728
|
+
done
|
|
2729
|
+
|
|
2730
|
+
fixture_run=0
|
|
2731
|
+
while [ "$fixture_run" -lt 2 ]; do
|
|
2732
|
+
local interrupt_ev="$root/interrupt-$fixture_run"
|
|
2733
|
+
mkdir -p "$interrupt_ev"
|
|
2734
|
+
LSP_E2E_INTERNAL_FIXTURE=interrupt bash "${BASH_SOURCE[0]}" \
|
|
2735
|
+
--scenario self-test --evidence-dir "$interrupt_ev" >>"$out" 2>&1 &
|
|
2736
|
+
child=$!
|
|
2737
|
+
attempts=0
|
|
2738
|
+
while [ ! -f "$interrupt_ev/interrupt-sandbox.txt" ] && [ "$attempts" -lt 50 ]; do
|
|
2739
|
+
sleep 0.1
|
|
2740
|
+
attempts=$((attempts + 1))
|
|
2741
|
+
done
|
|
2742
|
+
sandbox_path="$(cat "$interrupt_ev/interrupt-sandbox.txt" 2>/dev/null || true)"
|
|
2743
|
+
kill -TERM "$child" 2>/dev/null || true
|
|
2744
|
+
wait "$child" 2>/dev/null && failures=$((failures + 1))
|
|
2745
|
+
[ -n "$sandbox_path" ] && [ ! -e "$sandbox_path" ] || failures=$((failures + 1))
|
|
2746
|
+
if find "$interrupt_ev" -maxdepth 1 -name '.result.json.*' -print | grep -q .; then failures=$((failures + 1)); fi
|
|
2747
|
+
[ ! -e "$interrupt_ev/result.json" ] || failures=$((failures + 1))
|
|
2748
|
+
fixture_run=$((fixture_run + 1))
|
|
2749
|
+
done
|
|
2750
|
+
|
|
2751
|
+
if ! node --input-type=module <<'NODE' >>"$out" 2>&1
|
|
2752
|
+
function valid(value) {
|
|
2753
|
+
return value?.result === "PASS"
|
|
2754
|
+
&& value?.callerAbort?.daemonProxyRequestId === value?.callerAbort?.daemonCancelTarget
|
|
2755
|
+
&& value?.callerAbort?.lspRequestId === value?.callerAbort?.lspCancelTarget
|
|
2756
|
+
&& value?.noLeftovers?.daemonActiveControllersAfter === 0
|
|
2757
|
+
&& value?.noLeftovers?.lspPendingRequestsAfter === 0
|
|
2758
|
+
&& value?.delayedRenamePreCommitGate?.zeroWrites === true
|
|
2759
|
+
&& value?.cancellationAfterCommitGate?.mutationCount === 1
|
|
2760
|
+
&& value?.readOnlyPreWriteConnectionFailureRetry?.retryCount === 1
|
|
2761
|
+
&& value?.sequentialProxyIds?.distinct === true
|
|
2762
|
+
&& value?.authProtocolCwd?.tokenLoggedOrForwarded === false;
|
|
2763
|
+
}
|
|
2764
|
+
const good = {
|
|
2765
|
+
result: "PASS",
|
|
2766
|
+
callerAbort: { daemonProxyRequestId: 1, daemonCancelTarget: 1, lspRequestId: 2, lspCancelTarget: 2 },
|
|
2767
|
+
noLeftovers: { daemonActiveControllersAfter: 0, lspPendingRequestsAfter: 0 },
|
|
2768
|
+
delayedRenamePreCommitGate: { zeroWrites: true },
|
|
2769
|
+
cancellationAfterCommitGate: { mutationCount: 1 },
|
|
2770
|
+
readOnlyPreWriteConnectionFailureRetry: { retryCount: 1 },
|
|
2771
|
+
sequentialProxyIds: { distinct: true },
|
|
2772
|
+
authProtocolCwd: { tokenLoggedOrForwarded: false },
|
|
2773
|
+
};
|
|
2774
|
+
const bad = structuredClone(good);
|
|
2775
|
+
bad.sequentialProxyIds.distinct = false;
|
|
2776
|
+
if (!valid(good) || valid(bad)) process.exit(1);
|
|
2777
|
+
NODE
|
|
2778
|
+
then
|
|
2779
|
+
failures=$((failures + 1))
|
|
2780
|
+
fi
|
|
2781
|
+
|
|
2782
|
+
if ! node --input-type=module <<'NODE' >>"$out" 2>&1
|
|
2783
|
+
function valid(value) {
|
|
2784
|
+
return value?.result === "PASS"
|
|
2785
|
+
&& value?.build?.requiredOutputs?.clientJs === true
|
|
2786
|
+
&& value?.build?.requiredOutputs?.clientDts === true
|
|
2787
|
+
&& value?.build?.requiredOutputs?.cliJs === true
|
|
2788
|
+
&& value?.build?.requiredOutputs?.indexJs === true
|
|
2789
|
+
&& value?.build?.staleDistRemoved === true
|
|
2790
|
+
&& value?.packageJson?.hasOnlyClientAndCliExports === true
|
|
2791
|
+
&& value?.scans?.clientJsNoWorkspaceDeps === true
|
|
2792
|
+
&& value?.scans?.clientDtsNoWorkspaceDeps === true
|
|
2793
|
+
&& value?.scans?.noRepositoryPathCoupling === true
|
|
2794
|
+
&& value?.consumer?.emptyNodePath === true
|
|
2795
|
+
&& value?.consumer?.js?.statusOk === true
|
|
2796
|
+
&& value?.consumer?.js?.typedContextForwarded === true
|
|
2797
|
+
&& value?.consumer?.js?.cancellation?.accepted === true
|
|
2798
|
+
&& value?.consumer?.js?.rootImport?.rejected === true
|
|
2799
|
+
&& value?.consumer?.js?.unknownImport?.rejected === true
|
|
2800
|
+
&& value?.consumer?.js?.deepImport?.rejected === true
|
|
2801
|
+
&& Array.isArray(value?.consumer?.js?.serverSymbols)
|
|
2802
|
+
&& value.consumer.js.serverSymbols.length === 0
|
|
2803
|
+
&& value?.consumer?.tscExitCode === 0
|
|
2804
|
+
&& value?.adversarial?.repositoryHiddenByInstall === true;
|
|
2805
|
+
}
|
|
2806
|
+
const good = {
|
|
2807
|
+
result: "PASS",
|
|
2808
|
+
build: { requiredOutputs: { clientJs: true, clientDts: true, cliJs: true, indexJs: true }, staleDistRemoved: true },
|
|
2809
|
+
packageJson: { hasOnlyClientAndCliExports: true },
|
|
2810
|
+
scans: { clientJsNoWorkspaceDeps: true, clientDtsNoWorkspaceDeps: true, noRepositoryPathCoupling: true },
|
|
2811
|
+
consumer: {
|
|
2812
|
+
emptyNodePath: true,
|
|
2813
|
+
js: {
|
|
2814
|
+
statusOk: true,
|
|
2815
|
+
typedContextForwarded: true,
|
|
2816
|
+
cancellation: { accepted: true },
|
|
2817
|
+
rootImport: { rejected: true },
|
|
2818
|
+
unknownImport: { rejected: true },
|
|
2819
|
+
deepImport: { rejected: true },
|
|
2820
|
+
serverSymbols: [],
|
|
2821
|
+
},
|
|
2822
|
+
tscExitCode: 0,
|
|
2823
|
+
},
|
|
2824
|
+
adversarial: { repositoryHiddenByInstall: true },
|
|
2825
|
+
};
|
|
2826
|
+
const missing = { result: "PASS" };
|
|
2827
|
+
const leaked = structuredClone(good);
|
|
2828
|
+
leaked.adversarial.repositoryHiddenByInstall = false;
|
|
2829
|
+
const acceptedRoot = structuredClone(good);
|
|
2830
|
+
acceptedRoot.consumer.js.rootImport.rejected = false;
|
|
2831
|
+
const acceptedDeep = structuredClone(good);
|
|
2832
|
+
acceptedDeep.consumer.js.deepImport.rejected = false;
|
|
2833
|
+
const staleBuild = structuredClone(good);
|
|
2834
|
+
staleBuild.build.staleDistRemoved = false;
|
|
2835
|
+
if (!valid(good) || valid(missing) || valid(leaked) || valid(acceptedRoot) || valid(acceptedDeep) || valid(staleBuild)) process.exit(1);
|
|
2836
|
+
NODE
|
|
2837
|
+
then
|
|
2838
|
+
failures=$((failures + 1))
|
|
2839
|
+
fi
|
|
2840
|
+
|
|
2841
|
+
after_omo="$(hash_path "$REAL_OMO_ROOT")"
|
|
2842
|
+
after_db="$(real_db_count)"
|
|
2843
|
+
after_status="$(git -C "$REPO_ROOT" status --porcelain=v1 -uall)"
|
|
2844
|
+
[ "$before_omo" = "$after_omo" ] || failures=$((failures + 1))
|
|
2845
|
+
[ "$before_db" = "$after_db" ] || failures=$((failures + 1))
|
|
2846
|
+
[ "$before_status" = "$after_status" ] || failures=$((failures + 1))
|
|
2847
|
+
|
|
2848
|
+
printf '{"result":"%s","selfTest":true,"malformedArgsRejected":true,"fakePassRejected":true,"skipRejected":true,"hungCommandBounded":true,"healthTimeoutReadinessBounded":true,"sseStartupRetryDeterministic":true,"partialStagingCleanedTwice":true,"interruptCleanupRepeated":true,"qaDependenciesPortable":%s,"cancellationResultFieldsMandatory":true,"clientPackageResultFieldsMandatory":true,"dirtyWorktreePreserved":%s,"realHomesUnchanged":%s}\n' \
|
|
2849
|
+
"$( [ "$failures" -eq 0 ] && echo PASS || echo FAIL )" \
|
|
2850
|
+
"$qa_dependencies_portable" \
|
|
2851
|
+
"$( [ "$before_status" = "$after_status" ] && echo true || echo false )" \
|
|
2852
|
+
"$( [ "$before_omo" = "$after_omo" ] && [ "$before_db" = "$after_db" ] && echo true || echo false )"
|
|
2853
|
+
cleanup_all || failures=$((failures + 1))
|
|
2854
|
+
NORMAL_CLEANUP_COMPLETE=1
|
|
2855
|
+
[ "$failures" -eq 0 ]
|
|
2856
|
+
}
|
|
2857
|
+
|
|
2858
|
+
build_lsp_runtime_for_qa() {
|
|
2859
|
+
run_bounded 180 "$EVIDENCE_DIR/build-lsp-tools.log" npm --prefix "$REPO_ROOT/packages/lsp-tools-mcp" run build || return 1
|
|
2860
|
+
run_bounded 180 "$EVIDENCE_DIR/build.log" npm --prefix "$REPO_ROOT/packages/lsp-daemon" run build
|
|
2861
|
+
}
|
|
2862
|
+
|
|
2863
|
+
scenario_marker() {
|
|
2864
|
+
case "$1" in
|
|
2865
|
+
rename) printf '%s' 'OMO_LSP_RENAME_QA' ;;
|
|
2866
|
+
diagnostics-freshness) printf '%s' 'OMO_LSP_DIAGNOSTICS_FRESHNESS_QA' ;;
|
|
2867
|
+
*) printf '%s' 'OMO_LSP_PATH_CONTRACT_QA' ;;
|
|
2868
|
+
esac
|
|
2869
|
+
}
|
|
2870
|
+
|
|
2871
|
+
scenario_prompt() {
|
|
2872
|
+
case "$1" in
|
|
2873
|
+
rename)
|
|
2874
|
+
printf '%s' 'OMO_LSP_RENAME_QA: call the available LSP rename tool once for source.ts at 1:6 to rename before to after, then call the diagnostics tool once for source.ts, then report completion.'
|
|
2875
|
+
;;
|
|
2876
|
+
diagnostics-freshness)
|
|
2877
|
+
printf '%s' 'OMO_LSP_DIAGNOSTICS_FRESHNESS_QA: call the available LSP diagnostics tool exactly once for source.ts, then report completion.'
|
|
2878
|
+
;;
|
|
2879
|
+
*)
|
|
2880
|
+
printf '%s' 'OMO_LSP_PATH_CONTRACT_QA: call the available LSP status tool exactly once, then report completion.'
|
|
2881
|
+
;;
|
|
2882
|
+
esac
|
|
2883
|
+
}
|
|
2884
|
+
|
|
2885
|
+
run_normal() {
|
|
2886
|
+
require_bins opencode node npm bun jq sqlite3 curl git || return 1
|
|
2887
|
+
prepare_evidence || return 1
|
|
2888
|
+
if [ -n "${LSP_E2E_INTERNAL_FIXTURE:-}" ]; then
|
|
2889
|
+
run_internal_fixture
|
|
2890
|
+
return $?
|
|
2891
|
+
fi
|
|
2892
|
+
|
|
2893
|
+
local opencode_bin real_omo_before real_omo_after real_db_before real_db_after worktree_before worktree_after
|
|
2894
|
+
local build_rc port pass auth url encoded_dir session_response session_id prompt_code daemon_pid_file daemon_pid sandbox_removed=false
|
|
2895
|
+
opencode_bin="$(command -v opencode)"
|
|
2896
|
+
REAL_DB_PATH="$(opencode db path 2>/dev/null | head -1 || true)"
|
|
2897
|
+
real_db_before="$(real_db_count)"
|
|
2898
|
+
real_omo_before="$(hash_path "$REAL_OMO_ROOT")" || return 1
|
|
2899
|
+
REAL_OMO_BEFORE_HASH="$real_omo_before"
|
|
2900
|
+
worktree_before="$(git -C "$REPO_ROOT" status --porcelain=v1 -uall)"
|
|
2901
|
+
printf 'real_omo_root=%s before=%s\nreal_db=%s session_count_before=%s\n' \
|
|
2902
|
+
"$REAL_OMO_ROOT" "$real_omo_before" "${REAL_DB_PATH:-ABSENT}" "$real_db_before" >"$EVIDENCE_DIR/isolation-receipt.txt"
|
|
2903
|
+
|
|
2904
|
+
SANDBOX_ROOT="$(mktemp -d -t oqa-lsp-e2e.XXXXXX)" || return 1
|
|
2905
|
+
mkdir -p "$SANDBOX_ROOT/data" "$SANDBOX_ROOT/config" "$SANDBOX_ROOT/cache" "$SANDBOX_ROOT/state" "$SANDBOX_ROOT/home" "$SANDBOX_ROOT/project"
|
|
2906
|
+
export HOME="$SANDBOX_ROOT/home"
|
|
2907
|
+
export XDG_DATA_HOME="$SANDBOX_ROOT/data"
|
|
2908
|
+
export XDG_CONFIG_HOME="$SANDBOX_ROOT/config"
|
|
2909
|
+
export XDG_CACHE_HOME="$SANDBOX_ROOT/cache"
|
|
2910
|
+
export XDG_STATE_HOME="$SANDBOX_ROOT/state"
|
|
2911
|
+
export OPENCODE_TEST_HOME="$SANDBOX_ROOT/home"
|
|
2912
|
+
export OPENCODE_DISABLE_AUTOUPDATE=1
|
|
2913
|
+
export OPENCODE_DISABLE_MODELS_FETCH=1
|
|
2914
|
+
export OMO_DISABLE_POSTHOG=1
|
|
2915
|
+
export OMO_LSP_DAEMON_DIR="$SANDBOX_ROOT/omo/lsp-daemon"
|
|
2916
|
+
export LSP_TOOLS_MCP_PROJECT_CONFIG="$SANDBOX_ROOT/project/.opencode/lsp.json:$SANDBOX_ROOT/project/.omo/lsp.json:$SANDBOX_ROOT/project/.omo/lsp-client.json"
|
|
2917
|
+
export LSP_TOOLS_MCP_USER_CONFIG="$XDG_CONFIG_HOME/opencode/lsp.json"
|
|
2918
|
+
export LSP_TOOLS_MCP_INSTALL_DECISIONS="$XDG_CONFIG_HOME/opencode/lsp-install-decisions.json"
|
|
2919
|
+
unset OMO_LSP_DAEMON_CLI OMO_LSP_DAEMON_VERSION
|
|
2920
|
+
OMO_TEST_ROOT="$OMO_LSP_DAEMON_DIR"
|
|
2921
|
+
EXPECTED_DAEMON_CLI="$REPO_ROOT/packages/lsp-daemon/dist/cli.js"
|
|
2922
|
+
export QA_SCENARIO="$SCENARIO"
|
|
2923
|
+
export QA_SOURCE_FILE="source.ts"
|
|
2924
|
+
export QA_MARKER="$(scenario_marker "$SCENARIO")"
|
|
2925
|
+
|
|
2926
|
+
with_shared_build_lock "opencode-lsp-e2e-build" build_lsp_runtime_for_qa
|
|
2927
|
+
build_rc=$?
|
|
2928
|
+
[ "$build_rc" -eq 0 ] || { fail "daemon build failed (see build.log)"; return 1; }
|
|
2929
|
+
[ -f "$EXPECTED_DAEMON_CLI" ] || { fail "built daemon CLI is missing"; return 1; }
|
|
2930
|
+
EXPECTED_DAEMON_VERSION="$(node --input-type=module - "$REPO_ROOT/packages/lsp-daemon/dist/package.json" <<'NODE'
|
|
2931
|
+
import { readFileSync } from "node:fs";
|
|
2932
|
+
const packageJson = JSON.parse(readFileSync(process.argv[2], "utf8"));
|
|
2933
|
+
if (typeof packageJson.version !== "string") process.exit(1);
|
|
2934
|
+
process.stdout.write(packageJson.version);
|
|
2935
|
+
NODE
|
|
2936
|
+
)"
|
|
2937
|
+
[ -n "$EXPECTED_DAEMON_VERSION" ] || { fail "built daemon version is missing"; return 1; }
|
|
2938
|
+
export OMO_LSP_DAEMON_CLI="$EXPECTED_DAEMON_CLI"
|
|
2939
|
+
export OMO_LSP_DAEMON_VERSION="$EXPECTED_DAEMON_VERSION"
|
|
2940
|
+
|
|
2941
|
+
write_path_contract_probe "$SANDBOX_ROOT/contract" "$EVIDENCE_DIR/path-contract.json" || {
|
|
2942
|
+
fail "path-contract probe failed (see path-contract-probe.log)"
|
|
2943
|
+
return 1
|
|
2944
|
+
}
|
|
2945
|
+
if [ "$SCENARIO" = "source-dist-reuse" ]; then
|
|
2946
|
+
run_source_dist_reuse_probe || { fail "source/dist reuse probe failed"; return 1; }
|
|
2947
|
+
fi
|
|
2948
|
+
if [ "$SCENARIO" = "auth-ownership" ]; then
|
|
2949
|
+
run_auth_ownership_probe || { fail "auth ownership probe failed (see auth-ownership-probe.log)"; return 1; }
|
|
2950
|
+
stop_known_daemon || return 1
|
|
2951
|
+
stop_owned_sandbox_processes || return 1
|
|
2952
|
+
safe_rm_tree "$SANDBOX_ROOT" || return 1
|
|
2953
|
+
SANDBOX_ROOT=""
|
|
2954
|
+
sandbox_removed=true
|
|
2955
|
+
real_omo_after="$(hash_path "$REAL_OMO_ROOT")" || return 1
|
|
2956
|
+
real_db_after="$(real_db_count)"
|
|
2957
|
+
worktree_after="$(git -C "$REPO_ROOT" status --porcelain=v1 -uall)"
|
|
2958
|
+
printf '%s\n' "$worktree_after" >"$EVIDENCE_DIR/worktree-after.txt"
|
|
2959
|
+
[ "$real_omo_before" = "$real_omo_after" ] || { fail "real OMO daemon root changed"; return 1; }
|
|
2960
|
+
[ "$real_db_before" = "$real_db_after" ] || { fail "real OpenCode DB session count changed"; return 1; }
|
|
2961
|
+
[ "$worktree_before" = "$worktree_after" ] || { fail "driver changed the dirty worktree"; return 1; }
|
|
2962
|
+
printf 'after=%s unchanged=yes\nreal_db_after=%s unchanged=yes\n' "$real_omo_after" "$real_db_after" >>"$EVIDENCE_DIR/isolation-receipt.txt"
|
|
2963
|
+
printf 'auth_ownership_probe_complete=true\nisolated_state_removed=%s\n' "$sandbox_removed" >"$EVIDENCE_DIR/cleanup-receipt.txt"
|
|
2964
|
+
write_auth_ownership_result "$real_omo_before" "$real_omo_after" "$real_db_before" "$real_db_after" \
|
|
2965
|
+
"$worktree_before" "$worktree_after" "$sandbox_removed" || return 1
|
|
2966
|
+
NORMAL_CLEANUP_COMPLETE=1
|
|
2967
|
+
jq -c . "$EVIDENCE_DIR/result.json"
|
|
2968
|
+
return 0
|
|
2969
|
+
fi
|
|
2970
|
+
if [ "$SCENARIO" = "rename" ]; then
|
|
2971
|
+
write_workspace_edit_fixture "$SANDBOX_ROOT/project" || return 1
|
|
2972
|
+
run_workspace_edit_contract_probe || { fail "workspace-edit contract probe failed (see workspace-edit-contract-probe.log)"; return 1; }
|
|
2973
|
+
elif [ "$SCENARIO" = "diagnostics-freshness" ]; then
|
|
2974
|
+
write_diagnostics_freshness_fixture "$SANDBOX_ROOT/project" || return 1
|
|
2975
|
+
run_diagnostics_freshness_contract_probe || { fail "diagnostics freshness contract probe failed (see diagnostics-freshness-contract-probe.log)"; return 1; }
|
|
2976
|
+
elif [ "$SCENARIO" = "post-edit" ]; then
|
|
2977
|
+
run_post_edit_contract_probe || { fail "post-edit contract probe failed (see post-edit-contract-probe.log)"; return 1; }
|
|
2978
|
+
elif [ "$SCENARIO" = "cancellation" ]; then
|
|
2979
|
+
run_cancellation_contract_probe || { fail "cancellation contract probe failed (see cancellation-contract-probe.log)"; return 1; }
|
|
2980
|
+
elif [ "$SCENARIO" = "client-package" ]; then
|
|
2981
|
+
run_client_package_contract_probe || { fail "client package contract probe failed (see client-package-smoke.log)"; return 1; }
|
|
2982
|
+
fi
|
|
2983
|
+
start_fake_provider || return 1
|
|
2984
|
+
write_sandbox_config || return 1
|
|
2985
|
+
|
|
2986
|
+
port="$(node --input-type=module - <<'NODE'
|
|
2987
|
+
import net from "node:net";
|
|
2988
|
+
const server = net.createServer();
|
|
2989
|
+
server.listen(0, "127.0.0.1", () => {
|
|
2990
|
+
const address = server.address();
|
|
2991
|
+
process.stdout.write(String(typeof address === "object" && address ? address.port : 0));
|
|
2992
|
+
server.close();
|
|
2993
|
+
});
|
|
2994
|
+
NODE
|
|
2995
|
+
)"
|
|
2996
|
+
pass="oqa-$RANDOM$RANDOM"
|
|
2997
|
+
auth="opencode:$pass"
|
|
2998
|
+
url="http://127.0.0.1:$port"
|
|
2999
|
+
printf '%s\n' "$SANDBOX_ROOT/project" >"$EVIDENCE_DIR/opencode-serve-cwd.txt"
|
|
3000
|
+
(
|
|
3001
|
+
cd "$SANDBOX_ROOT/project" || exit 1
|
|
3002
|
+
export OPENCODE_SERVER_PASSWORD="$pass"
|
|
3003
|
+
exec "$opencode_bin" serve --port "$port" --hostname 127.0.0.1
|
|
3004
|
+
) >"$EVIDENCE_DIR/opencode-serve.stdout.log" 2>"$EVIDENCE_DIR/opencode-serve.stderr.log" &
|
|
3005
|
+
OPENCODE_PID=$!
|
|
3006
|
+
wait_http "$url/global/health" "$auth" || { fail "OpenCode server did not become ready"; return 1; }
|
|
3007
|
+
|
|
3008
|
+
encoded_dir="$(urlencode "$SANDBOX_ROOT/project")"
|
|
3009
|
+
wait_for_sse_connected "$url" "$auth" "$encoded_dir" || return 1
|
|
3010
|
+
|
|
3011
|
+
session_response="$(curl -sS -u "$auth" -X POST "$url/session?directory=$encoded_dir" \
|
|
3012
|
+
-H 'content-type: application/json' -d '{"title":"OMO LSP path contract QA"}' 2>/dev/null || true)"
|
|
3013
|
+
printf '%s\n' "$session_response" >"$EVIDENCE_DIR/session-create.json"
|
|
3014
|
+
session_id="$(printf '%s' "$session_response" | jq -r '.id // .sessionID // empty' 2>/dev/null || true)"
|
|
3015
|
+
[ -n "$session_id" ] || { fail "OpenCode session creation failed"; return 1; }
|
|
3016
|
+
|
|
3017
|
+
prompt_code="$(curl -sS -o "$EVIDENCE_DIR/prompt-response.txt" -w '%{http_code}' -u "$auth" \
|
|
3018
|
+
-X POST "$url/session/$session_id/prompt_async?directory=$encoded_dir" \
|
|
3019
|
+
-H 'content-type: application/json' \
|
|
3020
|
+
-d "{\"model\":{\"providerID\":\"openai\",\"modelID\":\"gpt-fake\"},\"parts\":[{\"type\":\"text\",\"text\":\"$(scenario_prompt "$SCENARIO")\"}]}" \
|
|
3021
|
+
2>"$EVIDENCE_DIR/prompt.stderr.log" || true)"
|
|
3022
|
+
[ "$prompt_code" = "204" ] || { fail "prompt_async returned HTTP $prompt_code"; return 1; }
|
|
3023
|
+
|
|
3024
|
+
wait_for_session_result "$url" "$auth" "$session_id" "$encoded_dir" "$EVIDENCE_DIR/messages.json" || {
|
|
3025
|
+
if [ -s "$EVIDENCE_DIR/session-terminal-failure.json" ]; then
|
|
3026
|
+
fail "OpenCode LSP tool call reached terminal error (see session-terminal-failure.json)"
|
|
3027
|
+
else
|
|
3028
|
+
fail "OpenCode LSP tool call did not complete within the bound"
|
|
3029
|
+
fi
|
|
3030
|
+
return 1
|
|
3031
|
+
}
|
|
3032
|
+
extract_tool_evidence || { fail "SSE/message/provider tool evidence failed"; return 1; }
|
|
3033
|
+
record_daemon_state || return 1
|
|
3034
|
+
daemon_pid_file="$(find_daemon_pid_file)"
|
|
3035
|
+
daemon_pid="$(tr -d '[:space:]' <"$daemon_pid_file")"
|
|
3036
|
+
|
|
3037
|
+
stop_verified_pid "$SSE_PID" "curl" "SSE watcher" || return 1
|
|
3038
|
+
SSE_PID=""
|
|
3039
|
+
stop_verified_pid "$OPENCODE_PID" "serve" "OpenCode server" || return 1
|
|
3040
|
+
OPENCODE_PID=""
|
|
3041
|
+
stop_known_daemon || return 1
|
|
3042
|
+
stop_verified_pid "$FAKE_PID" "$SANDBOX_ROOT/fake-provider.mjs" "fake provider" || return 1
|
|
3043
|
+
FAKE_PID=""
|
|
3044
|
+
stop_owned_sandbox_processes || return 1
|
|
3045
|
+
kill -0 "$daemon_pid" 2>/dev/null && { fail "daemon pid remained alive after cleanup"; return 1; }
|
|
3046
|
+
safe_rm_tree "$SANDBOX_ROOT" || return 1
|
|
3047
|
+
SANDBOX_ROOT=""
|
|
3048
|
+
sandbox_removed=true
|
|
3049
|
+
|
|
3050
|
+
real_omo_after="$(hash_path "$REAL_OMO_ROOT")" || return 1
|
|
3051
|
+
real_db_after="$(real_db_count)"
|
|
3052
|
+
worktree_after="$(git -C "$REPO_ROOT" status --porcelain=v1 -uall)"
|
|
3053
|
+
[ "$real_omo_before" = "$real_omo_after" ] || { fail "real OMO daemon root changed"; return 1; }
|
|
3054
|
+
[ "$real_db_before" = "$real_db_after" ] || { fail "real OpenCode DB session count changed"; return 1; }
|
|
3055
|
+
[ "$worktree_before" = "$worktree_after" ] || { fail "driver changed the dirty worktree"; return 1; }
|
|
3056
|
+
printf 'after=%s unchanged=yes\nreal_db_session_count_after=%s unchanged=yes\n' \
|
|
3057
|
+
"$real_omo_after" "$real_db_after" >>"$EVIDENCE_DIR/isolation-receipt.txt"
|
|
3058
|
+
printf 'daemon_pid=%s alive_after=no\nopencode_pid=stopped\nfake_provider_pid=stopped\nsse_watcher_pid=stopped\nisolated_state_removed=%s\n' \
|
|
3059
|
+
"$daemon_pid" "$sandbox_removed" >"$EVIDENCE_DIR/cleanup-receipt.txt"
|
|
3060
|
+
|
|
3061
|
+
write_final_result "$real_omo_before" "$real_omo_after" "$real_db_before" "$real_db_after" \
|
|
3062
|
+
"$worktree_before" "$worktree_after" "$sandbox_removed" || return 1
|
|
3063
|
+
NORMAL_CLEANUP_COMPLETE=1
|
|
3064
|
+
jq -c . "$EVIDENCE_DIR/result.json"
|
|
3065
|
+
}
|
|
3066
|
+
|
|
3067
|
+
run_all_scenarios() {
|
|
3068
|
+
require_bins bash node jq git || return 1
|
|
3069
|
+
prepare_evidence || return 1
|
|
3070
|
+
local scenarios scenario failures=0 before_omo before_db before_status after_omo after_db after_status
|
|
3071
|
+
REAL_DB_PATH="$(opencode db path 2>/dev/null | head -1 || true)"
|
|
3072
|
+
scenarios="path-contract rename diagnostics-freshness post-edit cancellation client-package source-dist-reuse auth-ownership"
|
|
3073
|
+
before_omo="$(hash_path "$REAL_OMO_ROOT")" || return 1
|
|
3074
|
+
before_db="$(real_db_count)"
|
|
3075
|
+
before_status="$(git -C "$REPO_ROOT" status --porcelain=v1 -uall)"
|
|
3076
|
+
printf '%s\n' "$scenarios" >"$EVIDENCE_DIR/all-scenarios.txt"
|
|
3077
|
+
for scenario in $scenarios; do
|
|
3078
|
+
mkdir -p "$EVIDENCE_DIR/$scenario"
|
|
3079
|
+
if ! bash "${BASH_SOURCE[0]}" --scenario "$scenario" --evidence-dir "$EVIDENCE_DIR/$scenario" >"$EVIDENCE_DIR/$scenario.command.log" 2>&1; then
|
|
3080
|
+
failures=$((failures + 1))
|
|
3081
|
+
continue
|
|
3082
|
+
fi
|
|
3083
|
+
[ -s "$EVIDENCE_DIR/$scenario/result.json" ] || { failures=$((failures + 1)); continue; }
|
|
3084
|
+
jq -e '.result == "PASS" and .scenario != "SKIP"' "$EVIDENCE_DIR/$scenario/result.json" >/dev/null || failures=$((failures + 1))
|
|
3085
|
+
done
|
|
3086
|
+
after_omo="$(hash_path "$REAL_OMO_ROOT")" || return 1
|
|
3087
|
+
after_db="$(real_db_count)"
|
|
3088
|
+
after_status="$(git -C "$REPO_ROOT" status --porcelain=v1 -uall)"
|
|
3089
|
+
[ "$before_omo" = "$after_omo" ] || failures=$((failures + 1))
|
|
3090
|
+
[ "$before_db" = "$after_db" ] || failures=$((failures + 1))
|
|
3091
|
+
[ "$before_status" = "$after_status" ] || failures=$((failures + 1))
|
|
3092
|
+
node --input-type=module - "$EVIDENCE_DIR" "$before_omo" "$after_omo" "$before_db" "$after_db" "$before_status" "$after_status" <<'NODE'
|
|
3093
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
3094
|
+
import { join } from "node:path";
|
|
3095
|
+
const [evidenceDir, omoBefore, omoAfter, dbBefore, dbAfter, statusBefore, statusAfter] = process.argv.slice(2);
|
|
3096
|
+
const scenarios = readFileSync(join(evidenceDir, "all-scenarios.txt"), "utf8").trim().split(/\s+/);
|
|
3097
|
+
const entries = scenarios.map((scenario) => {
|
|
3098
|
+
try {
|
|
3099
|
+
return [scenario, JSON.parse(readFileSync(join(evidenceDir, scenario, "result.json"), "utf8"))];
|
|
3100
|
+
} catch (error) {
|
|
3101
|
+
return [scenario, {
|
|
3102
|
+
result: "FAIL",
|
|
3103
|
+
scenario,
|
|
3104
|
+
missingResult: true,
|
|
3105
|
+
error: error instanceof Error ? error.message : String(error),
|
|
3106
|
+
artifacts: { commandLog: `${scenario}.command.log` },
|
|
3107
|
+
}];
|
|
3108
|
+
}
|
|
3109
|
+
});
|
|
3110
|
+
const results = Object.fromEntries(entries);
|
|
3111
|
+
const values = entries.map(([, value]) => value);
|
|
3112
|
+
const hasUnchangedOpenCodeDb = (value) =>
|
|
3113
|
+
value.realOpenCodeDbSessionCountUnchanged === true || value.realDbSessionCountUnchanged === true;
|
|
3114
|
+
const scenariosWithSse = entries.filter(([scenario]) => scenario !== "auth-ownership").map(([, value]) => value);
|
|
3115
|
+
const payload = {
|
|
3116
|
+
result: values.every((value) => value.result === "PASS") && omoBefore === omoAfter && dbBefore === dbAfter && statusBefore === statusAfter ? "PASS" : "FAIL",
|
|
3117
|
+
scenario: "all",
|
|
3118
|
+
harness: "opencode",
|
|
3119
|
+
scenarioOrder: scenarios,
|
|
3120
|
+
scenarioResults: Object.fromEntries(entries.map(([name, value]) => [name, { result: value.result, cleanup: value.cleanup ?? {}, artifacts: value.artifacts ?? {} }])),
|
|
3121
|
+
realOmoRootUnchanged: omoBefore === omoAfter && values.every((value) => value.realOmoRootUnchanged === true),
|
|
3122
|
+
realOpenCodeDbSessionCountUnchanged: dbBefore === dbAfter && values.every(hasUnchangedOpenCodeDb),
|
|
3123
|
+
dirtyWorktreePreserved: statusBefore === statusAfter && values.every((value) => value.dirtyWorktreePreserved === true),
|
|
3124
|
+
noSkip: values.every((value) => value.result !== "SKIP" && value.scenario !== "SKIP"),
|
|
3125
|
+
sseEvidence: scenariosWithSse.every((value) => value.sseEvidence?.connected === true && value.sseEvidence?.toolObserved === true),
|
|
3126
|
+
pathContract: results["path-contract"]?.overrideAssertions ?? null,
|
|
3127
|
+
pairRecovery: results["path-contract"]?.failureFixtures ?? null,
|
|
3128
|
+
reuse: results["source-dist-reuse"]?.sourceDistReuse ?? null,
|
|
3129
|
+
auth: results["auth-ownership"]?.authOwnership ?? results["auth-ownership"] ?? null,
|
|
3130
|
+
cancellation: results.cancellation?.cancellationContract ?? null,
|
|
3131
|
+
cleanup: {
|
|
3132
|
+
isolatedStateRemoved: values.every((value) => value.cleanup?.isolatedStateRemoved === true),
|
|
3133
|
+
daemonStopped: values.every((value) => value.cleanup?.daemonStopped === true),
|
|
3134
|
+
},
|
|
3135
|
+
};
|
|
3136
|
+
writeFileSync(join(evidenceDir, "result.json"), `${JSON.stringify(payload, null, 2)}\n`);
|
|
3137
|
+
console.log(JSON.stringify(payload));
|
|
3138
|
+
NODE
|
|
3139
|
+
NORMAL_CLEANUP_COMPLETE=1
|
|
3140
|
+
[ "$failures" -eq 0 ] && jq -e '.result == "PASS" and .noSkip == true' "$EVIDENCE_DIR/result.json" >/dev/null
|
|
3141
|
+
}
|
|
3142
|
+
|
|
3143
|
+
main() {
|
|
3144
|
+
parse_args "$@" || return $?
|
|
3145
|
+
if [ "$SELF_TEST" -eq 1 ]; then
|
|
3146
|
+
run_self_test
|
|
3147
|
+
elif [ "$SCENARIO" = "all" ]; then
|
|
3148
|
+
run_all_scenarios
|
|
3149
|
+
else
|
|
3150
|
+
run_normal
|
|
3151
|
+
fi
|
|
3152
|
+
}
|
|
3153
|
+
|
|
3154
|
+
main "$@"
|