@aiden-ade/sandbox-agent 0.1.59 → 0.1.60
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/binding-switch-git.test.d.ts +2 -0
- package/dist/__tests__/binding-switch-git.test.d.ts.map +1 -0
- package/dist/__tests__/binding-switch-git.test.js +610 -0
- package/dist/__tests__/binding-switch-git.test.js.map +1 -0
- package/dist/__tests__/cli-ensure-install.test.d.ts +2 -0
- package/dist/__tests__/cli-ensure-install.test.d.ts.map +1 -0
- package/dist/__tests__/cli-ensure-install.test.js +109 -0
- package/dist/__tests__/cli-ensure-install.test.js.map +1 -0
- package/dist/__tests__/cli-executable.test.d.ts +2 -0
- package/dist/__tests__/cli-executable.test.d.ts.map +1 -0
- package/dist/__tests__/cli-executable.test.js +129 -0
- package/dist/__tests__/cli-executable.test.js.map +1 -0
- package/dist/__tests__/cli-help.test.d.ts +2 -0
- package/dist/__tests__/cli-help.test.d.ts.map +1 -0
- package/dist/__tests__/cli-help.test.js +34 -0
- package/dist/__tests__/cli-help.test.js.map +1 -0
- package/dist/__tests__/computer-readiness.test.d.ts +2 -0
- package/dist/__tests__/computer-readiness.test.d.ts.map +1 -0
- package/dist/__tests__/computer-readiness.test.js +63 -0
- package/dist/__tests__/computer-readiness.test.js.map +1 -0
- package/dist/__tests__/core-agent-workflow-context.test.d.ts +2 -0
- package/dist/__tests__/core-agent-workflow-context.test.d.ts.map +1 -0
- package/dist/__tests__/core-agent-workflow-context.test.js +139 -0
- package/dist/__tests__/core-agent-workflow-context.test.js.map +1 -0
- package/dist/__tests__/daemon-reconcile-ask.test.d.ts +2 -0
- package/dist/__tests__/daemon-reconcile-ask.test.d.ts.map +1 -0
- package/dist/__tests__/daemon-reconcile-ask.test.js +86 -0
- package/dist/__tests__/daemon-reconcile-ask.test.js.map +1 -0
- package/dist/__tests__/daemon-socket.integration.test.d.ts +2 -0
- package/dist/__tests__/daemon-socket.integration.test.d.ts.map +1 -0
- package/dist/__tests__/daemon-socket.integration.test.js +450 -0
- package/dist/__tests__/daemon-socket.integration.test.js.map +1 -0
- package/dist/__tests__/daemon.test.d.ts +2 -0
- package/dist/__tests__/daemon.test.d.ts.map +1 -0
- package/dist/__tests__/daemon.test.js +3975 -0
- package/dist/__tests__/daemon.test.js.map +1 -0
- package/dist/__tests__/ensure-shared-checkout-branch.test.d.ts +2 -0
- package/dist/__tests__/ensure-shared-checkout-branch.test.d.ts.map +1 -0
- package/dist/__tests__/ensure-shared-checkout-branch.test.js +129 -0
- package/dist/__tests__/ensure-shared-checkout-branch.test.js.map +1 -0
- package/dist/__tests__/event-outbox-persist-failure.test.d.ts +2 -0
- package/dist/__tests__/event-outbox-persist-failure.test.d.ts.map +1 -0
- package/dist/__tests__/event-outbox-persist-failure.test.js +84 -0
- package/dist/__tests__/event-outbox-persist-failure.test.js.map +1 -0
- package/dist/__tests__/event-outbox.test.d.ts +2 -0
- package/dist/__tests__/event-outbox.test.d.ts.map +1 -0
- package/dist/__tests__/event-outbox.test.js +256 -0
- package/dist/__tests__/event-outbox.test.js.map +1 -0
- package/dist/__tests__/fake-provider-cli.test.d.ts +2 -0
- package/dist/__tests__/fake-provider-cli.test.d.ts.map +1 -0
- package/dist/__tests__/fake-provider-cli.test.js +46 -0
- package/dist/__tests__/fake-provider-cli.test.js.map +1 -0
- package/dist/__tests__/generated-images.test.d.ts +2 -0
- package/dist/__tests__/generated-images.test.d.ts.map +1 -0
- package/dist/__tests__/generated-images.test.js +132 -0
- package/dist/__tests__/generated-images.test.js.map +1 -0
- package/dist/__tests__/lifecycle-logger.test.d.ts +2 -0
- package/dist/__tests__/lifecycle-logger.test.d.ts.map +1 -0
- package/dist/__tests__/lifecycle-logger.test.js +49 -0
- package/dist/__tests__/lifecycle-logger.test.js.map +1 -0
- package/dist/__tests__/local-skill-scan.test.d.ts +2 -0
- package/dist/__tests__/local-skill-scan.test.d.ts.map +1 -0
- package/dist/__tests__/local-skill-scan.test.js +91 -0
- package/dist/__tests__/local-skill-scan.test.js.map +1 -0
- package/dist/__tests__/local-workspace-migration.test.d.ts +2 -0
- package/dist/__tests__/local-workspace-migration.test.d.ts.map +1 -0
- package/dist/__tests__/local-workspace-migration.test.js +195 -0
- package/dist/__tests__/local-workspace-migration.test.js.map +1 -0
- package/dist/__tests__/mcp-inspect-timeout.test.d.ts +2 -0
- package/dist/__tests__/mcp-inspect-timeout.test.d.ts.map +1 -0
- package/dist/__tests__/mcp-inspect-timeout.test.js +97 -0
- package/dist/__tests__/mcp-inspect-timeout.test.js.map +1 -0
- package/dist/__tests__/mcp-readiness.test.d.ts +2 -0
- package/dist/__tests__/mcp-readiness.test.d.ts.map +1 -0
- package/dist/__tests__/mcp-readiness.test.js +267 -0
- package/dist/__tests__/mcp-readiness.test.js.map +1 -0
- package/dist/__tests__/mcp-registration-cli.test.d.ts +2 -0
- package/dist/__tests__/mcp-registration-cli.test.d.ts.map +1 -0
- package/dist/__tests__/mcp-registration-cli.test.js +71 -0
- package/dist/__tests__/mcp-registration-cli.test.js.map +1 -0
- package/dist/__tests__/mcp-registration.test.d.ts +2 -0
- package/dist/__tests__/mcp-registration.test.d.ts.map +1 -0
- package/dist/__tests__/mcp-registration.test.js +426 -0
- package/dist/__tests__/mcp-registration.test.js.map +1 -0
- package/dist/__tests__/model-scan-runner.test.d.ts +2 -0
- package/dist/__tests__/model-scan-runner.test.d.ts.map +1 -0
- package/dist/__tests__/model-scan-runner.test.js +67 -0
- package/dist/__tests__/model-scan-runner.test.js.map +1 -0
- package/dist/__tests__/network-diagnostics.test.d.ts +2 -0
- package/dist/__tests__/network-diagnostics.test.d.ts.map +1 -0
- package/dist/__tests__/network-diagnostics.test.js +26 -0
- package/dist/__tests__/network-diagnostics.test.js.map +1 -0
- package/dist/__tests__/observable-map.test.d.ts +2 -0
- package/dist/__tests__/observable-map.test.d.ts.map +1 -0
- package/dist/__tests__/observable-map.test.js +54 -0
- package/dist/__tests__/observable-map.test.js.map +1 -0
- package/dist/__tests__/provider-auth-preflight.test.d.ts +2 -0
- package/dist/__tests__/provider-auth-preflight.test.d.ts.map +1 -0
- package/dist/__tests__/provider-auth-preflight.test.js +123 -0
- package/dist/__tests__/provider-auth-preflight.test.js.map +1 -0
- package/dist/__tests__/provider-model-discovery.test.d.ts +2 -0
- package/dist/__tests__/provider-model-discovery.test.d.ts.map +1 -0
- package/dist/__tests__/provider-model-discovery.test.js +472 -0
- package/dist/__tests__/provider-model-discovery.test.js.map +1 -0
- package/dist/__tests__/remove-worktrees-if-clean.test.d.ts +2 -0
- package/dist/__tests__/remove-worktrees-if-clean.test.d.ts.map +1 -0
- package/dist/__tests__/remove-worktrees-if-clean.test.js +221 -0
- package/dist/__tests__/remove-worktrees-if-clean.test.js.map +1 -0
- package/dist/__tests__/resume-fallback-env.test.d.ts +2 -0
- package/dist/__tests__/resume-fallback-env.test.d.ts.map +1 -0
- package/dist/__tests__/resume-fallback-env.test.js +28 -0
- package/dist/__tests__/resume-fallback-env.test.js.map +1 -0
- package/dist/__tests__/run-journal.test.d.ts +2 -0
- package/dist/__tests__/run-journal.test.d.ts.map +1 -0
- package/dist/__tests__/run-journal.test.js +121 -0
- package/dist/__tests__/run-journal.test.js.map +1 -0
- package/dist/__tests__/run-start-gate.test.d.ts +2 -0
- package/dist/__tests__/run-start-gate.test.d.ts.map +1 -0
- package/dist/__tests__/run-start-gate.test.js +29 -0
- package/dist/__tests__/run-start-gate.test.js.map +1 -0
- package/dist/__tests__/runtime-capabilities-sync.test.d.ts +2 -0
- package/dist/__tests__/runtime-capabilities-sync.test.d.ts.map +1 -0
- package/dist/__tests__/runtime-capabilities-sync.test.js +66 -0
- package/dist/__tests__/runtime-capabilities-sync.test.js.map +1 -0
- package/dist/__tests__/runtime-config.test.d.ts +2 -0
- package/dist/__tests__/runtime-config.test.d.ts.map +1 -0
- package/dist/__tests__/runtime-config.test.js +102 -0
- package/dist/__tests__/runtime-config.test.js.map +1 -0
- package/dist/__tests__/runtime-presenter-heartbeat.test.d.ts +2 -0
- package/dist/__tests__/runtime-presenter-heartbeat.test.d.ts.map +1 -0
- package/dist/__tests__/runtime-presenter-heartbeat.test.js +159 -0
- package/dist/__tests__/runtime-presenter-heartbeat.test.js.map +1 -0
- package/dist/__tests__/sandbox-outbox.test.d.ts +2 -0
- package/dist/__tests__/sandbox-outbox.test.d.ts.map +1 -0
- package/dist/__tests__/sandbox-outbox.test.js +214 -0
- package/dist/__tests__/sandbox-outbox.test.js.map +1 -0
- package/dist/__tests__/sandbox-workflow-context.test.d.ts +2 -0
- package/dist/__tests__/sandbox-workflow-context.test.d.ts.map +1 -0
- package/dist/__tests__/sandbox-workflow-context.test.js +396 -0
- package/dist/__tests__/sandbox-workflow-context.test.js.map +1 -0
- package/dist/__tests__/service-manager.test.d.ts +2 -0
- package/dist/__tests__/service-manager.test.d.ts.map +1 -0
- package/dist/__tests__/service-manager.test.js +50 -0
- package/dist/__tests__/service-manager.test.js.map +1 -0
- package/dist/__tests__/sleep-inhibitor.test.d.ts +2 -0
- package/dist/__tests__/sleep-inhibitor.test.d.ts.map +1 -0
- package/dist/__tests__/sleep-inhibitor.test.js +236 -0
- package/dist/__tests__/sleep-inhibitor.test.js.map +1 -0
- package/dist/__tests__/status-local-workspace.test.d.ts +2 -0
- package/dist/__tests__/status-local-workspace.test.d.ts.map +1 -0
- package/dist/__tests__/status-local-workspace.test.js +121 -0
- package/dist/__tests__/status-local-workspace.test.js.map +1 -0
- package/dist/__tests__/suspension-detector.test.d.ts +2 -0
- package/dist/__tests__/suspension-detector.test.d.ts.map +1 -0
- package/dist/__tests__/suspension-detector.test.js +112 -0
- package/dist/__tests__/suspension-detector.test.js.map +1 -0
- package/dist/__tests__/web-presenter-parent-events.test.d.ts +2 -0
- package/dist/__tests__/web-presenter-parent-events.test.d.ts.map +1 -0
- package/dist/__tests__/web-presenter-parent-events.test.js +130 -0
- package/dist/__tests__/web-presenter-parent-events.test.js.map +1 -0
- package/dist/__tests__/workspace-relocation.test.d.ts +2 -0
- package/dist/__tests__/workspace-relocation.test.d.ts.map +1 -0
- package/dist/__tests__/workspace-relocation.test.js +66 -0
- package/dist/__tests__/workspace-relocation.test.js.map +1 -0
- package/dist/__tests__/workspace-run-lease.test.d.ts +2 -0
- package/dist/__tests__/workspace-run-lease.test.d.ts.map +1 -0
- package/dist/__tests__/workspace-run-lease.test.js +230 -0
- package/dist/__tests__/workspace-run-lease.test.js.map +1 -0
- package/dist/__tests__/ws-client-heartbeat-volatile.test.d.ts +2 -0
- package/dist/__tests__/ws-client-heartbeat-volatile.test.d.ts.map +1 -0
- package/dist/__tests__/ws-client-heartbeat-volatile.test.js +61 -0
- package/dist/__tests__/ws-client-heartbeat-volatile.test.js.map +1 -0
- package/dist/__tests__/ws-client-heartbeat.integration.test.d.ts +2 -0
- package/dist/__tests__/ws-client-heartbeat.integration.test.d.ts.map +1 -0
- package/dist/__tests__/ws-client-heartbeat.integration.test.js +109 -0
- package/dist/__tests__/ws-client-heartbeat.integration.test.js.map +1 -0
- package/dist/__tests__/ws-client-user-message-ack.integration.test.d.ts +2 -0
- package/dist/__tests__/ws-client-user-message-ack.integration.test.d.ts.map +1 -0
- package/dist/__tests__/ws-client-user-message-ack.integration.test.js +167 -0
- package/dist/__tests__/ws-client-user-message-ack.integration.test.js.map +1 -0
- package/dist/aiden-system-prompt.d.ts +6 -0
- package/dist/aiden-system-prompt.d.ts.map +1 -0
- package/dist/aiden-system-prompt.js +6 -0
- package/dist/aiden-system-prompt.js.map +1 -0
- package/dist/binding-switch-git.d.ts +26 -0
- package/dist/binding-switch-git.d.ts.map +1 -0
- package/dist/binding-switch-git.js +973 -0
- package/dist/binding-switch-git.js.map +1 -0
- package/dist/cli-ensure-install.d.ts +39 -0
- package/dist/cli-ensure-install.d.ts.map +1 -0
- package/dist/cli-ensure-install.js +133 -0
- package/dist/cli-ensure-install.js.map +1 -0
- package/dist/cli-executable.d.ts +97 -0
- package/dist/cli-executable.d.ts.map +1 -0
- package/dist/cli-executable.js +432 -0
- package/dist/cli-executable.js.map +1 -0
- package/dist/cli-help.d.ts +7 -0
- package/dist/cli-help.d.ts.map +1 -0
- package/dist/cli-help.js +49 -0
- package/dist/cli-help.js.map +1 -0
- package/dist/computer-readiness.d.ts +42 -0
- package/dist/computer-readiness.d.ts.map +1 -0
- package/dist/computer-readiness.js +191 -0
- package/dist/computer-readiness.js.map +1 -0
- package/dist/core-agent.d.ts +47 -0
- package/dist/core-agent.d.ts.map +1 -0
- package/dist/core-agent.js +364 -0
- package/dist/core-agent.js.map +1 -0
- package/dist/daemon.d.ts +392 -0
- package/dist/daemon.d.ts.map +1 -0
- package/dist/daemon.js +4711 -0
- package/dist/daemon.js.map +1 -0
- package/dist/event-outbox.d.ts +83 -0
- package/dist/event-outbox.d.ts.map +1 -0
- package/dist/event-outbox.js +440 -0
- package/dist/event-outbox.js.map +1 -0
- package/dist/fake-provider-cli.d.ts +13 -0
- package/dist/fake-provider-cli.d.ts.map +1 -0
- package/dist/fake-provider-cli.js +53 -0
- package/dist/fake-provider-cli.js.map +1 -0
- package/dist/generated-images.d.ts +2 -0
- package/dist/generated-images.d.ts.map +1 -0
- package/dist/generated-images.js +2 -0
- package/dist/generated-images.js.map +1 -0
- package/dist/index.cjs +129 -29
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +209 -0
- package/dist/index.js.map +1 -0
- package/dist/lifecycle-logger.d.ts +25 -0
- package/dist/lifecycle-logger.d.ts.map +1 -0
- package/dist/lifecycle-logger.js +73 -0
- package/dist/lifecycle-logger.js.map +1 -0
- package/dist/local-skill-scan.d.ts +21 -0
- package/dist/local-skill-scan.d.ts.map +1 -0
- package/dist/local-skill-scan.js +109 -0
- package/dist/local-skill-scan.js.map +1 -0
- package/dist/local-workspace-migration.d.ts +68 -0
- package/dist/local-workspace-migration.d.ts.map +1 -0
- package/dist/local-workspace-migration.js +157 -0
- package/dist/local-workspace-migration.js.map +1 -0
- package/dist/mcp-readiness.d.ts +59 -0
- package/dist/mcp-readiness.d.ts.map +1 -0
- package/dist/mcp-readiness.js +202 -0
- package/dist/mcp-readiness.js.map +1 -0
- package/dist/mcp-registration.d.ts +75 -0
- package/dist/mcp-registration.d.ts.map +1 -0
- package/dist/mcp-registration.js +563 -0
- package/dist/mcp-registration.js.map +1 -0
- package/dist/model-scan-runner.d.ts +15 -0
- package/dist/model-scan-runner.d.ts.map +1 -0
- package/dist/model-scan-runner.js +51 -0
- package/dist/model-scan-runner.js.map +1 -0
- package/dist/network-diagnostics.d.ts +8 -0
- package/dist/network-diagnostics.d.ts.map +1 -0
- package/dist/network-diagnostics.js +56 -0
- package/dist/network-diagnostics.js.map +1 -0
- package/dist/observable-map.d.ts +25 -0
- package/dist/observable-map.d.ts.map +1 -0
- package/dist/observable-map.js +42 -0
- package/dist/observable-map.js.map +1 -0
- package/dist/provider-auth-preflight.d.ts +22 -0
- package/dist/provider-auth-preflight.d.ts.map +1 -0
- package/dist/provider-auth-preflight.js +110 -0
- package/dist/provider-auth-preflight.js.map +1 -0
- package/dist/provider-model-discovery.d.ts +38 -0
- package/dist/provider-model-discovery.d.ts.map +1 -0
- package/dist/provider-model-discovery.js +519 -0
- package/dist/provider-model-discovery.js.map +1 -0
- package/dist/resume-fallback-env.d.ts +20 -0
- package/dist/resume-fallback-env.d.ts.map +1 -0
- package/dist/resume-fallback-env.js +30 -0
- package/dist/resume-fallback-env.js.map +1 -0
- package/dist/run-journal.d.ts +53 -0
- package/dist/run-journal.d.ts.map +1 -0
- package/dist/run-journal.js +193 -0
- package/dist/run-journal.js.map +1 -0
- package/dist/run-start-gate.d.ts +9 -0
- package/dist/run-start-gate.d.ts.map +1 -0
- package/dist/run-start-gate.js +24 -0
- package/dist/run-start-gate.js.map +1 -0
- package/dist/runtime-backends.d.ts +2 -0
- package/dist/runtime-backends.d.ts.map +1 -0
- package/dist/runtime-backends.js +2 -0
- package/dist/runtime-backends.js.map +1 -0
- package/dist/runtime-capabilities-sync.d.ts +33 -0
- package/dist/runtime-capabilities-sync.d.ts.map +1 -0
- package/dist/runtime-capabilities-sync.js +56 -0
- package/dist/runtime-capabilities-sync.js.map +1 -0
- package/dist/runtime-config.d.ts +24 -0
- package/dist/runtime-config.d.ts.map +1 -0
- package/dist/runtime-config.js +124 -0
- package/dist/runtime-config.js.map +1 -0
- package/dist/sandbox-outbox.d.ts +122 -0
- package/dist/sandbox-outbox.d.ts.map +1 -0
- package/dist/sandbox-outbox.js +180 -0
- package/dist/sandbox-outbox.js.map +1 -0
- package/dist/sandbox.d.ts +44 -0
- package/dist/sandbox.d.ts.map +1 -0
- package/dist/sandbox.js +438 -0
- package/dist/sandbox.js.map +1 -0
- package/dist/service-manager.d.ts +28 -0
- package/dist/service-manager.d.ts.map +1 -0
- package/dist/service-manager.js +259 -0
- package/dist/service-manager.js.map +1 -0
- package/dist/sleep-inhibitor.d.ts +111 -0
- package/dist/sleep-inhibitor.d.ts.map +1 -0
- package/dist/sleep-inhibitor.js +412 -0
- package/dist/sleep-inhibitor.js.map +1 -0
- package/dist/suspension-detector.d.ts +48 -0
- package/dist/suspension-detector.d.ts.map +1 -0
- package/dist/suspension-detector.js +89 -0
- package/dist/suspension-detector.js.map +1 -0
- package/dist/types.d.ts +6 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -0
- package/dist/updater.d.ts +15 -0
- package/dist/updater.d.ts.map +1 -0
- package/dist/updater.js +50 -0
- package/dist/updater.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +2 -0
- package/dist/version.js.map +1 -0
- package/dist/web-presenter.d.ts +55 -0
- package/dist/web-presenter.d.ts.map +1 -0
- package/dist/web-presenter.js +265 -0
- package/dist/web-presenter.js.map +1 -0
- package/dist/workspace-relocation.d.ts +22 -0
- package/dist/workspace-relocation.d.ts.map +1 -0
- package/dist/workspace-relocation.js +90 -0
- package/dist/workspace-relocation.js.map +1 -0
- package/dist/workspace-run-lease.d.ts +97 -0
- package/dist/workspace-run-lease.d.ts.map +1 -0
- package/dist/workspace-run-lease.js +241 -0
- package/dist/workspace-run-lease.js.map +1 -0
- package/dist/ws-client.d.ts +194 -0
- package/dist/ws-client.d.ts.map +1 -0
- package/dist/ws-client.js +282 -0
- package/dist/ws-client.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,3975 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { ALAN_DEFAULT_WORKSPACE_URI } from "@alan-ai/shared/constants";
|
|
7
|
+
import { OPENCODE_ZEN_MODELS } from "@alan-ai/shared/frontend";
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
9
|
+
const mocks = vi.hoisted(() => ({
|
|
10
|
+
spawnSync: vi.fn(),
|
|
11
|
+
io: vi.fn(),
|
|
12
|
+
coreAgentConstructor: vi.fn(),
|
|
13
|
+
agentRun: vi.fn(),
|
|
14
|
+
agentKill: vi.fn(),
|
|
15
|
+
sendToolResponse: vi.fn(),
|
|
16
|
+
collectLocalAgentProviderLimits: vi.fn(),
|
|
17
|
+
ensureAlanMcpRegistered: vi.fn(),
|
|
18
|
+
verifyAlanMcpRegistered: vi.fn(),
|
|
19
|
+
runRuntimeModelScan: vi.fn(),
|
|
20
|
+
discoverRelocatedWorkspace: vi.fn(),
|
|
21
|
+
workspaceMatchesExpectedRepositories: vi.fn(),
|
|
22
|
+
captureWorkspaceRunLease: vi.fn(),
|
|
23
|
+
workspaceLeaseVerify: vi.fn(),
|
|
24
|
+
defaultWorkspacePath: "",
|
|
25
|
+
leaseReleaseMutationHook: null,
|
|
26
|
+
}));
|
|
27
|
+
const readinessContract = {
|
|
28
|
+
requiredTools: ["create_plan", "create_document_artifact"],
|
|
29
|
+
contractVersion: 1,
|
|
30
|
+
};
|
|
31
|
+
vi.mock("node:fs", async () => {
|
|
32
|
+
const actual = await vi.importActual("node:fs");
|
|
33
|
+
return {
|
|
34
|
+
...actual,
|
|
35
|
+
renameSync: (oldPath, newPath) => {
|
|
36
|
+
if (typeof oldPath === "string" && mocks.leaseReleaseMutationHook) {
|
|
37
|
+
const hook = mocks.leaseReleaseMutationHook;
|
|
38
|
+
mocks.leaseReleaseMutationHook = null;
|
|
39
|
+
hook(oldPath);
|
|
40
|
+
}
|
|
41
|
+
return actual.renameSync(oldPath, newPath);
|
|
42
|
+
},
|
|
43
|
+
rmSync: (path, options) => {
|
|
44
|
+
if (typeof path === "string" && mocks.leaseReleaseMutationHook) {
|
|
45
|
+
const hook = mocks.leaseReleaseMutationHook;
|
|
46
|
+
mocks.leaseReleaseMutationHook = null;
|
|
47
|
+
hook(path);
|
|
48
|
+
}
|
|
49
|
+
return actual.rmSync(path, options);
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
});
|
|
53
|
+
vi.mock("node:child_process", async () => {
|
|
54
|
+
const actual = await vi.importActual("node:child_process");
|
|
55
|
+
return {
|
|
56
|
+
...actual,
|
|
57
|
+
spawnSync: mocks.spawnSync,
|
|
58
|
+
};
|
|
59
|
+
});
|
|
60
|
+
vi.mock("socket.io-client", () => ({
|
|
61
|
+
io: mocks.io,
|
|
62
|
+
}));
|
|
63
|
+
vi.mock("../core-agent.js", () => ({
|
|
64
|
+
CoreAgent: mocks.coreAgentConstructor,
|
|
65
|
+
}));
|
|
66
|
+
vi.mock("../mcp-registration.js", () => ({
|
|
67
|
+
ensureAlanMcpRegistered: mocks.ensureAlanMcpRegistered,
|
|
68
|
+
verifyAlanMcpRegistered: mocks.verifyAlanMcpRegistered,
|
|
69
|
+
}));
|
|
70
|
+
vi.mock("../model-scan-runner.js", () => ({
|
|
71
|
+
runRuntimeModelScan: mocks.runRuntimeModelScan,
|
|
72
|
+
}));
|
|
73
|
+
vi.mock("../workspace-relocation.js", () => ({
|
|
74
|
+
discoverRelocatedWorkspace: mocks.discoverRelocatedWorkspace,
|
|
75
|
+
workspaceMatchesExpectedRepositories: mocks.workspaceMatchesExpectedRepositories,
|
|
76
|
+
}));
|
|
77
|
+
vi.mock("../workspace-run-lease.js", async (importOriginal) => {
|
|
78
|
+
// Keep the real pure lease-monitor helpers (applyLeaseCheckResult,
|
|
79
|
+
// isLeaseCheckDue, FRESH_LEASE_MONITOR_STATE) while stubbing the interval and
|
|
80
|
+
// the capture factory the daemon tests drive.
|
|
81
|
+
const actual = await importOriginal();
|
|
82
|
+
return {
|
|
83
|
+
...actual,
|
|
84
|
+
WORKSPACE_LEASE_CHECK_INTERVAL_MS: 10,
|
|
85
|
+
WorkspaceRunLease: { capture: mocks.captureWorkspaceRunLease },
|
|
86
|
+
};
|
|
87
|
+
});
|
|
88
|
+
vi.mock("@alan-ai/shared/node/provider-limits", () => ({
|
|
89
|
+
collectLocalAgentProviderLimits: mocks.collectLocalAgentProviderLimits,
|
|
90
|
+
}));
|
|
91
|
+
vi.mock("@alan-ai/shared/node/local-workspace", () => ({
|
|
92
|
+
resolveAlanDefaultWorkspacePath: () => mocks.defaultWorkspacePath,
|
|
93
|
+
}));
|
|
94
|
+
function seedCliBinaries(baseDir) {
|
|
95
|
+
const binDir = join(baseDir, "bin");
|
|
96
|
+
mkdirSync(binDir, { recursive: true });
|
|
97
|
+
for (const name of ["codex", "claude", "agy", "git"]) {
|
|
98
|
+
const path = join(binDir, name);
|
|
99
|
+
writeFileSync(path, "#!/bin/sh\n");
|
|
100
|
+
chmodSync(path, 0o755);
|
|
101
|
+
}
|
|
102
|
+
return binDir;
|
|
103
|
+
}
|
|
104
|
+
class TestSocket extends EventEmitter {
|
|
105
|
+
connected = true;
|
|
106
|
+
auth = {};
|
|
107
|
+
connectCalls = 0;
|
|
108
|
+
outbound = [];
|
|
109
|
+
disconnected = false;
|
|
110
|
+
acknowledgeWorkspaceRelocations = true;
|
|
111
|
+
emit(event, ...args) {
|
|
112
|
+
const rawPayload = args[0];
|
|
113
|
+
// Most daemon tests exercise unrelated runtime behavior. Model the current
|
|
114
|
+
// API contract by attaching valid per-run MCP metadata unless a test
|
|
115
|
+
// explicitly supplies alanMcp (including null for the rejection case).
|
|
116
|
+
const payload = event === "agent.execute" &&
|
|
117
|
+
rawPayload &&
|
|
118
|
+
typeof rawPayload === "object" &&
|
|
119
|
+
!("alanMcp" in rawPayload)
|
|
120
|
+
? {
|
|
121
|
+
...rawPayload,
|
|
122
|
+
alanMcp: {
|
|
123
|
+
url: "https://api.alan.test/mcp",
|
|
124
|
+
headers: { "x-alan-run-reference": "test-run-reference" },
|
|
125
|
+
...readinessContract,
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
: rawPayload;
|
|
129
|
+
const normalizedArgs = [payload, ...args.slice(1)];
|
|
130
|
+
this.outbound.push({ event, payload });
|
|
131
|
+
const emitted = super.emit(event, ...normalizedArgs);
|
|
132
|
+
const acknowledge = normalizedArgs[1];
|
|
133
|
+
if (event === "agent.event" && typeof acknowledge === "function") {
|
|
134
|
+
queueMicrotask(() => acknowledge({ ok: true }));
|
|
135
|
+
}
|
|
136
|
+
else if (event === "runtime.workspace.relocated" &&
|
|
137
|
+
this.acknowledgeWorkspaceRelocations &&
|
|
138
|
+
typeof acknowledge === "function") {
|
|
139
|
+
queueMicrotask(() => acknowledge({
|
|
140
|
+
ok: true,
|
|
141
|
+
persisted: true,
|
|
142
|
+
}));
|
|
143
|
+
}
|
|
144
|
+
return emitted;
|
|
145
|
+
}
|
|
146
|
+
disconnect() {
|
|
147
|
+
this.disconnected = true;
|
|
148
|
+
}
|
|
149
|
+
connect() {
|
|
150
|
+
this.connectCalls += 1;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
async function waitForOutbound(socket, event) {
|
|
154
|
+
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
155
|
+
const item = socket.outbound.find((entry) => entry.event === event);
|
|
156
|
+
if (item) {
|
|
157
|
+
return item;
|
|
158
|
+
}
|
|
159
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
160
|
+
}
|
|
161
|
+
throw new Error(`Timed out waiting for outbound event ${event}`);
|
|
162
|
+
}
|
|
163
|
+
describe("alan-agent daemon CLI helpers", () => {
|
|
164
|
+
let tempDir;
|
|
165
|
+
let configPath;
|
|
166
|
+
beforeEach(() => {
|
|
167
|
+
vi.resetModules();
|
|
168
|
+
vi.unstubAllGlobals();
|
|
169
|
+
vi.clearAllMocks();
|
|
170
|
+
mocks.leaseReleaseMutationHook = null;
|
|
171
|
+
tempDir = mkdtempSync(join(tmpdir(), "alan-agent-test-"));
|
|
172
|
+
mocks.defaultWorkspacePath = join(tempDir, ".alan", "workspace");
|
|
173
|
+
configPath = join(tempDir, "config.json");
|
|
174
|
+
process.env.ALAN_AGENT_CONFIG_PATH = configPath;
|
|
175
|
+
process.env.ALAN_MCP_READINESS_MODE = "off";
|
|
176
|
+
mocks.collectLocalAgentProviderLimits.mockResolvedValue({});
|
|
177
|
+
mocks.runRuntimeModelScan.mockResolvedValue(undefined);
|
|
178
|
+
mocks.discoverRelocatedWorkspace.mockReturnValue(null);
|
|
179
|
+
mocks.workspaceMatchesExpectedRepositories.mockReturnValue(true);
|
|
180
|
+
mocks.workspaceLeaseVerify.mockReturnValue(null);
|
|
181
|
+
mocks.captureWorkspaceRunLease.mockReturnValue({
|
|
182
|
+
lease: {
|
|
183
|
+
verify: mocks.workspaceLeaseVerify,
|
|
184
|
+
// The monitor now uses verifyDetailed(); derive it from the same verify
|
|
185
|
+
// mock. Failures here are treated as definitive (transient: false) so the
|
|
186
|
+
// existing lease-change tests still fail the run on the first observation.
|
|
187
|
+
verifyDetailed: () => ({ failure: mocks.workspaceLeaseVerify(), transient: false }),
|
|
188
|
+
},
|
|
189
|
+
failure: null,
|
|
190
|
+
});
|
|
191
|
+
mocks.agentRun.mockResolvedValue(undefined);
|
|
192
|
+
mocks.sendToolResponse.mockReturnValue(true);
|
|
193
|
+
mocks.ensureAlanMcpRegistered.mockResolvedValue({ ok: true, paths: [] });
|
|
194
|
+
mocks.verifyAlanMcpRegistered.mockResolvedValue({ ok: true, missing: [] });
|
|
195
|
+
mocks.coreAgentConstructor.mockImplementation(function MockCoreAgent(_presenter, runtime) {
|
|
196
|
+
return {
|
|
197
|
+
run: (...args) => {
|
|
198
|
+
runtime?.onProviderSpawned?.(process.pid);
|
|
199
|
+
return mocks.agentRun(...args);
|
|
200
|
+
},
|
|
201
|
+
kill: mocks.agentKill,
|
|
202
|
+
sendToolResponse: mocks.sendToolResponse,
|
|
203
|
+
};
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
afterEach(() => {
|
|
207
|
+
delete process.env.ALAN_AGENT_CONFIG_PATH;
|
|
208
|
+
delete process.env.ALAN_API_URL;
|
|
209
|
+
delete process.env.ALAN_WS_URL;
|
|
210
|
+
delete process.env.ALAN_AGENT_PROFILE;
|
|
211
|
+
delete process.env.ALAN_AGENT_ENDPOINTS_PATH;
|
|
212
|
+
delete process.env.ALAN_TEAM_ID;
|
|
213
|
+
delete process.env.ALAN_SETUP_TOKEN;
|
|
214
|
+
delete process.env.ALAN_CODEX_PATH;
|
|
215
|
+
delete process.env.ALAN_CLAUDE_PATH;
|
|
216
|
+
delete process.env.ALAN_ANTIGRAVITY_PATH;
|
|
217
|
+
delete process.env.ALAN_OPENCODE_PATH;
|
|
218
|
+
delete process.env.ALAN_MCP_READINESS_MODE;
|
|
219
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
220
|
+
vi.unstubAllGlobals();
|
|
221
|
+
vi.unstubAllEnvs();
|
|
222
|
+
});
|
|
223
|
+
it("discovers sanitized local capabilities without exposing binary paths", async () => {
|
|
224
|
+
const binDir = seedCliBinaries(tempDir);
|
|
225
|
+
process.env.PATH = binDir;
|
|
226
|
+
vi.stubEnv("SHELL", join(tempDir, "missing-shell"));
|
|
227
|
+
const { discoverCapabilities } = await import("../daemon.js");
|
|
228
|
+
const capabilities = discoverCapabilities();
|
|
229
|
+
expect(capabilities.hasGit).toBe(true);
|
|
230
|
+
expect(capabilities.hasTerminal).toBe(true);
|
|
231
|
+
expect(capabilities.supportsFilesystem).toBe(true);
|
|
232
|
+
expect(capabilities.agents).toHaveLength(10);
|
|
233
|
+
expect(capabilities.agents).toEqual(expect.arrayContaining([
|
|
234
|
+
expect.objectContaining({
|
|
235
|
+
provider: "codex_app_server",
|
|
236
|
+
available: true,
|
|
237
|
+
models: [],
|
|
238
|
+
}),
|
|
239
|
+
expect.objectContaining({
|
|
240
|
+
provider: "claude_cli",
|
|
241
|
+
available: true,
|
|
242
|
+
}),
|
|
243
|
+
]));
|
|
244
|
+
expect(capabilities.agents.every((agent) => agent.version === undefined)).toBe(true);
|
|
245
|
+
expect(JSON.stringify(capabilities)).not.toContain("/Users/");
|
|
246
|
+
});
|
|
247
|
+
it("maps a selected Project subfolder into the repository worktree", async () => {
|
|
248
|
+
const { resolveProjectWorktreePaths } = await import("../daemon.js");
|
|
249
|
+
const repoPath = join(tempDir, "repo");
|
|
250
|
+
const projectPath = join(repoPath, "apps", "web");
|
|
251
|
+
const worktreePath = join(tempDir, "repo-alan-worktrees", "task");
|
|
252
|
+
expect(resolveProjectWorktreePaths(projectPath, repoPath, worktreePath)).toEqual({
|
|
253
|
+
repoPath,
|
|
254
|
+
projectPath: join(worktreePath, "apps", "web"),
|
|
255
|
+
});
|
|
256
|
+
expect(resolveProjectWorktreePaths(repoPath, repoPath, worktreePath)).toEqual({
|
|
257
|
+
repoPath,
|
|
258
|
+
projectPath: worktreePath,
|
|
259
|
+
});
|
|
260
|
+
expect(() => resolveProjectWorktreePaths(join(tempDir, "another-repo"), repoPath, worktreePath)).toThrow("outside its Git repository");
|
|
261
|
+
});
|
|
262
|
+
it("refreshProviderVersions populates cached versions without discoverCapabilities blocking on spawnSync", async () => {
|
|
263
|
+
const binDir = join(tempDir, "bin");
|
|
264
|
+
mkdirSync(binDir, { recursive: true });
|
|
265
|
+
const codexPath = join(binDir, "codex");
|
|
266
|
+
writeFileSync(codexPath, "#!/bin/sh\necho 'codex-cli 9.9.9'\n");
|
|
267
|
+
chmodSync(codexPath, 0o755);
|
|
268
|
+
process.env.PATH = binDir;
|
|
269
|
+
process.env.ALAN_CODEX_PATH = codexPath;
|
|
270
|
+
vi.stubEnv("SHELL", join(tempDir, "missing-shell"));
|
|
271
|
+
const { discoverCapabilities, refreshProviderVersions } = await import("../daemon.js");
|
|
272
|
+
const before = discoverCapabilities();
|
|
273
|
+
expect(before.agents.find((agent) => agent.provider === "codex_app_server")?.version).toBe(undefined);
|
|
274
|
+
expect(mocks.spawnSync).not.toHaveBeenCalled();
|
|
275
|
+
await refreshProviderVersions();
|
|
276
|
+
const after = discoverCapabilities();
|
|
277
|
+
expect(after.agents.find((agent) => agent.provider === "codex_app_server")?.version).toBe("codex-cli 9.9.9");
|
|
278
|
+
expect(mocks.spawnSync).not.toHaveBeenCalled();
|
|
279
|
+
});
|
|
280
|
+
it("refreshProviderModels discovers OpenCode models without blocking discoverCapabilities", async () => {
|
|
281
|
+
const binDir = seedCliBinaries(tempDir);
|
|
282
|
+
const opencodePath = join(binDir, "opencode");
|
|
283
|
+
writeFileSync(opencodePath, [
|
|
284
|
+
"#!/bin/sh",
|
|
285
|
+
'if [ "$1 $2" = "models --json" ]; then',
|
|
286
|
+
' printf \'%s\\n\' \'{"models":[{"id":"opencode/custom-free"}]}\'',
|
|
287
|
+
" exit 0",
|
|
288
|
+
"fi",
|
|
289
|
+
"exit 1",
|
|
290
|
+
"",
|
|
291
|
+
].join("\n"));
|
|
292
|
+
chmodSync(opencodePath, 0o755);
|
|
293
|
+
process.env.ALAN_OPENCODE_PATH = opencodePath;
|
|
294
|
+
process.env.PATH = binDir;
|
|
295
|
+
const { discoverCapabilities, refreshProviderModels } = await import("../daemon.js");
|
|
296
|
+
const before = discoverCapabilities();
|
|
297
|
+
expect(before.agents.find((agent) => agent.provider === "opencode_cli")?.models).toEqual([]);
|
|
298
|
+
expect(mocks.spawnSync).not.toHaveBeenCalled();
|
|
299
|
+
await refreshProviderModels(["opencode_cli"]);
|
|
300
|
+
const capabilities = discoverCapabilities();
|
|
301
|
+
expect(capabilities.agents).toEqual(expect.arrayContaining([
|
|
302
|
+
expect.objectContaining({
|
|
303
|
+
provider: "opencode_cli",
|
|
304
|
+
available: true,
|
|
305
|
+
models: ["opencode/custom-free"],
|
|
306
|
+
}),
|
|
307
|
+
]));
|
|
308
|
+
expect(mocks.spawnSync).not.toHaveBeenCalled();
|
|
309
|
+
});
|
|
310
|
+
it("refreshProviderModels discovers Antigravity models without blocking discoverCapabilities", async () => {
|
|
311
|
+
const binDir = seedCliBinaries(tempDir);
|
|
312
|
+
const agyPath = join(binDir, "agy");
|
|
313
|
+
writeFileSync(agyPath, [
|
|
314
|
+
"#!/bin/sh",
|
|
315
|
+
'if [ "$1 $2" = "models --json" ]; then',
|
|
316
|
+
" printf '%s\\n' 'json not supported' >&2",
|
|
317
|
+
" exit 1",
|
|
318
|
+
"fi",
|
|
319
|
+
'if [ "$1" = "models" ]; then',
|
|
320
|
+
" printf '%s\\n' 'Antigravity 3.5 Flash (High)' 'Claude Sonnet 4.6 (Thinking)'",
|
|
321
|
+
" exit 0",
|
|
322
|
+
"fi",
|
|
323
|
+
"exit 1",
|
|
324
|
+
"",
|
|
325
|
+
].join("\n"));
|
|
326
|
+
chmodSync(agyPath, 0o755);
|
|
327
|
+
process.env.ALAN_ANTIGRAVITY_PATH = agyPath;
|
|
328
|
+
process.env.PATH = binDir;
|
|
329
|
+
const { discoverCapabilities, refreshProviderModels } = await import("../daemon.js");
|
|
330
|
+
const before = discoverCapabilities();
|
|
331
|
+
expect(before.agents.find((agent) => agent.provider === "antigravity_cli")?.models).toEqual([]);
|
|
332
|
+
expect(mocks.spawnSync).not.toHaveBeenCalled();
|
|
333
|
+
await refreshProviderModels(["antigravity_cli"]);
|
|
334
|
+
const capabilities = discoverCapabilities();
|
|
335
|
+
expect(capabilities.agents).toEqual(expect.arrayContaining([
|
|
336
|
+
expect.objectContaining({
|
|
337
|
+
provider: "antigravity_cli",
|
|
338
|
+
available: true,
|
|
339
|
+
models: ["Antigravity 3.5 Flash (High)", "Claude Sonnet 4.6 (Thinking)"],
|
|
340
|
+
}),
|
|
341
|
+
]));
|
|
342
|
+
expect(mocks.spawnSync).not.toHaveBeenCalled();
|
|
343
|
+
});
|
|
344
|
+
it("falls back to known free OpenCode models when model discovery fails", async () => {
|
|
345
|
+
const binDir = seedCliBinaries(tempDir);
|
|
346
|
+
const opencodePath = join(binDir, "opencode");
|
|
347
|
+
writeFileSync(opencodePath, "#!/bin/sh\nexit 1\n");
|
|
348
|
+
chmodSync(opencodePath, 0o755);
|
|
349
|
+
process.env.ALAN_OPENCODE_PATH = opencodePath;
|
|
350
|
+
process.env.PATH = binDir;
|
|
351
|
+
const { discoverCapabilities, refreshProviderModels } = await import("../daemon.js");
|
|
352
|
+
await refreshProviderModels(["opencode_cli"]);
|
|
353
|
+
const capabilities = discoverCapabilities();
|
|
354
|
+
expect(capabilities.agents).toEqual(expect.arrayContaining([
|
|
355
|
+
expect.objectContaining({
|
|
356
|
+
provider: "opencode_cli",
|
|
357
|
+
available: true,
|
|
358
|
+
models: OPENCODE_ZEN_MODELS.map((model) => model.id),
|
|
359
|
+
}),
|
|
360
|
+
]));
|
|
361
|
+
expect(mocks.spawnSync).not.toHaveBeenCalled();
|
|
362
|
+
});
|
|
363
|
+
it("refreshProviderModels probes codex models from CLI without spawnSync", async () => {
|
|
364
|
+
const binDir = seedCliBinaries(tempDir);
|
|
365
|
+
const codexPath = join(binDir, "codex");
|
|
366
|
+
writeFileSync(codexPath, [
|
|
367
|
+
"#!/bin/sh",
|
|
368
|
+
'if [ "$1" = "debug" ] && [ "$2" = "models" ]; then',
|
|
369
|
+
' printf \'%s\' \'{"models":[{"slug":"gpt-5.5","visibility":"list"},{"slug":"gpt-5.3-codex-spark","visibility":"list"}]}\'',
|
|
370
|
+
" exit 0",
|
|
371
|
+
"fi",
|
|
372
|
+
"exit 1",
|
|
373
|
+
"",
|
|
374
|
+
].join("\n"));
|
|
375
|
+
chmodSync(codexPath, 0o755);
|
|
376
|
+
process.env.ALAN_CODEX_PATH = codexPath;
|
|
377
|
+
process.env.PATH = binDir;
|
|
378
|
+
const { discoverCapabilities, refreshProviderModels } = await import("../daemon.js");
|
|
379
|
+
await refreshProviderModels(["codex_app_server"]);
|
|
380
|
+
const capabilities = discoverCapabilities();
|
|
381
|
+
expect(capabilities.agents).toEqual(expect.arrayContaining([
|
|
382
|
+
expect.objectContaining({
|
|
383
|
+
provider: "codex_app_server",
|
|
384
|
+
available: true,
|
|
385
|
+
models: ["gpt-5.5", "gpt-5.3-codex-spark"],
|
|
386
|
+
}),
|
|
387
|
+
]));
|
|
388
|
+
expect(mocks.spawnSync).not.toHaveBeenCalled();
|
|
389
|
+
});
|
|
390
|
+
it("scanAgentCapabilities refreshes versions and models before returning capabilities", async () => {
|
|
391
|
+
const binDir = join(tempDir, "bin");
|
|
392
|
+
mkdirSync(binDir, { recursive: true });
|
|
393
|
+
const codexPath = join(binDir, "codex");
|
|
394
|
+
writeFileSync(codexPath, `#!/bin/sh
|
|
395
|
+
if [ "$1" = "--version" ]; then echo 'codex-cli 9.9.9'; exit 0; fi
|
|
396
|
+
if [ "$1" = "debug" ] && [ "$2" = "models" ]; then
|
|
397
|
+
echo '{"models":[{"slug":"gpt-5.5","visibility":"list"}]}'
|
|
398
|
+
exit 0
|
|
399
|
+
fi
|
|
400
|
+
exit 1
|
|
401
|
+
`);
|
|
402
|
+
chmodSync(codexPath, 0o755);
|
|
403
|
+
process.env.ALAN_CODEX_PATH = codexPath;
|
|
404
|
+
process.env.PATH = binDir;
|
|
405
|
+
const { scanAgentCapabilities } = await import("../daemon.js");
|
|
406
|
+
const capabilities = await scanAgentCapabilities(["codex_app_server"]);
|
|
407
|
+
expect(capabilities.agents).toEqual(expect.arrayContaining([
|
|
408
|
+
expect.objectContaining({
|
|
409
|
+
provider: "codex_app_server",
|
|
410
|
+
available: true,
|
|
411
|
+
models: expect.arrayContaining(["gpt-5.5"]),
|
|
412
|
+
}),
|
|
413
|
+
]));
|
|
414
|
+
});
|
|
415
|
+
it("setup registers a daemon runtime and persists only runtime config locally", async () => {
|
|
416
|
+
mocks.spawnSync.mockReturnValue({ status: 1, stdout: "", stderr: "" });
|
|
417
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
418
|
+
ok: true,
|
|
419
|
+
json: async () => ({
|
|
420
|
+
runtime: {
|
|
421
|
+
id: "11111111-1111-4111-8111-111111111111",
|
|
422
|
+
displayName: "CI Runtime",
|
|
423
|
+
},
|
|
424
|
+
runtimeToken: "alan_runtime_secret",
|
|
425
|
+
runtimeRenewalToken: "alan_runtime_renew_secret",
|
|
426
|
+
}),
|
|
427
|
+
});
|
|
428
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
429
|
+
const { setupDaemon } = await import("../daemon.js");
|
|
430
|
+
await setupDaemon([
|
|
431
|
+
"--api-url",
|
|
432
|
+
"http://api.test",
|
|
433
|
+
"--ws-url",
|
|
434
|
+
"ws://ws.test",
|
|
435
|
+
"--token",
|
|
436
|
+
"alan_pat_secret",
|
|
437
|
+
"--name",
|
|
438
|
+
"CI Runtime",
|
|
439
|
+
]);
|
|
440
|
+
expect(fetchMock).toHaveBeenCalledWith("http://api.test/public/runtimes", expect.objectContaining({
|
|
441
|
+
method: "POST",
|
|
442
|
+
headers: expect.objectContaining({
|
|
443
|
+
authorization: "Bearer alan_pat_secret",
|
|
444
|
+
}),
|
|
445
|
+
}));
|
|
446
|
+
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
447
|
+
expect(body).toMatchObject({
|
|
448
|
+
displayName: "CI Runtime",
|
|
449
|
+
runtimeKind: "machine",
|
|
450
|
+
hostKind: "daemon",
|
|
451
|
+
lifecycle: "durable",
|
|
452
|
+
});
|
|
453
|
+
expect(body).not.toHaveProperty("ownerType");
|
|
454
|
+
expect(body).not.toHaveProperty("visibility");
|
|
455
|
+
const saved = JSON.parse(readFileSync(configPath, "utf8"));
|
|
456
|
+
expect(saved).toEqual({
|
|
457
|
+
apiUrl: "http://api.test",
|
|
458
|
+
wsUrl: "ws://ws.test",
|
|
459
|
+
endpointProfile: "custom",
|
|
460
|
+
runtimeId: "11111111-1111-4111-8111-111111111111",
|
|
461
|
+
runtimeToken: "alan_runtime_secret",
|
|
462
|
+
runtimeRenewalToken: "alan_runtime_renew_secret",
|
|
463
|
+
localApiPort: 47831,
|
|
464
|
+
localApiToken: expect.any(String),
|
|
465
|
+
localServiceId: expect.any(String),
|
|
466
|
+
localServiceSecret: expect.any(String),
|
|
467
|
+
eventSpoolKey: expect.any(String),
|
|
468
|
+
installationId: expect.any(String),
|
|
469
|
+
displayName: "CI Runtime",
|
|
470
|
+
});
|
|
471
|
+
expect(readFileSync(configPath, "utf8")).not.toContain("alan_pat_secret");
|
|
472
|
+
});
|
|
473
|
+
it("retries transient setup registration failures with one stable attempt id", async () => {
|
|
474
|
+
mocks.spawnSync.mockReturnValue({ status: 1, stdout: "", stderr: "" });
|
|
475
|
+
const fetchMock = vi
|
|
476
|
+
.fn()
|
|
477
|
+
.mockResolvedValueOnce({
|
|
478
|
+
ok: false,
|
|
479
|
+
status: 503,
|
|
480
|
+
text: async () => "temporarily unavailable",
|
|
481
|
+
})
|
|
482
|
+
.mockResolvedValueOnce({
|
|
483
|
+
ok: true,
|
|
484
|
+
json: async () => ({
|
|
485
|
+
runtime: {
|
|
486
|
+
id: "11111111-1111-4111-8111-111111111111",
|
|
487
|
+
displayName: "Retry Runtime",
|
|
488
|
+
},
|
|
489
|
+
runtimeToken: "alan_runtime_secret",
|
|
490
|
+
}),
|
|
491
|
+
});
|
|
492
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
493
|
+
const { setupDaemon } = await import("../daemon.js");
|
|
494
|
+
await setupDaemon([
|
|
495
|
+
"--api-url",
|
|
496
|
+
"http://api.test",
|
|
497
|
+
"--ws-url",
|
|
498
|
+
"ws://ws.test",
|
|
499
|
+
"--setup-token",
|
|
500
|
+
"alan_setup_secret",
|
|
501
|
+
"--name",
|
|
502
|
+
"Retry Runtime",
|
|
503
|
+
]);
|
|
504
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
505
|
+
const firstBody = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
506
|
+
const secondBody = JSON.parse(fetchMock.mock.calls[1][1].body);
|
|
507
|
+
expect(firstBody.registrationAttemptId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
|
|
508
|
+
expect(secondBody.registrationAttemptId).toBe(firstBody.registrationAttemptId);
|
|
509
|
+
expect(JSON.parse(readFileSync(configPath, "utf8"))).toMatchObject({
|
|
510
|
+
runtimeId: "11111111-1111-4111-8111-111111111111",
|
|
511
|
+
runtimeToken: "alan_runtime_secret",
|
|
512
|
+
});
|
|
513
|
+
});
|
|
514
|
+
it("resumes the same setup attempt after local config replacement fails", async () => {
|
|
515
|
+
mocks.spawnSync.mockReturnValue({ status: 1, stdout: "", stderr: "" });
|
|
516
|
+
const successfulRegistration = {
|
|
517
|
+
ok: true,
|
|
518
|
+
json: async () => ({
|
|
519
|
+
runtime: {
|
|
520
|
+
id: "11111111-1111-4111-8111-111111111111",
|
|
521
|
+
displayName: "Resumed Runtime",
|
|
522
|
+
},
|
|
523
|
+
runtimeToken: "alan_runtime_secret",
|
|
524
|
+
}),
|
|
525
|
+
};
|
|
526
|
+
const fetchMock = vi
|
|
527
|
+
.fn()
|
|
528
|
+
.mockResolvedValueOnce(successfulRegistration)
|
|
529
|
+
.mockResolvedValueOnce(successfulRegistration);
|
|
530
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
531
|
+
mkdirSync(configPath);
|
|
532
|
+
const { setupDaemon } = await import("../daemon.js");
|
|
533
|
+
const setupArgs = [
|
|
534
|
+
"--api-url",
|
|
535
|
+
"http://api.test",
|
|
536
|
+
"--ws-url",
|
|
537
|
+
"ws://ws.test",
|
|
538
|
+
"--setup-token",
|
|
539
|
+
"alan_setup_secret",
|
|
540
|
+
"--name",
|
|
541
|
+
"Resumed Runtime",
|
|
542
|
+
];
|
|
543
|
+
await expect(setupDaemon(setupArgs)).rejects.toThrow();
|
|
544
|
+
rmSync(configPath, { recursive: true, force: true });
|
|
545
|
+
await setupDaemon(setupArgs);
|
|
546
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
547
|
+
const firstBody = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
548
|
+
const secondBody = JSON.parse(fetchMock.mock.calls[1][1].body);
|
|
549
|
+
expect(secondBody.registrationAttemptId).toBe(firstBody.registrationAttemptId);
|
|
550
|
+
expect(existsSync(`${configPath}.setup-pending`)).toBe(false);
|
|
551
|
+
expect(JSON.parse(readFileSync(configPath, "utf8"))).toMatchObject({
|
|
552
|
+
runtimeId: "11111111-1111-4111-8111-111111111111",
|
|
553
|
+
runtimeToken: "alan_runtime_secret",
|
|
554
|
+
});
|
|
555
|
+
});
|
|
556
|
+
it("setup uses install-script endpoint defaults when endpoint flags are omitted", async () => {
|
|
557
|
+
mocks.spawnSync.mockReturnValue({ status: 1, stdout: "", stderr: "" });
|
|
558
|
+
const endpointsPath = join(tempDir, "endpoints.json");
|
|
559
|
+
process.env.ALAN_AGENT_ENDPOINTS_PATH = endpointsPath;
|
|
560
|
+
writeFileSync(endpointsPath, JSON.stringify({
|
|
561
|
+
apiUrl: "https://preview-api.tryalan.ai",
|
|
562
|
+
wsUrl: "wss://preview-ws.tryalan.ai",
|
|
563
|
+
endpointProfile: "custom",
|
|
564
|
+
}));
|
|
565
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
566
|
+
ok: true,
|
|
567
|
+
json: async () => ({
|
|
568
|
+
runtime: {
|
|
569
|
+
id: "11111111-1111-4111-8111-111111111111",
|
|
570
|
+
displayName: "Installed Runtime",
|
|
571
|
+
},
|
|
572
|
+
runtimeToken: "alan_runtime_secret",
|
|
573
|
+
}),
|
|
574
|
+
});
|
|
575
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
576
|
+
const { setupDaemon } = await import("../daemon.js");
|
|
577
|
+
await setupDaemon(["--setup-token", "alan_setup_secret", "--name", "Installed Runtime"]);
|
|
578
|
+
expect(fetchMock).toHaveBeenCalledWith("https://preview-api.tryalan.ai/public/runtimes/register-with-setup-token", expect.objectContaining({ method: "POST" }));
|
|
579
|
+
const saved = JSON.parse(readFileSync(configPath, "utf8"));
|
|
580
|
+
expect(saved).toMatchObject({
|
|
581
|
+
apiUrl: "https://preview-api.tryalan.ai",
|
|
582
|
+
wsUrl: "wss://preview-ws.tryalan.ai",
|
|
583
|
+
endpointProfile: "custom",
|
|
584
|
+
runtimeId: "11111111-1111-4111-8111-111111111111",
|
|
585
|
+
runtimeToken: "alan_runtime_secret",
|
|
586
|
+
});
|
|
587
|
+
});
|
|
588
|
+
it("setup fails fast without team and setup token", async () => {
|
|
589
|
+
const { setupDaemon } = await import("../daemon.js");
|
|
590
|
+
await expect(setupDaemon(["--team", "22222222-2222-4222-8222-222222222222"])).rejects.toThrow("setup requires --setup-token <token>");
|
|
591
|
+
});
|
|
592
|
+
it("setup can register with a short-lived runtime setup token without storing a PAT", async () => {
|
|
593
|
+
mocks.spawnSync.mockReturnValue({ status: 1, stdout: "", stderr: "" });
|
|
594
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
595
|
+
ok: true,
|
|
596
|
+
json: async () => ({
|
|
597
|
+
runtime: {
|
|
598
|
+
id: "11111111-1111-4111-8111-111111111111",
|
|
599
|
+
displayName: "Headless CI",
|
|
600
|
+
},
|
|
601
|
+
runtimeToken: "alan_runtime_secret",
|
|
602
|
+
}),
|
|
603
|
+
});
|
|
604
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
605
|
+
const { setupDaemon } = await import("../daemon.js");
|
|
606
|
+
await setupDaemon([
|
|
607
|
+
"--api-url",
|
|
608
|
+
"http://api.test",
|
|
609
|
+
"--ws-url",
|
|
610
|
+
"ws://ws.test",
|
|
611
|
+
"--setup-token",
|
|
612
|
+
"alan_setup_secret",
|
|
613
|
+
"--name",
|
|
614
|
+
"Headless CI",
|
|
615
|
+
]);
|
|
616
|
+
expect(fetchMock).toHaveBeenCalledWith("http://api.test/public/runtimes/register-with-setup-token", expect.objectContaining({
|
|
617
|
+
method: "POST",
|
|
618
|
+
headers: { "content-type": "application/json" },
|
|
619
|
+
}));
|
|
620
|
+
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
621
|
+
expect(body).toMatchObject({
|
|
622
|
+
setupToken: "alan_setup_secret",
|
|
623
|
+
displayName: "Headless CI",
|
|
624
|
+
runtimeKind: "machine",
|
|
625
|
+
hostKind: "daemon",
|
|
626
|
+
});
|
|
627
|
+
expect(body).not.toHaveProperty("ownerType");
|
|
628
|
+
expect(body).not.toHaveProperty("visibility");
|
|
629
|
+
expect(body).not.toHaveProperty("token");
|
|
630
|
+
const saved = JSON.parse(readFileSync(configPath, "utf8"));
|
|
631
|
+
expect(saved.runtimeToken).toBe("alan_runtime_secret");
|
|
632
|
+
expect(readFileSync(configPath, "utf8")).not.toContain("alan_setup_secret");
|
|
633
|
+
});
|
|
634
|
+
it("keeps one installation identity across repeated setup", async () => {
|
|
635
|
+
mocks.spawnSync.mockReturnValue({ status: 1, stdout: "", stderr: "" });
|
|
636
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
637
|
+
ok: true,
|
|
638
|
+
json: async () => ({
|
|
639
|
+
runtime: {
|
|
640
|
+
id: "11111111-1111-4111-8111-111111111111",
|
|
641
|
+
displayName: "Renamed Laptop",
|
|
642
|
+
},
|
|
643
|
+
runtimeToken: "alan_runtime_secret",
|
|
644
|
+
}),
|
|
645
|
+
});
|
|
646
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
647
|
+
const { setupDaemon } = await import("../daemon.js");
|
|
648
|
+
await setupDaemon(["--setup-token", "alan_setup_first", "--name", "Original Laptop"]);
|
|
649
|
+
const firstSaved = JSON.parse(readFileSync(configPath, "utf8"));
|
|
650
|
+
await setupDaemon(["--setup-token", "alan_setup_second", "--name", "Renamed Laptop"]);
|
|
651
|
+
const firstBody = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
652
|
+
const secondBody = JSON.parse(fetchMock.mock.calls[1][1].body);
|
|
653
|
+
expect(firstBody.metadata.installationId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
|
|
654
|
+
expect(secondBody.metadata.installationId).toBe(firstBody.metadata.installationId);
|
|
655
|
+
const saved = JSON.parse(readFileSync(configPath, "utf8"));
|
|
656
|
+
expect(saved.installationId).toBe(firstBody.metadata.installationId);
|
|
657
|
+
expect(saved.localApiToken).toBe(firstSaved.localApiToken);
|
|
658
|
+
expect(saved.localServiceId).toBe(firstSaved.localServiceId);
|
|
659
|
+
expect(saved.localServiceSecret).toBe(firstSaved.localServiceSecret);
|
|
660
|
+
});
|
|
661
|
+
it("setup can register a personal runtime with a setup token", async () => {
|
|
662
|
+
mocks.spawnSync.mockReturnValue({ status: 1, stdout: "", stderr: "" });
|
|
663
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
664
|
+
ok: true,
|
|
665
|
+
json: async () => ({
|
|
666
|
+
runtime: {
|
|
667
|
+
id: "11111111-1111-4111-8111-111111111111",
|
|
668
|
+
displayName: "Personal Laptop",
|
|
669
|
+
},
|
|
670
|
+
runtimeToken: "alan_runtime_secret",
|
|
671
|
+
}),
|
|
672
|
+
});
|
|
673
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
674
|
+
const { setupDaemon } = await import("../daemon.js");
|
|
675
|
+
await setupDaemon([
|
|
676
|
+
"--api-url",
|
|
677
|
+
"http://api.test",
|
|
678
|
+
"--ws-url",
|
|
679
|
+
"ws://ws.test",
|
|
680
|
+
"--setup-token",
|
|
681
|
+
"alan_setup_secret",
|
|
682
|
+
"--scope",
|
|
683
|
+
"user",
|
|
684
|
+
"--name",
|
|
685
|
+
"Personal Laptop",
|
|
686
|
+
]);
|
|
687
|
+
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
688
|
+
expect(body).toMatchObject({
|
|
689
|
+
setupToken: "alan_setup_secret",
|
|
690
|
+
displayName: "Personal Laptop",
|
|
691
|
+
});
|
|
692
|
+
expect(body).not.toHaveProperty("ownerType");
|
|
693
|
+
expect(body).not.toHaveProperty("visibility");
|
|
694
|
+
});
|
|
695
|
+
it("continues an expired setup link through browser authorization", async () => {
|
|
696
|
+
mocks.spawnSync.mockReturnValue({ status: 1, stdout: "", stderr: "" });
|
|
697
|
+
const fetchMock = vi
|
|
698
|
+
.fn()
|
|
699
|
+
.mockResolvedValueOnce({
|
|
700
|
+
ok: false,
|
|
701
|
+
status: 401,
|
|
702
|
+
text: async () => JSON.stringify({ error: "setup_token_expired" }),
|
|
703
|
+
})
|
|
704
|
+
.mockResolvedValueOnce({
|
|
705
|
+
ok: true,
|
|
706
|
+
json: async () => ({
|
|
707
|
+
deviceCode: "alan_device_secret",
|
|
708
|
+
userCode: "ABCD1234",
|
|
709
|
+
verificationUri: "https://app.tryalan.ai/device",
|
|
710
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
711
|
+
intervalSeconds: 0,
|
|
712
|
+
}),
|
|
713
|
+
})
|
|
714
|
+
.mockResolvedValueOnce({
|
|
715
|
+
ok: true,
|
|
716
|
+
json: async () => ({
|
|
717
|
+
runtime: { id: "11111111-1111-4111-8111-111111111111", displayName: "Laptop" },
|
|
718
|
+
runtimeToken: "alan_runtime_secret",
|
|
719
|
+
}),
|
|
720
|
+
});
|
|
721
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
722
|
+
const { setupDaemon } = await import("../daemon.js");
|
|
723
|
+
await setupDaemon(["--setup-token", "alan_setup_expired", "--name", "Laptop"]);
|
|
724
|
+
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
|
|
725
|
+
"https://api.tryalan.ai/public/runtimes/register-with-setup-token",
|
|
726
|
+
"https://api.tryalan.ai/public/runtimes/device-authorizations",
|
|
727
|
+
"https://api.tryalan.ai/public/runtimes/device-authorizations/token",
|
|
728
|
+
]);
|
|
729
|
+
expect(JSON.parse(readFileSync(configPath, "utf8"))).toMatchObject({
|
|
730
|
+
runtimeId: "11111111-1111-4111-8111-111111111111",
|
|
731
|
+
runtimeToken: "alan_runtime_secret",
|
|
732
|
+
});
|
|
733
|
+
expect(existsSync(`${configPath}.setup-pending`)).toBe(false);
|
|
734
|
+
});
|
|
735
|
+
it("setup uses production endpoints by default for installable package users", async () => {
|
|
736
|
+
mocks.spawnSync.mockReturnValue({ status: 1, stdout: "", stderr: "" });
|
|
737
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
738
|
+
ok: true,
|
|
739
|
+
json: async () => ({
|
|
740
|
+
runtime: {
|
|
741
|
+
id: "11111111-1111-4111-8111-111111111111",
|
|
742
|
+
displayName: "Prod Runtime",
|
|
743
|
+
},
|
|
744
|
+
runtimeToken: "alan_runtime_secret",
|
|
745
|
+
}),
|
|
746
|
+
});
|
|
747
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
748
|
+
const { setupDaemon } = await import("../daemon.js");
|
|
749
|
+
await setupDaemon(["--setup-token", "alan_setup_secret"]);
|
|
750
|
+
expect(fetchMock).toHaveBeenCalledWith("https://api.tryalan.ai/public/runtimes/register-with-setup-token", expect.objectContaining({ method: "POST" }));
|
|
751
|
+
const saved = JSON.parse(readFileSync(configPath, "utf8"));
|
|
752
|
+
expect(saved).toMatchObject({
|
|
753
|
+
apiUrl: "https://api.tryalan.ai",
|
|
754
|
+
wsUrl: "wss://ws.tryalan.ai",
|
|
755
|
+
endpointProfile: "production",
|
|
756
|
+
});
|
|
757
|
+
});
|
|
758
|
+
it("setup uses localhost only when explicitly requested with local profile", async () => {
|
|
759
|
+
mocks.spawnSync.mockReturnValue({ status: 1, stdout: "", stderr: "" });
|
|
760
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
761
|
+
ok: true,
|
|
762
|
+
json: async () => ({
|
|
763
|
+
runtime: {
|
|
764
|
+
id: "11111111-1111-4111-8111-111111111111",
|
|
765
|
+
displayName: "Local Runtime",
|
|
766
|
+
},
|
|
767
|
+
runtimeToken: "alan_runtime_secret",
|
|
768
|
+
}),
|
|
769
|
+
});
|
|
770
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
771
|
+
const { setupDaemon } = await import("../daemon.js");
|
|
772
|
+
await setupDaemon(["--profile", "local", "--setup-token", "alan_setup_secret"]);
|
|
773
|
+
expect(fetchMock).toHaveBeenCalledWith("http://localhost:8400/public/runtimes/register-with-setup-token", expect.objectContaining({ method: "POST" }));
|
|
774
|
+
const saved = JSON.parse(readFileSync(configPath, "utf8"));
|
|
775
|
+
expect(saved).toMatchObject({
|
|
776
|
+
apiUrl: "http://localhost:8400",
|
|
777
|
+
wsUrl: "ws://localhost:8401",
|
|
778
|
+
endpointProfile: "local",
|
|
779
|
+
});
|
|
780
|
+
});
|
|
781
|
+
it("rejects startDaemon when an alan-agent daemon is already listening", async () => {
|
|
782
|
+
writeFileSync(configPath, JSON.stringify({
|
|
783
|
+
runtimeId: "11111111-1111-4111-8111-111111111111",
|
|
784
|
+
runtimeToken: "alan_runtime_secret",
|
|
785
|
+
wsUrl: "ws://ws.test",
|
|
786
|
+
localApiToken: "local-secret",
|
|
787
|
+
}));
|
|
788
|
+
const existing = createServer((req, res) => {
|
|
789
|
+
if (req.method === "GET" && req.url === "/status") {
|
|
790
|
+
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({
|
|
791
|
+
configured: true,
|
|
792
|
+
runtimeId: "11111111-1111-4111-8111-111111111111",
|
|
793
|
+
wsUrl: "ws://ws.test",
|
|
794
|
+
connected: true,
|
|
795
|
+
activeRunIds: [],
|
|
796
|
+
localApiPort: 47831,
|
|
797
|
+
version: "test",
|
|
798
|
+
}));
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
res.writeHead(404).end();
|
|
802
|
+
});
|
|
803
|
+
await new Promise((resolve) => existing.listen(0, "127.0.0.1", resolve));
|
|
804
|
+
const address = existing.address();
|
|
805
|
+
const port = typeof address === "object" && address ? address.port : 47831;
|
|
806
|
+
const socket = new TestSocket();
|
|
807
|
+
mocks.io.mockReturnValue(socket);
|
|
808
|
+
const { startDaemon } = await import("../daemon.js");
|
|
809
|
+
await expect(startDaemon(["--local-api-port", String(port)])).rejects.toThrow("already in use");
|
|
810
|
+
expect(socket.disconnected).toBe(true);
|
|
811
|
+
await new Promise((resolve, reject) => {
|
|
812
|
+
existing.close((error) => (error ? reject(error) : resolve()));
|
|
813
|
+
});
|
|
814
|
+
});
|
|
815
|
+
it("runDaemon exits successfully when the local daemon is listening with a stale token", async () => {
|
|
816
|
+
writeFileSync(configPath, JSON.stringify({
|
|
817
|
+
runtimeId: "11111111-1111-4111-8111-111111111111",
|
|
818
|
+
runtimeToken: "alan_runtime_secret",
|
|
819
|
+
wsUrl: "ws://ws.test",
|
|
820
|
+
localApiToken: "stale-token",
|
|
821
|
+
}));
|
|
822
|
+
const existing = createServer((req, res) => {
|
|
823
|
+
if (req.method === "GET" && req.url === "/status") {
|
|
824
|
+
res
|
|
825
|
+
.writeHead(401, { "content-type": "application/json" })
|
|
826
|
+
.end(JSON.stringify({ error: "Unauthorized" }));
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
res.writeHead(404).end();
|
|
830
|
+
});
|
|
831
|
+
await new Promise((resolve) => existing.listen(0, "127.0.0.1", resolve));
|
|
832
|
+
const address = existing.address();
|
|
833
|
+
const port = typeof address === "object" && address ? address.port : 47831;
|
|
834
|
+
const info = vi.spyOn(console, "info").mockImplementation(() => { });
|
|
835
|
+
const { runDaemon } = await import("../daemon.js");
|
|
836
|
+
await runDaemon(["--local-api-port", String(port)]);
|
|
837
|
+
expect(mocks.io).not.toHaveBeenCalled();
|
|
838
|
+
expect(info).toHaveBeenCalledWith("[alan-agent] Daemon already running", expect.objectContaining({ port }));
|
|
839
|
+
await new Promise((resolve, reject) => {
|
|
840
|
+
existing.close((error) => (error ? reject(error) : resolve()));
|
|
841
|
+
});
|
|
842
|
+
});
|
|
843
|
+
it("adopts an owned local daemon but rejects a mismatched owner secret", async () => {
|
|
844
|
+
const serviceSecret = "owner-secret-shared-only-through-the-config-file";
|
|
845
|
+
writeFileSync(configPath, JSON.stringify({
|
|
846
|
+
runtimeId: "11111111-1111-4111-8111-111111111111",
|
|
847
|
+
runtimeToken: "alan_runtime_secret",
|
|
848
|
+
wsUrl: "ws://ws.test",
|
|
849
|
+
localApiToken: "incumbent-local-token-that-is-long-enough",
|
|
850
|
+
localServiceId: "service-owner-1",
|
|
851
|
+
localServiceSecret: serviceSecret,
|
|
852
|
+
installationId: "22222222-2222-4222-8222-222222222222",
|
|
853
|
+
}));
|
|
854
|
+
mocks.io.mockReturnValue(new TestSocket());
|
|
855
|
+
const { adoptConfiguredLocalDaemon, startDaemon } = await import("../daemon.js");
|
|
856
|
+
const controller = await startDaemon(["--local-api-port", "0"]);
|
|
857
|
+
try {
|
|
858
|
+
const runningConfig = JSON.parse(readFileSync(configPath, "utf8"));
|
|
859
|
+
writeFileSync(configPath, JSON.stringify({
|
|
860
|
+
...runningConfig,
|
|
861
|
+
localApiPort: controller.localApiPort,
|
|
862
|
+
localApiToken: "stale-local-token-that-is-long-enough",
|
|
863
|
+
localServiceSecret: "wrong-owner-secret",
|
|
864
|
+
}));
|
|
865
|
+
await expect(adoptConfiguredLocalDaemon([])).resolves.toBeNull();
|
|
866
|
+
expect(JSON.parse(readFileSync(configPath, "utf8")).localApiToken).toBe("stale-local-token-that-is-long-enough");
|
|
867
|
+
writeFileSync(configPath, JSON.stringify({
|
|
868
|
+
...JSON.parse(readFileSync(configPath, "utf8")),
|
|
869
|
+
localServiceSecret: serviceSecret,
|
|
870
|
+
}));
|
|
871
|
+
const adopted = await adoptConfiguredLocalDaemon([]);
|
|
872
|
+
expect(adopted).toMatchObject({
|
|
873
|
+
runtimeId: "11111111-1111-4111-8111-111111111111",
|
|
874
|
+
localServiceId: "service-owner-1",
|
|
875
|
+
});
|
|
876
|
+
expect(JSON.parse(readFileSync(configPath, "utf8")).localApiToken).toBe(controller.localApiToken);
|
|
877
|
+
}
|
|
878
|
+
finally {
|
|
879
|
+
await controller.stop();
|
|
880
|
+
}
|
|
881
|
+
});
|
|
882
|
+
it("runDaemon exits successfully when the local daemon is already running", async () => {
|
|
883
|
+
writeFileSync(configPath, JSON.stringify({
|
|
884
|
+
runtimeId: "11111111-1111-4111-8111-111111111111",
|
|
885
|
+
runtimeToken: "alan_runtime_secret",
|
|
886
|
+
wsUrl: "ws://ws.test",
|
|
887
|
+
localApiToken: "local-secret",
|
|
888
|
+
}));
|
|
889
|
+
const existing = createServer((req, res) => {
|
|
890
|
+
if (req.method === "GET" && req.url === "/status") {
|
|
891
|
+
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({
|
|
892
|
+
configured: true,
|
|
893
|
+
runtimeId: "11111111-1111-4111-8111-111111111111",
|
|
894
|
+
wsUrl: "ws://ws.test",
|
|
895
|
+
connected: true,
|
|
896
|
+
activeRunIds: [],
|
|
897
|
+
localApiPort: 47831,
|
|
898
|
+
version: "test",
|
|
899
|
+
}));
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
res.writeHead(404).end();
|
|
903
|
+
});
|
|
904
|
+
await new Promise((resolve) => existing.listen(0, "127.0.0.1", resolve));
|
|
905
|
+
const address = existing.address();
|
|
906
|
+
const port = typeof address === "object" && address ? address.port : 47831;
|
|
907
|
+
const info = vi.spyOn(console, "info").mockImplementation(() => { });
|
|
908
|
+
const { runDaemon } = await import("../daemon.js");
|
|
909
|
+
await runDaemon(["--local-api-port", String(port)]);
|
|
910
|
+
expect(mocks.io).not.toHaveBeenCalled();
|
|
911
|
+
expect(info).toHaveBeenCalledWith("[alan-agent] Daemon already running", expect.objectContaining({
|
|
912
|
+
runtimeId: "11111111-1111-4111-8111-111111111111",
|
|
913
|
+
}));
|
|
914
|
+
await new Promise((resolve, reject) => {
|
|
915
|
+
existing.close((error) => (error ? reject(error) : resolve()));
|
|
916
|
+
});
|
|
917
|
+
});
|
|
918
|
+
it("shared recovery redials before a run-safe restart and returns typed exhaustion", async () => {
|
|
919
|
+
const { runLocalDaemonRecoveryLadder } = await import("../daemon.js");
|
|
920
|
+
const restart = vi.fn().mockResolvedValue({ connected: true, activeRunCount: 0 });
|
|
921
|
+
const recovered = await runLocalDaemonRecoveryLadder({
|
|
922
|
+
initialStatus: { connected: false, activeRunCount: 0 },
|
|
923
|
+
getStatus: async () => ({ connected: false, activeRunCount: 0 }),
|
|
924
|
+
redial: async () => true,
|
|
925
|
+
restart,
|
|
926
|
+
blockingRunCount: (status) => status.activeRunCount ?? 0,
|
|
927
|
+
options: { connectTimeoutMs: 0, pollMs: 1 },
|
|
928
|
+
});
|
|
929
|
+
expect(restart).toHaveBeenCalledTimes(1);
|
|
930
|
+
expect(recovered).toMatchObject({ connected: true });
|
|
931
|
+
const busyRestart = vi.fn();
|
|
932
|
+
const busy = await runLocalDaemonRecoveryLadder({
|
|
933
|
+
initialStatus: { connected: false, activeRunCount: 2 },
|
|
934
|
+
getStatus: async () => ({ connected: false, activeRunCount: 2 }),
|
|
935
|
+
redial: async () => false,
|
|
936
|
+
restart: busyRestart,
|
|
937
|
+
blockingRunCount: (status) => status.activeRunCount ?? 0,
|
|
938
|
+
options: { connectTimeoutMs: 0, pollMs: 1 },
|
|
939
|
+
});
|
|
940
|
+
expect(busyRestart).not.toHaveBeenCalled();
|
|
941
|
+
expect(busy).toMatchObject({ recoveryCode: "needs_restart_busy", activeRunCount: 2 });
|
|
942
|
+
const restartRace = await runLocalDaemonRecoveryLadder({
|
|
943
|
+
initialStatus: { connected: false, activeRunCount: 0 },
|
|
944
|
+
getStatus: async () => ({ connected: false, activeRunCount: 1 }),
|
|
945
|
+
redial: async () => false,
|
|
946
|
+
restart: async () => ({ connected: false, activeRunCount: 1 }),
|
|
947
|
+
blockingRunCount: (status) => status.activeRunCount ?? 0,
|
|
948
|
+
options: { connectTimeoutMs: 0, pollMs: 1 },
|
|
949
|
+
});
|
|
950
|
+
expect(restartRace).toMatchObject({
|
|
951
|
+
recoveryCode: "needs_restart_busy",
|
|
952
|
+
activeRunCount: 1,
|
|
953
|
+
});
|
|
954
|
+
const exhausted = await runLocalDaemonRecoveryLadder({
|
|
955
|
+
initialStatus: { connected: false, activeRunCount: 0 },
|
|
956
|
+
getStatus: async () => ({ connected: false, activeRunCount: 0 }),
|
|
957
|
+
redial: async () => false,
|
|
958
|
+
restart: async () => ({ connected: false, activeRunCount: 0 }),
|
|
959
|
+
blockingRunCount: (status) => status.activeRunCount ?? 0,
|
|
960
|
+
options: { connectTimeoutMs: 0, pollMs: 1 },
|
|
961
|
+
});
|
|
962
|
+
expect(exhausted).toMatchObject({
|
|
963
|
+
connected: false,
|
|
964
|
+
recoveryCode: "daemon_recovery_failed",
|
|
965
|
+
error: expect.stringContaining("exhausted"),
|
|
966
|
+
});
|
|
967
|
+
});
|
|
968
|
+
it("maintenance leases expire safely and journal inspection includes pending starts", async () => {
|
|
969
|
+
const { acquireLocalDaemonMaintenanceLease, inspectLocalDaemonRunJournal, readLocalDaemonMaintenanceLease, releaseLocalDaemonMaintenanceLease, } = await import("../daemon.js");
|
|
970
|
+
const acquired = acquireLocalDaemonMaintenanceLease(configPath, "watchdog", 1_000);
|
|
971
|
+
expect(acquired.acquired).toBe(true);
|
|
972
|
+
expect(acquireLocalDaemonMaintenanceLease(configPath, "watchdog", 1_000).acquired).toBe(false);
|
|
973
|
+
releaseLocalDaemonMaintenanceLease(configPath, "wrong-id");
|
|
974
|
+
expect(readLocalDaemonMaintenanceLease(configPath)).not.toBeNull();
|
|
975
|
+
expect(readLocalDaemonMaintenanceLease(configPath, (acquired.lease.expiresAtMs ?? Date.now()) + 1)).toBeNull();
|
|
976
|
+
writeFileSync(`${configPath}.pending-runs.json`, JSON.stringify({ version: 1, entries: [{ runId: "pending", conversationId: "c" }] }));
|
|
977
|
+
expect(inspectLocalDaemonRunJournal(configPath)).toEqual({
|
|
978
|
+
safeToRestart: false,
|
|
979
|
+
activeRunCount: 1,
|
|
980
|
+
});
|
|
981
|
+
});
|
|
982
|
+
it("does not count a recovered interrupted run as active watchdog work", async () => {
|
|
983
|
+
const { inspectLocalDaemonRunJournal } = await import("../daemon.js");
|
|
984
|
+
const { RuntimeRunJournal } = await import("../run-journal.js");
|
|
985
|
+
const journalPath = `${configPath}.run-journal.json`;
|
|
986
|
+
writeFileSync(journalPath, JSON.stringify({
|
|
987
|
+
version: 1,
|
|
988
|
+
entries: [
|
|
989
|
+
{
|
|
990
|
+
runId: "interrupted-run",
|
|
991
|
+
conversationId: "interrupted-conversation",
|
|
992
|
+
acceptedAtMs: Date.now() - 1_000,
|
|
993
|
+
},
|
|
994
|
+
],
|
|
995
|
+
}));
|
|
996
|
+
const recovered = new RuntimeRunJournal(journalPath);
|
|
997
|
+
recovered.confirmRecoveryOwnership();
|
|
998
|
+
expect(recovered.recoveredRunIds()).toEqual(["interrupted-run"]);
|
|
999
|
+
expect(inspectLocalDaemonRunJournal(configPath)).toEqual({
|
|
1000
|
+
safeToRestart: true,
|
|
1001
|
+
activeRunCount: 0,
|
|
1002
|
+
});
|
|
1003
|
+
});
|
|
1004
|
+
it("fails closed when an interrupted run entry is missing its durable identity", async () => {
|
|
1005
|
+
const { inspectLocalDaemonRunJournal } = await import("../daemon.js");
|
|
1006
|
+
writeFileSync(`${configPath}.run-journal.json`, JSON.stringify({
|
|
1007
|
+
version: 1,
|
|
1008
|
+
entries: [{ interruptedAtMs: Date.now() - 1_000 }],
|
|
1009
|
+
}));
|
|
1010
|
+
expect(inspectLocalDaemonRunJournal(configPath)).toEqual({
|
|
1011
|
+
safeToRestart: false,
|
|
1012
|
+
activeRunCount: 1,
|
|
1013
|
+
});
|
|
1014
|
+
});
|
|
1015
|
+
it("clears pending starts inherited from a previous daemon session", async () => {
|
|
1016
|
+
const pendingPath = `${configPath}.pending-runs.json`;
|
|
1017
|
+
writeFileSync(pendingPath, JSON.stringify({
|
|
1018
|
+
version: 1,
|
|
1019
|
+
entries: [
|
|
1020
|
+
{
|
|
1021
|
+
runId: "preflight-before-crash",
|
|
1022
|
+
conversationId: "conversation-before-crash",
|
|
1023
|
+
startedAtMs: Date.now(),
|
|
1024
|
+
daemonSessionId: "previous-daemon-session",
|
|
1025
|
+
},
|
|
1026
|
+
],
|
|
1027
|
+
}));
|
|
1028
|
+
const socket = new TestSocket();
|
|
1029
|
+
mocks.io.mockReturnValue(socket);
|
|
1030
|
+
const { inspectLocalDaemonRunJournal, startDaemon } = await import("../daemon.js");
|
|
1031
|
+
const controller = await startDaemon([
|
|
1032
|
+
"--runtime-id",
|
|
1033
|
+
"11111111-1111-4111-8111-111111111111",
|
|
1034
|
+
"--token",
|
|
1035
|
+
"alan_runtime_secret",
|
|
1036
|
+
"--ws-url",
|
|
1037
|
+
"ws://ws.test",
|
|
1038
|
+
"--local-api-port",
|
|
1039
|
+
"0",
|
|
1040
|
+
]);
|
|
1041
|
+
try {
|
|
1042
|
+
expect(existsSync(pendingPath)).toBe(false);
|
|
1043
|
+
expect(inspectLocalDaemonRunJournal(configPath)).toEqual({
|
|
1044
|
+
safeToRestart: true,
|
|
1045
|
+
activeRunCount: 0,
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
1048
|
+
finally {
|
|
1049
|
+
await controller.stop();
|
|
1050
|
+
}
|
|
1051
|
+
});
|
|
1052
|
+
it("does not let an expired pending start block watchdog recovery", async () => {
|
|
1053
|
+
writeFileSync(`${configPath}.pending-runs.json`, JSON.stringify({
|
|
1054
|
+
version: 1,
|
|
1055
|
+
entries: [
|
|
1056
|
+
{
|
|
1057
|
+
runId: "expired-preflight",
|
|
1058
|
+
conversationId: "expired-conversation",
|
|
1059
|
+
startedAtMs: Date.now() - 60 * 60_000,
|
|
1060
|
+
daemonSessionId: "stuck-daemon-session",
|
|
1061
|
+
},
|
|
1062
|
+
],
|
|
1063
|
+
}));
|
|
1064
|
+
const { inspectLocalDaemonRunJournal } = await import("../daemon.js");
|
|
1065
|
+
expect(inspectLocalDaemonRunJournal(configPath)).toEqual({
|
|
1066
|
+
safeToRestart: true,
|
|
1067
|
+
activeRunCount: 0,
|
|
1068
|
+
});
|
|
1069
|
+
});
|
|
1070
|
+
it("does not delete a replacement maintenance lease while releasing an older owner", async () => {
|
|
1071
|
+
const { acquireLocalDaemonMaintenanceLease, readLocalDaemonMaintenanceLease, releaseLocalDaemonMaintenanceLease, } = await import("../daemon.js");
|
|
1072
|
+
const acquired = acquireLocalDaemonMaintenanceLease(configPath, "watchdog", 5_000);
|
|
1073
|
+
const replacement = {
|
|
1074
|
+
version: 1,
|
|
1075
|
+
id: "replacement-owner",
|
|
1076
|
+
reason: "credential_cleanup",
|
|
1077
|
+
expiresAtMs: Date.now() + 60_000,
|
|
1078
|
+
};
|
|
1079
|
+
const leasePath = `${configPath}.maintenance-lease.json`;
|
|
1080
|
+
mocks.leaseReleaseMutationHook = (mutationPath) => {
|
|
1081
|
+
if (mutationPath !== leasePath)
|
|
1082
|
+
return;
|
|
1083
|
+
writeFileSync(leasePath, JSON.stringify(replacement), { mode: 0o600 });
|
|
1084
|
+
};
|
|
1085
|
+
releaseLocalDaemonMaintenanceLease(configPath, acquired.lease.id);
|
|
1086
|
+
expect(readLocalDaemonMaintenanceLease(configPath)).toEqual(replacement);
|
|
1087
|
+
});
|
|
1088
|
+
it("does not delete a replacement lease while cleaning up an expired watchdog owner", async () => {
|
|
1089
|
+
const { readLocalDaemonMaintenanceLease } = await import("../daemon.js");
|
|
1090
|
+
const now = Date.now();
|
|
1091
|
+
const expired = {
|
|
1092
|
+
version: 1,
|
|
1093
|
+
id: "expired-watchdog-owner",
|
|
1094
|
+
reason: "watchdog",
|
|
1095
|
+
expiresAtMs: now - 1,
|
|
1096
|
+
};
|
|
1097
|
+
const replacement = {
|
|
1098
|
+
version: 1,
|
|
1099
|
+
id: "replacement-cleanup-owner",
|
|
1100
|
+
reason: "credential_cleanup",
|
|
1101
|
+
expiresAtMs: null,
|
|
1102
|
+
};
|
|
1103
|
+
const leasePath = `${configPath}.maintenance-lease.json`;
|
|
1104
|
+
writeFileSync(leasePath, JSON.stringify(expired), { mode: 0o600 });
|
|
1105
|
+
mocks.leaseReleaseMutationHook = (mutationPath) => {
|
|
1106
|
+
if (mutationPath !== leasePath)
|
|
1107
|
+
return;
|
|
1108
|
+
writeFileSync(leasePath, JSON.stringify(replacement), { mode: 0o600 });
|
|
1109
|
+
};
|
|
1110
|
+
expect(readLocalDaemonMaintenanceLease(configPath, now)).toBeNull();
|
|
1111
|
+
expect(readLocalDaemonMaintenanceLease(configPath)).toEqual(replacement);
|
|
1112
|
+
});
|
|
1113
|
+
it("maintenance closes the pending-dispatch TOCTOU window before journal acceptance", async () => {
|
|
1114
|
+
const socket = new TestSocket();
|
|
1115
|
+
mocks.io.mockReturnValue(socket);
|
|
1116
|
+
const { acquireLocalDaemonMaintenanceLease, inspectLocalDaemonRunJournal, startDaemon } = await import("../daemon.js");
|
|
1117
|
+
const controller = await startDaemon([
|
|
1118
|
+
"--runtime-id",
|
|
1119
|
+
"11111111-1111-4111-8111-111111111111",
|
|
1120
|
+
"--token",
|
|
1121
|
+
"alan_runtime_secret",
|
|
1122
|
+
"--ws-url",
|
|
1123
|
+
"ws://ws.test",
|
|
1124
|
+
"--local-api-port",
|
|
1125
|
+
"0",
|
|
1126
|
+
]);
|
|
1127
|
+
try {
|
|
1128
|
+
socket.emit("agent.execute", {
|
|
1129
|
+
conversationId: "22222222-2222-4222-8222-222222222222",
|
|
1130
|
+
runId: "33333333-3333-4333-8333-333333333333",
|
|
1131
|
+
content: "Do not cross maintenance",
|
|
1132
|
+
});
|
|
1133
|
+
acquireLocalDaemonMaintenanceLease(configPath, "watchdog", 5_000);
|
|
1134
|
+
await vi.waitFor(() => expect(socket.outbound).toEqual(expect.arrayContaining([
|
|
1135
|
+
{
|
|
1136
|
+
event: "agent.rejected",
|
|
1137
|
+
payload: expect.objectContaining({
|
|
1138
|
+
runId: "33333333-3333-4333-8333-333333333333",
|
|
1139
|
+
code: "runtime_maintenance_pending",
|
|
1140
|
+
}),
|
|
1141
|
+
},
|
|
1142
|
+
])));
|
|
1143
|
+
expect(mocks.agentRun).not.toHaveBeenCalled();
|
|
1144
|
+
expect(inspectLocalDaemonRunJournal(configPath)).toEqual({
|
|
1145
|
+
safeToRestart: true,
|
|
1146
|
+
activeRunCount: 0,
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
finally {
|
|
1150
|
+
await controller.stop();
|
|
1151
|
+
}
|
|
1152
|
+
});
|
|
1153
|
+
it("CLI recovery restarts a stuck idle daemon only after redial verification fails", async () => {
|
|
1154
|
+
writeFileSync(configPath, JSON.stringify({
|
|
1155
|
+
runtimeId: "11111111-1111-4111-8111-111111111111",
|
|
1156
|
+
runtimeToken: "alan_runtime_secret",
|
|
1157
|
+
wsUrl: "ws://ws.test",
|
|
1158
|
+
localApiToken: "local-secret-that-is-long-enough-for-tests",
|
|
1159
|
+
}));
|
|
1160
|
+
const stuckSocket = new TestSocket();
|
|
1161
|
+
stuckSocket.connected = false;
|
|
1162
|
+
const replacementSocket = new TestSocket();
|
|
1163
|
+
mocks.io.mockReturnValueOnce(stuckSocket).mockReturnValueOnce(replacementSocket);
|
|
1164
|
+
vi.spyOn(console, "info").mockImplementation(() => { });
|
|
1165
|
+
const { recoverDaemonConnection, startDaemon, stopDaemon } = await import("../daemon.js");
|
|
1166
|
+
const original = await startDaemon(["--local-api-port", "0"]);
|
|
1167
|
+
writeFileSync(configPath, JSON.stringify({
|
|
1168
|
+
...JSON.parse(readFileSync(configPath, "utf8")),
|
|
1169
|
+
localApiPort: original.localApiPort,
|
|
1170
|
+
}));
|
|
1171
|
+
try {
|
|
1172
|
+
const recovered = await recoverDaemonConnection([], {
|
|
1173
|
+
connectTimeoutMs: 0,
|
|
1174
|
+
pollMs: 1,
|
|
1175
|
+
});
|
|
1176
|
+
expect(stuckSocket.connectCalls).toBe(1);
|
|
1177
|
+
expect(mocks.io).toHaveBeenCalledTimes(2);
|
|
1178
|
+
expect(recovered).toMatchObject({ connected: true, running: true });
|
|
1179
|
+
}
|
|
1180
|
+
finally {
|
|
1181
|
+
await stopDaemon([]);
|
|
1182
|
+
await original.stop();
|
|
1183
|
+
}
|
|
1184
|
+
});
|
|
1185
|
+
it("explains when the local daemon API port is already in use", async () => {
|
|
1186
|
+
writeFileSync(configPath, JSON.stringify({
|
|
1187
|
+
runtimeId: "11111111-1111-4111-8111-111111111111",
|
|
1188
|
+
runtimeToken: "alan_runtime_secret",
|
|
1189
|
+
wsUrl: "ws://ws.test",
|
|
1190
|
+
localApiToken: "local-secret",
|
|
1191
|
+
}));
|
|
1192
|
+
const occupied = createServer();
|
|
1193
|
+
await new Promise((resolve) => occupied.listen(0, "127.0.0.1", resolve));
|
|
1194
|
+
const address = occupied.address();
|
|
1195
|
+
const port = typeof address === "object" && address ? address.port : 47831;
|
|
1196
|
+
const socket = new TestSocket();
|
|
1197
|
+
mocks.io.mockReturnValue(socket);
|
|
1198
|
+
const { startDaemon } = await import("../daemon.js");
|
|
1199
|
+
await expect(startDaemon(["--local-api-port", String(port)])).rejects.toThrow("already in use by another process");
|
|
1200
|
+
expect(socket.disconnected).toBe(true);
|
|
1201
|
+
expect(existsSync(`${configPath}.daemon-owner.json`)).toBe(false);
|
|
1202
|
+
expect(existsSync(`${configPath}.maintenance-lease.json`)).toBe(false);
|
|
1203
|
+
await new Promise((resolve, reject) => {
|
|
1204
|
+
occupied.close((error) => (error ? reject(error) : resolve()));
|
|
1205
|
+
});
|
|
1206
|
+
});
|
|
1207
|
+
it("does not interrupt a live run journal when a competing daemon loses local ownership", async () => {
|
|
1208
|
+
writeFileSync(configPath, JSON.stringify({
|
|
1209
|
+
runtimeId: "11111111-1111-4111-8111-111111111111",
|
|
1210
|
+
runtimeToken: "alan_runtime_secret",
|
|
1211
|
+
wsUrl: "ws://ws.test",
|
|
1212
|
+
localApiToken: "local-secret",
|
|
1213
|
+
}));
|
|
1214
|
+
const runJournalPath = `${configPath}.run-journal.json`;
|
|
1215
|
+
const liveJournal = {
|
|
1216
|
+
version: 1,
|
|
1217
|
+
entries: [
|
|
1218
|
+
{
|
|
1219
|
+
runId: "live-run",
|
|
1220
|
+
conversationId: "live-conversation",
|
|
1221
|
+
acceptedAtMs: 1_000,
|
|
1222
|
+
},
|
|
1223
|
+
],
|
|
1224
|
+
};
|
|
1225
|
+
writeFileSync(runJournalPath, JSON.stringify(liveJournal));
|
|
1226
|
+
const occupied = createServer();
|
|
1227
|
+
await new Promise((resolve) => occupied.listen(0, "127.0.0.1", resolve));
|
|
1228
|
+
try {
|
|
1229
|
+
const address = occupied.address();
|
|
1230
|
+
const port = typeof address === "object" && address ? address.port : 47831;
|
|
1231
|
+
mocks.io.mockReturnValue(new TestSocket());
|
|
1232
|
+
const { startDaemon } = await import("../daemon.js");
|
|
1233
|
+
await expect(startDaemon(["--local-api-port", String(port)])).rejects.toThrow("already in use by another process");
|
|
1234
|
+
expect(JSON.parse(readFileSync(runJournalPath, "utf8"))).toEqual(liveJournal);
|
|
1235
|
+
}
|
|
1236
|
+
finally {
|
|
1237
|
+
await new Promise((resolve, reject) => {
|
|
1238
|
+
occupied.close((error) => (error ? reject(error) : resolve()));
|
|
1239
|
+
});
|
|
1240
|
+
}
|
|
1241
|
+
});
|
|
1242
|
+
it("rejects a same-config competitor before it can use an alternate ephemeral port", async () => {
|
|
1243
|
+
writeFileSync(configPath, JSON.stringify({
|
|
1244
|
+
runtimeId: "11111111-1111-4111-8111-111111111111",
|
|
1245
|
+
runtimeToken: "alan_runtime_secret",
|
|
1246
|
+
wsUrl: "ws://ws.test",
|
|
1247
|
+
localApiToken: "local-secret",
|
|
1248
|
+
}));
|
|
1249
|
+
mocks.io.mockImplementation(() => new TestSocket());
|
|
1250
|
+
const { startDaemon } = await import("../daemon.js");
|
|
1251
|
+
const owner = await startDaemon(["--local-api-port", "0"]);
|
|
1252
|
+
let competitor = null;
|
|
1253
|
+
try {
|
|
1254
|
+
const runJournalPath = `${configPath}.run-journal.json`;
|
|
1255
|
+
const liveJournal = {
|
|
1256
|
+
version: 1,
|
|
1257
|
+
entries: [
|
|
1258
|
+
{
|
|
1259
|
+
runId: "live-run",
|
|
1260
|
+
conversationId: "live-conversation",
|
|
1261
|
+
acceptedAtMs: 1_000,
|
|
1262
|
+
},
|
|
1263
|
+
],
|
|
1264
|
+
};
|
|
1265
|
+
writeFileSync(runJournalPath, JSON.stringify(liveJournal));
|
|
1266
|
+
let competitorError;
|
|
1267
|
+
try {
|
|
1268
|
+
competitor = await startDaemon(["--local-api-port", "0"]);
|
|
1269
|
+
}
|
|
1270
|
+
catch (error) {
|
|
1271
|
+
competitorError = error;
|
|
1272
|
+
}
|
|
1273
|
+
expect(competitor).toBeNull();
|
|
1274
|
+
expect(competitorError).toBeInstanceOf(Error);
|
|
1275
|
+
expect(competitorError.message).toContain("already owns this daemon config");
|
|
1276
|
+
expect(mocks.io).toHaveBeenCalledTimes(1);
|
|
1277
|
+
expect(JSON.parse(readFileSync(runJournalPath, "utf8"))).toEqual(liveJournal);
|
|
1278
|
+
}
|
|
1279
|
+
finally {
|
|
1280
|
+
await competitor?.stop();
|
|
1281
|
+
await owner.stop();
|
|
1282
|
+
}
|
|
1283
|
+
expect(existsSync(`${configPath}.daemon-owner.json`)).toBe(false);
|
|
1284
|
+
expect(existsSync(`${configPath}.maintenance-lease.json`)).toBe(false);
|
|
1285
|
+
});
|
|
1286
|
+
it("rejects an alternate-port start when a pre-lease daemon owns the stored endpoint", async () => {
|
|
1287
|
+
const existing = createServer((req, res) => {
|
|
1288
|
+
if (req.method === "GET" && req.url === "/status") {
|
|
1289
|
+
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({
|
|
1290
|
+
configured: true,
|
|
1291
|
+
runtimeId: "11111111-1111-4111-8111-111111111111",
|
|
1292
|
+
wsUrl: "ws://ws.test",
|
|
1293
|
+
connected: true,
|
|
1294
|
+
activeRunIds: [],
|
|
1295
|
+
localApiPort: 0,
|
|
1296
|
+
localServiceId: "legacy-service-owner",
|
|
1297
|
+
daemonSessionId: "legacy-session",
|
|
1298
|
+
version: "test",
|
|
1299
|
+
}));
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
res.writeHead(404).end();
|
|
1303
|
+
});
|
|
1304
|
+
await new Promise((resolve) => existing.listen(0, "127.0.0.1", resolve));
|
|
1305
|
+
let competitor = null;
|
|
1306
|
+
try {
|
|
1307
|
+
const address = existing.address();
|
|
1308
|
+
const storedPort = typeof address === "object" && address ? address.port : 47831;
|
|
1309
|
+
const storedConfig = {
|
|
1310
|
+
runtimeId: "11111111-1111-4111-8111-111111111111",
|
|
1311
|
+
runtimeToken: "alan_runtime_secret",
|
|
1312
|
+
wsUrl: "ws://ws.test",
|
|
1313
|
+
localApiPort: storedPort,
|
|
1314
|
+
localApiToken: "local-secret",
|
|
1315
|
+
localServiceId: "legacy-service-owner",
|
|
1316
|
+
};
|
|
1317
|
+
writeFileSync(configPath, JSON.stringify(storedConfig));
|
|
1318
|
+
let competitorError;
|
|
1319
|
+
try {
|
|
1320
|
+
competitor = await (await import("../daemon.js")).startDaemon(["--local-api-port", "0"]);
|
|
1321
|
+
}
|
|
1322
|
+
catch (error) {
|
|
1323
|
+
competitorError = error;
|
|
1324
|
+
}
|
|
1325
|
+
expect(competitor).toBeNull();
|
|
1326
|
+
expect(competitorError).toBeInstanceOf(Error);
|
|
1327
|
+
expect(competitorError.message).toContain("stored local endpoint already owns this daemon config");
|
|
1328
|
+
expect(mocks.io).not.toHaveBeenCalled();
|
|
1329
|
+
expect(JSON.parse(readFileSync(configPath, "utf8"))).toEqual(storedConfig);
|
|
1330
|
+
expect(existsSync(`${configPath}.daemon-owner.json`)).toBe(false);
|
|
1331
|
+
expect(existsSync(`${configPath}.maintenance-lease.json`)).toBe(false);
|
|
1332
|
+
}
|
|
1333
|
+
finally {
|
|
1334
|
+
await competitor?.stop();
|
|
1335
|
+
await new Promise((resolve, reject) => {
|
|
1336
|
+
existing.close((error) => (error ? reject(error) : resolve()));
|
|
1337
|
+
});
|
|
1338
|
+
}
|
|
1339
|
+
});
|
|
1340
|
+
it("releases config ownership when the accepted-run journal cannot be validated", async () => {
|
|
1341
|
+
writeFileSync(configPath, JSON.stringify({
|
|
1342
|
+
runtimeId: "11111111-1111-4111-8111-111111111111",
|
|
1343
|
+
runtimeToken: "alan_runtime_secret",
|
|
1344
|
+
wsUrl: "ws://ws.test",
|
|
1345
|
+
localApiToken: "local-secret",
|
|
1346
|
+
}));
|
|
1347
|
+
writeFileSync(`${configPath}.run-journal.json`, "not-json");
|
|
1348
|
+
const { startDaemon } = await import("../daemon.js");
|
|
1349
|
+
await expect(startDaemon(["--local-api-port", "0"])).rejects.toThrow("Cannot safely read accepted-run journal");
|
|
1350
|
+
expect(mocks.io).not.toHaveBeenCalled();
|
|
1351
|
+
expect(existsSync(`${configPath}.daemon-owner.json`)).toBe(false);
|
|
1352
|
+
expect(existsSync(`${configPath}.maintenance-lease.json`)).toBe(false);
|
|
1353
|
+
});
|
|
1354
|
+
it("reclaims a stale daemon owner when its pid was reused and no runs are active", async () => {
|
|
1355
|
+
writeFileSync(configPath, JSON.stringify({
|
|
1356
|
+
runtimeId: "11111111-1111-4111-8111-111111111111",
|
|
1357
|
+
runtimeToken: "alan_runtime_secret",
|
|
1358
|
+
wsUrl: "ws://ws.test",
|
|
1359
|
+
localApiToken: "local-secret",
|
|
1360
|
+
localServiceId: "service-owner",
|
|
1361
|
+
}));
|
|
1362
|
+
writeFileSync(`${configPath}.daemon-owner.json`, JSON.stringify({
|
|
1363
|
+
version: 1,
|
|
1364
|
+
id: "reused-pid-owner",
|
|
1365
|
+
daemonSessionId: "reused-pid-session",
|
|
1366
|
+
pid: process.pid,
|
|
1367
|
+
startedAtMs: 1_000,
|
|
1368
|
+
runtimeId: "11111111-1111-4111-8111-111111111111",
|
|
1369
|
+
localServiceId: "service-owner",
|
|
1370
|
+
localApiPort: 0,
|
|
1371
|
+
localApiToken: "local-secret",
|
|
1372
|
+
}));
|
|
1373
|
+
writeFileSync(`${configPath}.run-journal.json`, JSON.stringify({ version: 1, entries: [] }));
|
|
1374
|
+
mocks.io.mockReturnValue(new TestSocket());
|
|
1375
|
+
const controller = await (await import("../daemon.js")).startDaemon(["--local-api-port", "0"]);
|
|
1376
|
+
try {
|
|
1377
|
+
expect(JSON.parse(readFileSync(`${configPath}.daemon-owner.json`, "utf8"))).toMatchObject({
|
|
1378
|
+
pid: process.pid,
|
|
1379
|
+
runtimeId: "11111111-1111-4111-8111-111111111111",
|
|
1380
|
+
});
|
|
1381
|
+
expect(JSON.parse(readFileSync(`${configPath}.daemon-owner.json`, "utf8")).id).not.toBe("reused-pid-owner");
|
|
1382
|
+
}
|
|
1383
|
+
finally {
|
|
1384
|
+
await controller.stop();
|
|
1385
|
+
}
|
|
1386
|
+
expect(existsSync(`${configPath}.daemon-owner.json`)).toBe(false);
|
|
1387
|
+
});
|
|
1388
|
+
it("preserves an unresponsive live owner when its run journal is active", async () => {
|
|
1389
|
+
writeFileSync(configPath, JSON.stringify({
|
|
1390
|
+
runtimeId: "11111111-1111-4111-8111-111111111111",
|
|
1391
|
+
runtimeToken: "alan_runtime_secret",
|
|
1392
|
+
wsUrl: "ws://ws.test",
|
|
1393
|
+
localApiToken: "local-secret",
|
|
1394
|
+
localServiceId: "service-owner",
|
|
1395
|
+
}));
|
|
1396
|
+
writeFileSync(`${configPath}.daemon-owner.json`, JSON.stringify({
|
|
1397
|
+
version: 1,
|
|
1398
|
+
id: "live-unresponsive-owner",
|
|
1399
|
+
daemonSessionId: "live-unresponsive-session",
|
|
1400
|
+
pid: process.pid,
|
|
1401
|
+
startedAtMs: Date.now(),
|
|
1402
|
+
runtimeId: "11111111-1111-4111-8111-111111111111",
|
|
1403
|
+
localServiceId: "service-owner",
|
|
1404
|
+
localApiPort: 0,
|
|
1405
|
+
localApiToken: "local-secret",
|
|
1406
|
+
}));
|
|
1407
|
+
writeFileSync(`${configPath}.run-journal.json`, JSON.stringify({
|
|
1408
|
+
version: 1,
|
|
1409
|
+
entries: [
|
|
1410
|
+
{
|
|
1411
|
+
runId: "live-run",
|
|
1412
|
+
conversationId: "live-conversation",
|
|
1413
|
+
acceptedAtMs: Date.now(),
|
|
1414
|
+
},
|
|
1415
|
+
],
|
|
1416
|
+
}));
|
|
1417
|
+
mocks.io.mockReturnValue(new TestSocket());
|
|
1418
|
+
const { startDaemon } = await import("../daemon.js");
|
|
1419
|
+
await expect(startDaemon(["--local-api-port", "0"])).rejects.toThrow("already owns this daemon config");
|
|
1420
|
+
expect(JSON.parse(readFileSync(`${configPath}.daemon-owner.json`, "utf8"))).toMatchObject({
|
|
1421
|
+
id: "live-unresponsive-owner",
|
|
1422
|
+
});
|
|
1423
|
+
expect(mocks.io).not.toHaveBeenCalled();
|
|
1424
|
+
});
|
|
1425
|
+
it("reclaims a dead daemon owner before marking inherited runs interrupted", async () => {
|
|
1426
|
+
writeFileSync(configPath, JSON.stringify({
|
|
1427
|
+
runtimeId: "11111111-1111-4111-8111-111111111111",
|
|
1428
|
+
runtimeToken: "alan_runtime_secret",
|
|
1429
|
+
wsUrl: "ws://ws.test",
|
|
1430
|
+
localApiToken: "local-secret",
|
|
1431
|
+
localServiceId: "service-owner",
|
|
1432
|
+
}));
|
|
1433
|
+
writeFileSync(`${configPath}.daemon-owner.json`, JSON.stringify({
|
|
1434
|
+
version: 1,
|
|
1435
|
+
id: "dead-owner",
|
|
1436
|
+
daemonSessionId: "dead-session",
|
|
1437
|
+
pid: 2_147_483_647,
|
|
1438
|
+
startedAtMs: 1_000,
|
|
1439
|
+
runtimeId: "11111111-1111-4111-8111-111111111111",
|
|
1440
|
+
localServiceId: "service-owner",
|
|
1441
|
+
localApiPort: 0,
|
|
1442
|
+
localApiToken: "local-secret",
|
|
1443
|
+
}));
|
|
1444
|
+
const runJournalPath = `${configPath}.run-journal.json`;
|
|
1445
|
+
writeFileSync(runJournalPath, JSON.stringify({
|
|
1446
|
+
version: 1,
|
|
1447
|
+
entries: [
|
|
1448
|
+
{
|
|
1449
|
+
runId: "inherited-run",
|
|
1450
|
+
conversationId: "inherited-conversation",
|
|
1451
|
+
acceptedAtMs: 1_000,
|
|
1452
|
+
},
|
|
1453
|
+
],
|
|
1454
|
+
}));
|
|
1455
|
+
mocks.io.mockReturnValue(new TestSocket());
|
|
1456
|
+
const controller = await (await import("../daemon.js")).startDaemon(["--local-api-port", "0"]);
|
|
1457
|
+
try {
|
|
1458
|
+
expect(JSON.parse(readFileSync(`${configPath}.daemon-owner.json`, "utf8"))).toMatchObject({
|
|
1459
|
+
pid: process.pid,
|
|
1460
|
+
runtimeId: "11111111-1111-4111-8111-111111111111",
|
|
1461
|
+
});
|
|
1462
|
+
expect(JSON.parse(readFileSync(runJournalPath, "utf8"))).toMatchObject({
|
|
1463
|
+
entries: [
|
|
1464
|
+
expect.objectContaining({ runId: "inherited-run", interruptedAtMs: expect.any(Number) }),
|
|
1465
|
+
],
|
|
1466
|
+
});
|
|
1467
|
+
}
|
|
1468
|
+
finally {
|
|
1469
|
+
await controller.stop();
|
|
1470
|
+
}
|
|
1471
|
+
expect(existsSync(`${configPath}.daemon-owner.json`)).toBe(false);
|
|
1472
|
+
});
|
|
1473
|
+
it("login starts browser device authorization, polls, and stores runtime config", async () => {
|
|
1474
|
+
mocks.spawnSync.mockReturnValue({ status: 1, stdout: "", stderr: "" });
|
|
1475
|
+
const fetchMock = vi
|
|
1476
|
+
.fn()
|
|
1477
|
+
.mockResolvedValueOnce({
|
|
1478
|
+
ok: true,
|
|
1479
|
+
json: async () => ({
|
|
1480
|
+
deviceCode: "alan_device_secret",
|
|
1481
|
+
userCode: "ABCD1234",
|
|
1482
|
+
verificationUri: "http://app.test/runtime/device?code=ABCD1234",
|
|
1483
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
1484
|
+
intervalSeconds: 0,
|
|
1485
|
+
}),
|
|
1486
|
+
})
|
|
1487
|
+
.mockResolvedValueOnce({
|
|
1488
|
+
ok: false,
|
|
1489
|
+
status: 428,
|
|
1490
|
+
json: async () => ({ error: "authorization_pending" }),
|
|
1491
|
+
text: async () => JSON.stringify({ error: "authorization_pending" }),
|
|
1492
|
+
})
|
|
1493
|
+
.mockResolvedValueOnce({
|
|
1494
|
+
ok: true,
|
|
1495
|
+
status: 201,
|
|
1496
|
+
json: async () => ({
|
|
1497
|
+
runtime: {
|
|
1498
|
+
id: "11111111-1111-4111-8111-111111111111",
|
|
1499
|
+
displayName: "Browser Login VM",
|
|
1500
|
+
teamId: "22222222-2222-4222-8222-222222222222",
|
|
1501
|
+
},
|
|
1502
|
+
runtimeToken: "alan_runtime_browser_secret",
|
|
1503
|
+
runtimeRenewalToken: "alan_runtime_renew_browser_secret",
|
|
1504
|
+
}),
|
|
1505
|
+
});
|
|
1506
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
1507
|
+
const info = vi.spyOn(console, "info").mockImplementation(() => { });
|
|
1508
|
+
const { loginWithDeviceCode } = await import("../daemon.js");
|
|
1509
|
+
await loginWithDeviceCode([
|
|
1510
|
+
"--api-url",
|
|
1511
|
+
"http://api.test",
|
|
1512
|
+
"--ws-url",
|
|
1513
|
+
"ws://ws.test",
|
|
1514
|
+
"--name",
|
|
1515
|
+
"Browser Login VM",
|
|
1516
|
+
"--max-polls",
|
|
1517
|
+
"3",
|
|
1518
|
+
]);
|
|
1519
|
+
expect(fetchMock).toHaveBeenNthCalledWith(1, "http://api.test/public/runtimes/device-authorizations", expect.objectContaining({ method: "POST" }));
|
|
1520
|
+
expect(fetchMock).toHaveBeenNthCalledWith(2, "http://api.test/public/runtimes/device-authorizations/token", expect.objectContaining({
|
|
1521
|
+
method: "POST",
|
|
1522
|
+
body: JSON.stringify({ deviceCode: "alan_device_secret" }),
|
|
1523
|
+
}));
|
|
1524
|
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
1525
|
+
expect(info.mock.calls.some((call) => String(call[0]).includes("ABCD1234"))).toBe(true);
|
|
1526
|
+
const saved = JSON.parse(readFileSync(configPath, "utf8"));
|
|
1527
|
+
expect(saved).toMatchObject({
|
|
1528
|
+
apiUrl: "http://api.test",
|
|
1529
|
+
wsUrl: "ws://ws.test",
|
|
1530
|
+
endpointProfile: "custom",
|
|
1531
|
+
runtimeId: "11111111-1111-4111-8111-111111111111",
|
|
1532
|
+
runtimeToken: "alan_runtime_browser_secret",
|
|
1533
|
+
runtimeRenewalToken: "alan_runtime_renew_browser_secret",
|
|
1534
|
+
displayName: "Browser Login VM",
|
|
1535
|
+
});
|
|
1536
|
+
expect(readFileSync(configPath, "utf8")).not.toContain("alan_device_secret");
|
|
1537
|
+
});
|
|
1538
|
+
it("resumes a still-valid browser authorization after login is abandoned", async () => {
|
|
1539
|
+
mocks.spawnSync.mockReturnValue({ status: 1, stdout: "", stderr: "" });
|
|
1540
|
+
const fetchMock = vi
|
|
1541
|
+
.fn()
|
|
1542
|
+
.mockResolvedValueOnce({
|
|
1543
|
+
ok: true,
|
|
1544
|
+
json: async () => ({
|
|
1545
|
+
deviceCode: "alan_device_resume_secret",
|
|
1546
|
+
userCode: "RESUME12",
|
|
1547
|
+
verificationUri: "https://app.tryalan.ai/device?code=RESUME12",
|
|
1548
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
1549
|
+
intervalSeconds: 0,
|
|
1550
|
+
}),
|
|
1551
|
+
})
|
|
1552
|
+
.mockResolvedValueOnce({
|
|
1553
|
+
ok: false,
|
|
1554
|
+
status: 428,
|
|
1555
|
+
text: async () => JSON.stringify({ error: "authorization_pending" }),
|
|
1556
|
+
})
|
|
1557
|
+
.mockResolvedValueOnce({
|
|
1558
|
+
ok: true,
|
|
1559
|
+
status: 201,
|
|
1560
|
+
json: async () => ({
|
|
1561
|
+
runtime: {
|
|
1562
|
+
id: "11111111-1111-4111-8111-111111111111",
|
|
1563
|
+
displayName: "Resumed Laptop",
|
|
1564
|
+
teamId: "22222222-2222-4222-8222-222222222222",
|
|
1565
|
+
},
|
|
1566
|
+
runtimeToken: "alan_runtime_resumed_secret",
|
|
1567
|
+
}),
|
|
1568
|
+
});
|
|
1569
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
1570
|
+
vi.spyOn(console, "info").mockImplementation(() => { });
|
|
1571
|
+
const { loginWithDeviceCode } = await import("../daemon.js");
|
|
1572
|
+
await expect(loginWithDeviceCode(["--name", "Resumed Laptop", "--max-polls", "1"])).rejects.toThrow("timed out before approval");
|
|
1573
|
+
const pendingPath = `${configPath}.device-auth-pending`;
|
|
1574
|
+
expect(existsSync(pendingPath)).toBe(true);
|
|
1575
|
+
expect(statSync(pendingPath).mode & 0o777).toBe(0o600);
|
|
1576
|
+
await loginWithDeviceCode(["--name", "Resumed Laptop", "--max-polls", "1"]);
|
|
1577
|
+
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
|
|
1578
|
+
"https://api.tryalan.ai/public/runtimes/device-authorizations",
|
|
1579
|
+
"https://api.tryalan.ai/public/runtimes/device-authorizations/token",
|
|
1580
|
+
"https://api.tryalan.ai/public/runtimes/device-authorizations/token",
|
|
1581
|
+
]);
|
|
1582
|
+
expect(existsSync(pendingPath)).toBe(false);
|
|
1583
|
+
expect(JSON.parse(readFileSync(configPath, "utf8"))).toMatchObject({
|
|
1584
|
+
runtimeId: "11111111-1111-4111-8111-111111111111",
|
|
1585
|
+
runtimeToken: "alan_runtime_resumed_secret",
|
|
1586
|
+
});
|
|
1587
|
+
});
|
|
1588
|
+
it("allows only one browser-login process to own and finalize resumable authorization", async () => {
|
|
1589
|
+
let resolveStart;
|
|
1590
|
+
const startResponse = new Promise((resolve) => {
|
|
1591
|
+
resolveStart = resolve;
|
|
1592
|
+
});
|
|
1593
|
+
const fetchMock = vi.fn((url) => {
|
|
1594
|
+
if (url.endsWith("/device-authorizations"))
|
|
1595
|
+
return startResponse;
|
|
1596
|
+
return Promise.resolve({
|
|
1597
|
+
ok: false,
|
|
1598
|
+
status: 428,
|
|
1599
|
+
text: async () => JSON.stringify({ error: "authorization_pending" }),
|
|
1600
|
+
});
|
|
1601
|
+
});
|
|
1602
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
1603
|
+
vi.spyOn(console, "info").mockImplementation(() => { });
|
|
1604
|
+
const { loginWithDeviceCode } = await import("../daemon.js");
|
|
1605
|
+
const first = loginWithDeviceCode(["--name", "Exclusive Laptop", "--max-polls", "1"]);
|
|
1606
|
+
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
|
1607
|
+
const second = loginWithDeviceCode(["--name", "Exclusive Laptop", "--max-polls", "1"]);
|
|
1608
|
+
const secondResult = expect(second).rejects.toThrow("browser authorization is already in progress");
|
|
1609
|
+
await Promise.resolve();
|
|
1610
|
+
resolveStart({
|
|
1611
|
+
ok: true,
|
|
1612
|
+
json: async () => ({
|
|
1613
|
+
deviceCode: "alan_device_exclusive",
|
|
1614
|
+
userCode: "EXCLUSIVE",
|
|
1615
|
+
verificationUri: "https://app.tryalan.ai/device?code=EXCLUSIVE",
|
|
1616
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
1617
|
+
intervalSeconds: 0,
|
|
1618
|
+
}),
|
|
1619
|
+
});
|
|
1620
|
+
await secondResult;
|
|
1621
|
+
await expect(first).rejects.toThrow("timed out before approval");
|
|
1622
|
+
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
|
|
1623
|
+
"https://api.tryalan.ai/public/runtimes/device-authorizations",
|
|
1624
|
+
"https://api.tryalan.ai/public/runtimes/device-authorizations/token",
|
|
1625
|
+
]);
|
|
1626
|
+
});
|
|
1627
|
+
it("does not finalize or clear browser credentials after sidecar ownership changes", async () => {
|
|
1628
|
+
const pendingPath = `${configPath}.device-auth-pending`;
|
|
1629
|
+
const fetchMock = vi
|
|
1630
|
+
.fn()
|
|
1631
|
+
.mockResolvedValueOnce({
|
|
1632
|
+
ok: true,
|
|
1633
|
+
json: async () => ({
|
|
1634
|
+
deviceCode: "alan_device_owner_change",
|
|
1635
|
+
userCode: "OWNER123",
|
|
1636
|
+
verificationUri: "https://app.tryalan.ai/device?code=OWNER123",
|
|
1637
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
1638
|
+
intervalSeconds: 0,
|
|
1639
|
+
}),
|
|
1640
|
+
})
|
|
1641
|
+
.mockResolvedValueOnce({
|
|
1642
|
+
ok: true,
|
|
1643
|
+
json: async () => {
|
|
1644
|
+
const replacement = {
|
|
1645
|
+
...JSON.parse(readFileSync(pendingPath, "utf8")),
|
|
1646
|
+
operationId: "replacement-login-owner",
|
|
1647
|
+
};
|
|
1648
|
+
writeFileSync(pendingPath, JSON.stringify(replacement), { mode: 0o600 });
|
|
1649
|
+
return {
|
|
1650
|
+
runtime: { id: "11111111-1111-4111-8111-111111111111" },
|
|
1651
|
+
runtimeToken: "must-not-be-persisted",
|
|
1652
|
+
};
|
|
1653
|
+
},
|
|
1654
|
+
});
|
|
1655
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
1656
|
+
vi.spyOn(console, "info").mockImplementation(() => { });
|
|
1657
|
+
const { loginWithDeviceCode } = await import("../daemon.js");
|
|
1658
|
+
await expect(loginWithDeviceCode(["--max-polls", "1"])).rejects.toThrow("sidecar ownership was lost before finalization");
|
|
1659
|
+
expect(existsSync(configPath)).toBe(false);
|
|
1660
|
+
expect(JSON.parse(readFileSync(pendingPath, "utf8"))).toMatchObject({
|
|
1661
|
+
operationId: "replacement-login-owner",
|
|
1662
|
+
});
|
|
1663
|
+
});
|
|
1664
|
+
it("replaces an expired pending browser authorization", async () => {
|
|
1665
|
+
writeFileSync(`${configPath}.device-auth-pending`, JSON.stringify({
|
|
1666
|
+
apiUrl: "https://api.tryalan.ai",
|
|
1667
|
+
deviceCode: "alan_device_expired",
|
|
1668
|
+
userCode: "EXPIRED1",
|
|
1669
|
+
verificationUri: "https://app.tryalan.ai/device?code=EXPIRED1",
|
|
1670
|
+
expiresAt: new Date(Date.now() - 1_000).toISOString(),
|
|
1671
|
+
intervalSeconds: 0,
|
|
1672
|
+
installationId: "11111111-1111-4111-8111-111111111111",
|
|
1673
|
+
}), { mode: 0o600 });
|
|
1674
|
+
const fetchMock = vi
|
|
1675
|
+
.fn()
|
|
1676
|
+
.mockResolvedValueOnce({
|
|
1677
|
+
ok: true,
|
|
1678
|
+
json: async () => ({
|
|
1679
|
+
deviceCode: "alan_device_replacement",
|
|
1680
|
+
userCode: "REPLACE1",
|
|
1681
|
+
verificationUri: "https://app.tryalan.ai/device?code=REPLACE1",
|
|
1682
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
1683
|
+
intervalSeconds: 0,
|
|
1684
|
+
}),
|
|
1685
|
+
})
|
|
1686
|
+
.mockResolvedValueOnce({
|
|
1687
|
+
ok: true,
|
|
1688
|
+
json: async () => ({
|
|
1689
|
+
runtime: { id: "11111111-1111-4111-8111-111111111111" },
|
|
1690
|
+
runtimeToken: "alan_runtime_replacement",
|
|
1691
|
+
}),
|
|
1692
|
+
});
|
|
1693
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
1694
|
+
vi.spyOn(console, "info").mockImplementation(() => { });
|
|
1695
|
+
const { loginWithDeviceCode } = await import("../daemon.js");
|
|
1696
|
+
await loginWithDeviceCode(["--max-polls", "1"]);
|
|
1697
|
+
expect(fetchMock).toHaveBeenNthCalledWith(1, "https://api.tryalan.ai/public/runtimes/device-authorizations", expect.objectContaining({ method: "POST" }));
|
|
1698
|
+
expect(fetchMock.mock.calls[1][1].body).toBe(JSON.stringify({ deviceCode: "alan_device_replacement" }));
|
|
1699
|
+
});
|
|
1700
|
+
it("cancels a pending browser authorization locally", async () => {
|
|
1701
|
+
const pendingPath = `${configPath}.device-auth-pending`;
|
|
1702
|
+
writeFileSync(pendingPath, "pending", { mode: 0o600 });
|
|
1703
|
+
const fetchMock = vi.fn();
|
|
1704
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
1705
|
+
const info = vi.spyOn(console, "info").mockImplementation(() => { });
|
|
1706
|
+
const { loginWithDeviceCode } = await import("../daemon.js");
|
|
1707
|
+
await loginWithDeviceCode(["--cancel"]);
|
|
1708
|
+
expect(existsSync(pendingPath)).toBe(false);
|
|
1709
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
1710
|
+
expect(info).toHaveBeenCalledWith("[alan-agent] Browser authorization canceled");
|
|
1711
|
+
});
|
|
1712
|
+
it("recovers login and cancellation from a corrupt browser-operation owner", async () => {
|
|
1713
|
+
const operationPath = `${configPath}.device-auth-operation.json`;
|
|
1714
|
+
const pendingPath = `${configPath}.device-auth-pending`;
|
|
1715
|
+
writeFileSync(operationPath, '{"version":1,"id":', { mode: 0o600 });
|
|
1716
|
+
writeFileSync(pendingPath, "abandoned", { mode: 0o600 });
|
|
1717
|
+
const info = vi.spyOn(console, "info").mockImplementation(() => { });
|
|
1718
|
+
const { loginWithDeviceCode } = await import("../daemon.js");
|
|
1719
|
+
await loginWithDeviceCode(["--cancel"]);
|
|
1720
|
+
expect(existsSync(operationPath)).toBe(false);
|
|
1721
|
+
expect(existsSync(pendingPath)).toBe(false);
|
|
1722
|
+
expect(info).toHaveBeenCalledWith("[alan-agent] Browser authorization canceled");
|
|
1723
|
+
writeFileSync(operationPath, "not-json", { mode: 0o600 });
|
|
1724
|
+
const fetchMock = vi
|
|
1725
|
+
.fn()
|
|
1726
|
+
.mockResolvedValueOnce({
|
|
1727
|
+
ok: true,
|
|
1728
|
+
json: async () => ({
|
|
1729
|
+
deviceCode: "alan_device_after_corruption",
|
|
1730
|
+
userCode: "RECOVER1",
|
|
1731
|
+
verificationUri: "https://app.tryalan.ai/device?code=RECOVER1",
|
|
1732
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
1733
|
+
intervalSeconds: 0,
|
|
1734
|
+
}),
|
|
1735
|
+
})
|
|
1736
|
+
.mockResolvedValueOnce({
|
|
1737
|
+
ok: true,
|
|
1738
|
+
json: async () => ({
|
|
1739
|
+
runtime: { id: "11111111-1111-4111-8111-111111111111" },
|
|
1740
|
+
runtimeToken: "alan_runtime_after_corruption",
|
|
1741
|
+
}),
|
|
1742
|
+
});
|
|
1743
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
1744
|
+
await loginWithDeviceCode(["--max-polls", "1"]);
|
|
1745
|
+
expect(JSON.parse(readFileSync(configPath, "utf8"))).toMatchObject({
|
|
1746
|
+
runtimeId: "11111111-1111-4111-8111-111111111111",
|
|
1747
|
+
runtimeToken: "alan_runtime_after_corruption",
|
|
1748
|
+
});
|
|
1749
|
+
expect(existsSync(operationPath)).toBe(false);
|
|
1750
|
+
});
|
|
1751
|
+
it("preserves a valid browser-operation replacement raced over corrupt state", async () => {
|
|
1752
|
+
const operationPath = `${configPath}.device-auth-operation.json`;
|
|
1753
|
+
const replacement = {
|
|
1754
|
+
version: 1,
|
|
1755
|
+
id: "replacement-browser-operation",
|
|
1756
|
+
expiresAtMs: Date.now() + 60_000,
|
|
1757
|
+
};
|
|
1758
|
+
writeFileSync(operationPath, "corrupt", { mode: 0o600 });
|
|
1759
|
+
mocks.leaseReleaseMutationHook = (mutationPath) => {
|
|
1760
|
+
if (mutationPath !== operationPath)
|
|
1761
|
+
return;
|
|
1762
|
+
writeFileSync(operationPath, JSON.stringify(replacement), { mode: 0o600 });
|
|
1763
|
+
};
|
|
1764
|
+
const { loginWithDeviceCode } = await import("../daemon.js");
|
|
1765
|
+
await expect(loginWithDeviceCode(["--cancel"])).rejects.toThrow("browser authorization is already in progress");
|
|
1766
|
+
expect(JSON.parse(readFileSync(operationPath, "utf8"))).toEqual(replacement);
|
|
1767
|
+
});
|
|
1768
|
+
it("login uses production endpoint defaults", async () => {
|
|
1769
|
+
mocks.spawnSync.mockReturnValue({ status: 1, stdout: "", stderr: "" });
|
|
1770
|
+
const fetchMock = vi
|
|
1771
|
+
.fn()
|
|
1772
|
+
.mockResolvedValueOnce({
|
|
1773
|
+
ok: true,
|
|
1774
|
+
json: async () => ({
|
|
1775
|
+
deviceCode: "alan_device_secret",
|
|
1776
|
+
userCode: "ABCD1234",
|
|
1777
|
+
verificationUri: "https://app.tryalan.ai/runtime/device?code=ABCD1234",
|
|
1778
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
1779
|
+
intervalSeconds: 0,
|
|
1780
|
+
}),
|
|
1781
|
+
})
|
|
1782
|
+
.mockResolvedValueOnce({
|
|
1783
|
+
ok: true,
|
|
1784
|
+
status: 201,
|
|
1785
|
+
json: async () => ({
|
|
1786
|
+
runtime: {
|
|
1787
|
+
id: "11111111-1111-4111-8111-111111111111",
|
|
1788
|
+
displayName: "Prod Login Runtime",
|
|
1789
|
+
teamId: "22222222-2222-4222-8222-222222222222",
|
|
1790
|
+
},
|
|
1791
|
+
runtimeToken: "alan_runtime_browser_secret",
|
|
1792
|
+
}),
|
|
1793
|
+
});
|
|
1794
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
1795
|
+
vi.spyOn(console, "info").mockImplementation(() => { });
|
|
1796
|
+
const { loginWithDeviceCode } = await import("../daemon.js");
|
|
1797
|
+
await loginWithDeviceCode(["--max-polls", "1"]);
|
|
1798
|
+
expect(fetchMock).toHaveBeenNthCalledWith(1, "https://api.tryalan.ai/public/runtimes/device-authorizations", expect.objectContaining({ method: "POST" }));
|
|
1799
|
+
const saved = JSON.parse(readFileSync(configPath, "utf8"));
|
|
1800
|
+
expect(saved).toMatchObject({
|
|
1801
|
+
apiUrl: "https://api.tryalan.ai",
|
|
1802
|
+
wsUrl: "wss://ws.tryalan.ai",
|
|
1803
|
+
endpointProfile: "production",
|
|
1804
|
+
});
|
|
1805
|
+
});
|
|
1806
|
+
it("setup preserves local config when runtime registration fails", async () => {
|
|
1807
|
+
writeFileSync(configPath, JSON.stringify({
|
|
1808
|
+
apiUrl: "http://existing.test",
|
|
1809
|
+
runtimeId: "existing-runtime",
|
|
1810
|
+
runtimeToken: "existing-token",
|
|
1811
|
+
}));
|
|
1812
|
+
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
|
|
1813
|
+
ok: false,
|
|
1814
|
+
status: 403,
|
|
1815
|
+
text: async () => "forbidden",
|
|
1816
|
+
}));
|
|
1817
|
+
const { setupDaemon } = await import("../daemon.js");
|
|
1818
|
+
await expect(setupDaemon([
|
|
1819
|
+
"--api-url",
|
|
1820
|
+
"http://api.test",
|
|
1821
|
+
"--team",
|
|
1822
|
+
"22222222-2222-4222-8222-222222222222",
|
|
1823
|
+
"--token",
|
|
1824
|
+
"alan_pat_secret",
|
|
1825
|
+
])).rejects.toThrow("runtime registration failed: 403 forbidden");
|
|
1826
|
+
expect(JSON.parse(readFileSync(configPath, "utf8"))).toMatchObject({
|
|
1827
|
+
apiUrl: "http://existing.test",
|
|
1828
|
+
runtimeId: "existing-runtime",
|
|
1829
|
+
runtimeToken: "existing-token",
|
|
1830
|
+
});
|
|
1831
|
+
});
|
|
1832
|
+
it("daemon refuses to start without runtime identity, token, and websocket URL", async () => {
|
|
1833
|
+
const { startDaemon } = await import("../daemon.js");
|
|
1834
|
+
await expect(startDaemon(["--runtime-id", "11111111-1111-4111-8111-111111111111"])).rejects.toThrow("daemon requires runtimeId, runtime token, and wsUrl");
|
|
1835
|
+
expect(mocks.io).not.toHaveBeenCalled();
|
|
1836
|
+
});
|
|
1837
|
+
it("startDaemon does not persist CLI identity or wsUrl overrides over stored config", async () => {
|
|
1838
|
+
writeFileSync(configPath, JSON.stringify({
|
|
1839
|
+
apiUrl: "https://api.real.test",
|
|
1840
|
+
wsUrl: "wss://ws.real.test",
|
|
1841
|
+
endpointProfile: "production",
|
|
1842
|
+
runtimeId: "22222222-2222-4222-8222-222222222222",
|
|
1843
|
+
runtimeToken: "alan_runtime_real",
|
|
1844
|
+
localApiPort: 0,
|
|
1845
|
+
localApiToken: "existing-local-token",
|
|
1846
|
+
}));
|
|
1847
|
+
const socket = new TestSocket();
|
|
1848
|
+
mocks.io.mockReturnValue(socket);
|
|
1849
|
+
const { startDaemon } = await import("../daemon.js");
|
|
1850
|
+
const controller = await startDaemon([
|
|
1851
|
+
"--runtime-id",
|
|
1852
|
+
"runtime-override",
|
|
1853
|
+
"--token",
|
|
1854
|
+
"alan_runtime_override",
|
|
1855
|
+
"--ws-url",
|
|
1856
|
+
"ws://127.0.0.1:57613",
|
|
1857
|
+
"--local-api-port",
|
|
1858
|
+
"0",
|
|
1859
|
+
]);
|
|
1860
|
+
try {
|
|
1861
|
+
expect(mocks.io).toHaveBeenCalledWith("ws://127.0.0.1:57613", expect.anything());
|
|
1862
|
+
const saved = JSON.parse(readFileSync(configPath, "utf8"));
|
|
1863
|
+
expect(saved).toMatchObject({
|
|
1864
|
+
apiUrl: "https://api.real.test",
|
|
1865
|
+
wsUrl: "wss://ws.real.test",
|
|
1866
|
+
endpointProfile: "production",
|
|
1867
|
+
runtimeId: "22222222-2222-4222-8222-222222222222",
|
|
1868
|
+
runtimeToken: "alan_runtime_real",
|
|
1869
|
+
localApiPort: 0,
|
|
1870
|
+
});
|
|
1871
|
+
}
|
|
1872
|
+
finally {
|
|
1873
|
+
await controller.stop();
|
|
1874
|
+
}
|
|
1875
|
+
});
|
|
1876
|
+
it("self-heals a stored production config whose wsUrl contradicts the profile", async () => {
|
|
1877
|
+
writeFileSync(configPath, JSON.stringify({
|
|
1878
|
+
apiUrl: "https://api.tryalan.ai",
|
|
1879
|
+
wsUrl: "ws://127.0.0.1:57613",
|
|
1880
|
+
endpointProfile: "production",
|
|
1881
|
+
runtimeId: "22222222-2222-4222-8222-222222222222",
|
|
1882
|
+
runtimeToken: "alan_runtime_real",
|
|
1883
|
+
localApiPort: 0,
|
|
1884
|
+
localApiToken: "existing-local-token",
|
|
1885
|
+
}));
|
|
1886
|
+
writeFileSync(`${configPath}.run-journal.json`, JSON.stringify({
|
|
1887
|
+
version: 1,
|
|
1888
|
+
entries: [
|
|
1889
|
+
{
|
|
1890
|
+
runId: "33333333-3333-4333-8333-333333333333",
|
|
1891
|
+
conversationId: "44444444-4444-4444-8444-444444444444",
|
|
1892
|
+
acceptedAtMs: Date.now(),
|
|
1893
|
+
},
|
|
1894
|
+
],
|
|
1895
|
+
}));
|
|
1896
|
+
const socket = new TestSocket();
|
|
1897
|
+
mocks.io.mockReturnValue(socket);
|
|
1898
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => { });
|
|
1899
|
+
const { startDaemon } = await import("../daemon.js");
|
|
1900
|
+
const controller = await startDaemon(["--local-api-port", "0"]);
|
|
1901
|
+
try {
|
|
1902
|
+
expect(mocks.io).toHaveBeenCalledWith("wss://ws.tryalan.ai", expect.anything());
|
|
1903
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("does not match its endpoint profile"), expect.objectContaining({ detail: expect.stringContaining("ws://127.0.0.1:57613") }));
|
|
1904
|
+
// Runtime-only heal: the stored config keeps the bad value so the
|
|
1905
|
+
// mismatch stays visible until the user re-runs setup.
|
|
1906
|
+
const saved = JSON.parse(readFileSync(configPath, "utf8"));
|
|
1907
|
+
expect(saved.wsUrl).toBe("ws://127.0.0.1:57613");
|
|
1908
|
+
expect(saved.daemonEpoch).toBe(1);
|
|
1909
|
+
// The degraded state is surfaced as a warn readiness check that does
|
|
1910
|
+
// not block run acceptance.
|
|
1911
|
+
socket.emit("connect");
|
|
1912
|
+
const hello = await waitForOutbound(socket, "runtime.hello");
|
|
1913
|
+
const runnerStatus = hello.payload.metadata.runnerStatus;
|
|
1914
|
+
expect(runnerStatus.acceptingRuns).toBe(true);
|
|
1915
|
+
expect(runnerStatus.daemonEpoch).toBe(1);
|
|
1916
|
+
expect(runnerStatus).toMatchObject({
|
|
1917
|
+
protocolVersion: 2,
|
|
1918
|
+
protocolMinVersion: 1,
|
|
1919
|
+
protocolMaxVersion: 2,
|
|
1920
|
+
});
|
|
1921
|
+
expect(runnerStatus.interruptedRunIds).toEqual(["33333333-3333-4333-8333-333333333333"]);
|
|
1922
|
+
expect(runnerStatus.computerReadiness).toEqual(expect.arrayContaining([
|
|
1923
|
+
expect.objectContaining({
|
|
1924
|
+
key: "config_matches_profile",
|
|
1925
|
+
status: "warn",
|
|
1926
|
+
code: "config_endpoint_mismatch",
|
|
1927
|
+
}),
|
|
1928
|
+
]));
|
|
1929
|
+
}
|
|
1930
|
+
finally {
|
|
1931
|
+
warnSpy.mockRestore();
|
|
1932
|
+
await controller.stop();
|
|
1933
|
+
}
|
|
1934
|
+
});
|
|
1935
|
+
it("trusts explicit --ws-url overrides without profile validation", async () => {
|
|
1936
|
+
writeFileSync(configPath, JSON.stringify({
|
|
1937
|
+
apiUrl: "https://api.tryalan.ai",
|
|
1938
|
+
wsUrl: "ws://127.0.0.1:57613",
|
|
1939
|
+
endpointProfile: "production",
|
|
1940
|
+
runtimeId: "22222222-2222-4222-8222-222222222222",
|
|
1941
|
+
runtimeToken: "alan_runtime_real",
|
|
1942
|
+
localApiPort: 0,
|
|
1943
|
+
localApiToken: "existing-local-token",
|
|
1944
|
+
}));
|
|
1945
|
+
const socket = new TestSocket();
|
|
1946
|
+
mocks.io.mockReturnValue(socket);
|
|
1947
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => { });
|
|
1948
|
+
const { startDaemon } = await import("../daemon.js");
|
|
1949
|
+
const controller = await startDaemon(["--ws-url", "ws://ws.test", "--local-api-port", "0"]);
|
|
1950
|
+
try {
|
|
1951
|
+
expect(mocks.io).toHaveBeenCalledWith("ws://ws.test", expect.anything());
|
|
1952
|
+
expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining("does not match its endpoint profile"), expect.anything());
|
|
1953
|
+
}
|
|
1954
|
+
finally {
|
|
1955
|
+
warnSpy.mockRestore();
|
|
1956
|
+
await controller.stop();
|
|
1957
|
+
}
|
|
1958
|
+
});
|
|
1959
|
+
it("accepts stored endpoints that match a deployment's endpoints.json overrides", async () => {
|
|
1960
|
+
writeFileSync(join(tempDir, "endpoints.json"), JSON.stringify({
|
|
1961
|
+
apiUrl: "https://api.corp.example",
|
|
1962
|
+
wsUrl: "wss://ws.corp.example",
|
|
1963
|
+
endpointProfile: "production",
|
|
1964
|
+
}));
|
|
1965
|
+
writeFileSync(configPath, JSON.stringify({
|
|
1966
|
+
apiUrl: "https://api.corp.example",
|
|
1967
|
+
wsUrl: "wss://ws.corp.example",
|
|
1968
|
+
endpointProfile: "production",
|
|
1969
|
+
runtimeId: "22222222-2222-4222-8222-222222222222",
|
|
1970
|
+
runtimeToken: "alan_runtime_real",
|
|
1971
|
+
localApiPort: 0,
|
|
1972
|
+
localApiToken: "existing-local-token",
|
|
1973
|
+
}));
|
|
1974
|
+
const socket = new TestSocket();
|
|
1975
|
+
mocks.io.mockReturnValue(socket);
|
|
1976
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => { });
|
|
1977
|
+
const { startDaemon } = await import("../daemon.js");
|
|
1978
|
+
const controller = await startDaemon(["--local-api-port", "0"]);
|
|
1979
|
+
try {
|
|
1980
|
+
expect(mocks.io).toHaveBeenCalledWith("wss://ws.corp.example", expect.anything());
|
|
1981
|
+
expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining("does not match its endpoint profile"), expect.anything());
|
|
1982
|
+
}
|
|
1983
|
+
finally {
|
|
1984
|
+
warnSpy.mockRestore();
|
|
1985
|
+
await controller.stop();
|
|
1986
|
+
}
|
|
1987
|
+
});
|
|
1988
|
+
it("ignores stale endpoint defaults when stored production endpoints match the binary profile", async () => {
|
|
1989
|
+
writeFileSync(join(tempDir, "endpoints.json"), JSON.stringify({
|
|
1990
|
+
apiUrl: "https://api.tryalan.ai",
|
|
1991
|
+
wsUrl: "wss://ws.tryalan.ai",
|
|
1992
|
+
endpointProfile: "production",
|
|
1993
|
+
}));
|
|
1994
|
+
writeFileSync(configPath, JSON.stringify({
|
|
1995
|
+
apiUrl: "https://api.tryalan.ai",
|
|
1996
|
+
wsUrl: "wss://ws.tryalan.ai",
|
|
1997
|
+
endpointProfile: "production",
|
|
1998
|
+
runtimeId: "22222222-2222-4222-8222-222222222222",
|
|
1999
|
+
runtimeToken: "alan_runtime_real",
|
|
2000
|
+
localApiPort: 0,
|
|
2001
|
+
localApiToken: "existing-local-token",
|
|
2002
|
+
}));
|
|
2003
|
+
const socket = new TestSocket();
|
|
2004
|
+
mocks.io.mockReturnValue(socket);
|
|
2005
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => { });
|
|
2006
|
+
const { startDaemon } = await import("../daemon.js");
|
|
2007
|
+
const controller = await startDaemon(["--local-api-port", "0"]);
|
|
2008
|
+
try {
|
|
2009
|
+
expect(mocks.io).toHaveBeenCalledWith("wss://ws.tryalan.ai", expect.anything());
|
|
2010
|
+
expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining("does not match its endpoint profile"), expect.anything());
|
|
2011
|
+
}
|
|
2012
|
+
finally {
|
|
2013
|
+
warnSpy.mockRestore();
|
|
2014
|
+
await controller.stop();
|
|
2015
|
+
}
|
|
2016
|
+
});
|
|
2017
|
+
it("daemon connects as a runtime and hands accepted executions to CoreAgent", async () => {
|
|
2018
|
+
const socket = new TestSocket();
|
|
2019
|
+
const infoSpy = vi.spyOn(console, "info").mockImplementation(() => { });
|
|
2020
|
+
mocks.io.mockReturnValue(socket);
|
|
2021
|
+
const binDir = seedCliBinaries(tempDir);
|
|
2022
|
+
const codexPath = join(binDir, "codex");
|
|
2023
|
+
process.env.ALAN_CODEX_PATH = codexPath;
|
|
2024
|
+
mocks.spawnSync.mockReturnValue({ status: 1, stdout: "", stderr: "" });
|
|
2025
|
+
mocks.agentRun.mockReturnValue(new Promise(() => { }));
|
|
2026
|
+
const { startDaemon } = await import("../daemon.js");
|
|
2027
|
+
const controller = await startDaemon([
|
|
2028
|
+
"--runtime-id",
|
|
2029
|
+
"11111111-1111-4111-8111-111111111111",
|
|
2030
|
+
"--token",
|
|
2031
|
+
"alan_runtime_secret",
|
|
2032
|
+
"--ws-url",
|
|
2033
|
+
"ws://ws.test",
|
|
2034
|
+
"--local-api-port",
|
|
2035
|
+
"0",
|
|
2036
|
+
]);
|
|
2037
|
+
expect(mocks.io).toHaveBeenCalledWith("ws://ws.test", expect.objectContaining({
|
|
2038
|
+
query: expect.objectContaining({
|
|
2039
|
+
type: "runtime",
|
|
2040
|
+
runtimeId: "11111111-1111-4111-8111-111111111111",
|
|
2041
|
+
hostKind: "daemon",
|
|
2042
|
+
}),
|
|
2043
|
+
auth: { token: "alan_runtime_secret" },
|
|
2044
|
+
transports: ["websocket"],
|
|
2045
|
+
}));
|
|
2046
|
+
socket.emit("connect");
|
|
2047
|
+
await expect(waitForOutbound(socket, "runtime.hello")).resolves.toEqual(expect.objectContaining({
|
|
2048
|
+
event: "runtime.hello",
|
|
2049
|
+
payload: expect.objectContaining({
|
|
2050
|
+
capabilities: expect.objectContaining({ supportsFilesystem: true }),
|
|
2051
|
+
metadata: expect.objectContaining({
|
|
2052
|
+
platform: expect.any(String),
|
|
2053
|
+
arch: expect.any(String),
|
|
2054
|
+
agentVersion: expect.any(String),
|
|
2055
|
+
memoryBytes: expect.any(Number),
|
|
2056
|
+
}),
|
|
2057
|
+
}),
|
|
2058
|
+
}));
|
|
2059
|
+
socket.emit("agent.execute", {
|
|
2060
|
+
conversationId: "conversation-missing-workspace",
|
|
2061
|
+
runId: "run-missing-workspace",
|
|
2062
|
+
content: "Run from a moved project",
|
|
2063
|
+
backendKind: "codex",
|
|
2064
|
+
projectPath: join(tempDir, "moved-workspace"),
|
|
2065
|
+
});
|
|
2066
|
+
expect(socket.outbound).toEqual(expect.arrayContaining([
|
|
2067
|
+
{
|
|
2068
|
+
event: "agent.rejected",
|
|
2069
|
+
payload: expect.objectContaining({
|
|
2070
|
+
runId: "run-missing-workspace",
|
|
2071
|
+
code: "workspace_missing",
|
|
2072
|
+
message: expect.stringContaining("moved or removed"),
|
|
2073
|
+
}),
|
|
2074
|
+
},
|
|
2075
|
+
]));
|
|
2076
|
+
expect(mocks.agentRun).not.toHaveBeenCalled();
|
|
2077
|
+
const deniedStatus = await fetch(`http://127.0.0.1:${controller.localApiPort}/status`, {
|
|
2078
|
+
headers: { authorization: `Bearer ${controller.localApiToken}` },
|
|
2079
|
+
}).then((response) => response.json());
|
|
2080
|
+
expect(deniedStatus.workspaceRecoveryPath).toBe(join(tempDir, "moved-workspace"));
|
|
2081
|
+
const deniedRecheck = await fetch(`http://127.0.0.1:${controller.localApiPort}/workspace/recheck`, {
|
|
2082
|
+
method: "POST",
|
|
2083
|
+
headers: { authorization: `Bearer ${controller.localApiToken}` },
|
|
2084
|
+
}).then((response) => response.json());
|
|
2085
|
+
expect(deniedRecheck).toMatchObject({
|
|
2086
|
+
ok: false,
|
|
2087
|
+
readiness: { code: "workspace_missing" },
|
|
2088
|
+
});
|
|
2089
|
+
const workspacePath = join(tempDir, "workspace");
|
|
2090
|
+
mkdirSync(workspacePath, { recursive: true });
|
|
2091
|
+
socket.emit("agent.execute", {
|
|
2092
|
+
conversationId: "conversation-1",
|
|
2093
|
+
runId: "run-1",
|
|
2094
|
+
messageId: "message-1",
|
|
2095
|
+
taskId: "task-1",
|
|
2096
|
+
cloudEnvironmentId: "cloud-env-1",
|
|
2097
|
+
content: "Ship the task",
|
|
2098
|
+
backendKind: "codex",
|
|
2099
|
+
teamId: "team-1",
|
|
2100
|
+
projectPath: workspacePath,
|
|
2101
|
+
maxIterations: 7,
|
|
2102
|
+
});
|
|
2103
|
+
await vi.waitFor(() => expect(socket.outbound).toEqual(expect.arrayContaining([{ event: "agent.accepted", payload: { runId: "run-1" } }])), { timeout: 5_000 });
|
|
2104
|
+
const recoveredStatus = await fetch(`http://127.0.0.1:${controller.localApiPort}/status`, {
|
|
2105
|
+
headers: { authorization: `Bearer ${controller.localApiToken}` },
|
|
2106
|
+
}).then((response) => response.json());
|
|
2107
|
+
expect(recoveredStatus.workspaceRecoveryPath).toBeNull();
|
|
2108
|
+
expect(mocks.coreAgentConstructor).toHaveBeenCalledWith(expect.any(Object), {
|
|
2109
|
+
backendKind: "codex_app_server",
|
|
2110
|
+
runtimeCommand: codexPath,
|
|
2111
|
+
providerApiKey: undefined,
|
|
2112
|
+
onProviderSpawned: expect.any(Function),
|
|
2113
|
+
});
|
|
2114
|
+
expect(mocks.agentRun).toHaveBeenCalledWith(expect.objectContaining({
|
|
2115
|
+
task: "Ship the task",
|
|
2116
|
+
cwd: workspacePath,
|
|
2117
|
+
teamPath: workspacePath,
|
|
2118
|
+
backendKind: "codex_app_server",
|
|
2119
|
+
runtimeCommand: codexPath,
|
|
2120
|
+
conversationId: "conversation-1",
|
|
2121
|
+
taskId: "task-1",
|
|
2122
|
+
teamId: "team-1",
|
|
2123
|
+
maxIterations: 7,
|
|
2124
|
+
}));
|
|
2125
|
+
expect(infoSpy).toHaveBeenCalledWith("[alan-agent] Agent run accepted", expect.objectContaining({
|
|
2126
|
+
taskId: "task-1",
|
|
2127
|
+
conversationId: "conversation-1",
|
|
2128
|
+
runId: "run-1",
|
|
2129
|
+
messageId: "message-1",
|
|
2130
|
+
cloudEnvironmentId: "cloud-env-1",
|
|
2131
|
+
provider: "codex_app_server",
|
|
2132
|
+
backendKind: "codex_app_server",
|
|
2133
|
+
agentVersion: expect.any(String),
|
|
2134
|
+
}));
|
|
2135
|
+
socket.emit("agent.execute", {
|
|
2136
|
+
conversationId: "conversation-1",
|
|
2137
|
+
runId: "run-1",
|
|
2138
|
+
content: "Duplicate",
|
|
2139
|
+
});
|
|
2140
|
+
expect(socket.outbound.filter((item) => item.event === "agent.accepted" &&
|
|
2141
|
+
item.payload?.runId === "run-1")).toHaveLength(2);
|
|
2142
|
+
expect(socket.outbound).not.toEqual(expect.arrayContaining([
|
|
2143
|
+
{
|
|
2144
|
+
event: "agent.rejected",
|
|
2145
|
+
payload: expect.objectContaining({ runId: "run-1" }),
|
|
2146
|
+
},
|
|
2147
|
+
]));
|
|
2148
|
+
expect(mocks.agentRun).toHaveBeenCalledTimes(1);
|
|
2149
|
+
socket.emit("agent.execute", {
|
|
2150
|
+
conversationId: "different-conversation",
|
|
2151
|
+
runId: "run-1",
|
|
2152
|
+
content: "Conflicting duplicate",
|
|
2153
|
+
});
|
|
2154
|
+
expect(socket.outbound).toEqual(expect.arrayContaining([
|
|
2155
|
+
{
|
|
2156
|
+
event: "agent.rejected",
|
|
2157
|
+
payload: expect.objectContaining({ runId: "run-1", code: "run_id_conflict" }),
|
|
2158
|
+
},
|
|
2159
|
+
]));
|
|
2160
|
+
expect(mocks.agentRun).toHaveBeenCalledTimes(1);
|
|
2161
|
+
socket.emit("agent.abort", { runId: "run-1" });
|
|
2162
|
+
expect(mocks.agentKill).toHaveBeenCalled();
|
|
2163
|
+
expect(socket.outbound).toEqual(expect.arrayContaining([
|
|
2164
|
+
{
|
|
2165
|
+
event: "agent.event",
|
|
2166
|
+
payload: expect.objectContaining({
|
|
2167
|
+
conversationId: "conversation-1",
|
|
2168
|
+
runId: "run-1",
|
|
2169
|
+
event: expect.objectContaining({
|
|
2170
|
+
type: "agent_run_finished",
|
|
2171
|
+
result: expect.objectContaining({
|
|
2172
|
+
error: "Interrupted by user",
|
|
2173
|
+
errorKind: "user",
|
|
2174
|
+
success: false,
|
|
2175
|
+
}),
|
|
2176
|
+
}),
|
|
2177
|
+
}),
|
|
2178
|
+
},
|
|
2179
|
+
]));
|
|
2180
|
+
expect(socket.disconnected).toBe(false);
|
|
2181
|
+
// A server-watchdog abort must carry a distinct, non-user-attributed error.
|
|
2182
|
+
socket.emit("agent.execute", {
|
|
2183
|
+
conversationId: "conversation-2",
|
|
2184
|
+
runId: "run-2",
|
|
2185
|
+
messageId: "message-2",
|
|
2186
|
+
taskId: "task-2",
|
|
2187
|
+
cloudEnvironmentId: "cloud-env-1",
|
|
2188
|
+
content: "Second task",
|
|
2189
|
+
backendKind: "codex",
|
|
2190
|
+
teamId: "team-1",
|
|
2191
|
+
projectPath: workspacePath,
|
|
2192
|
+
maxIterations: 7,
|
|
2193
|
+
});
|
|
2194
|
+
await vi.waitFor(() => expect(socket.outbound).toEqual(expect.arrayContaining([{ event: "agent.accepted", payload: { runId: "run-2" } }])), { timeout: 5_000 });
|
|
2195
|
+
socket.emit("agent.abort", { runId: "run-2", reason: "watchdog_timeout" });
|
|
2196
|
+
expect(socket.outbound).toEqual(expect.arrayContaining([
|
|
2197
|
+
{
|
|
2198
|
+
event: "agent.event",
|
|
2199
|
+
payload: expect.objectContaining({
|
|
2200
|
+
conversationId: "conversation-2",
|
|
2201
|
+
runId: "run-2",
|
|
2202
|
+
event: expect.objectContaining({
|
|
2203
|
+
type: "agent_run_finished",
|
|
2204
|
+
result: expect.objectContaining({
|
|
2205
|
+
error: expect.stringContaining("stopped by the server"),
|
|
2206
|
+
errorKind: "watchdog_timeout",
|
|
2207
|
+
success: false,
|
|
2208
|
+
}),
|
|
2209
|
+
}),
|
|
2210
|
+
}),
|
|
2211
|
+
},
|
|
2212
|
+
]));
|
|
2213
|
+
await controller.stop();
|
|
2214
|
+
expect(socket.disconnected).toBe(true);
|
|
2215
|
+
});
|
|
2216
|
+
it("renews a rejected runtime token once and reconnects with the persisted replacement", async () => {
|
|
2217
|
+
const socket = new TestSocket();
|
|
2218
|
+
mocks.io.mockReturnValue(socket);
|
|
2219
|
+
mocks.spawnSync.mockReturnValue({ status: 1, stdout: "", stderr: "" });
|
|
2220
|
+
writeFileSync(configPath, JSON.stringify({
|
|
2221
|
+
apiUrl: "http://api.test",
|
|
2222
|
+
wsUrl: "ws://ws.test",
|
|
2223
|
+
runtimeId: "11111111-1111-4111-8111-111111111111",
|
|
2224
|
+
runtimeToken: "old_runtime_secret",
|
|
2225
|
+
runtimeRenewalToken: "alan_runtime_renew_secret",
|
|
2226
|
+
localApiPort: 0,
|
|
2227
|
+
localApiToken: "local-secret",
|
|
2228
|
+
}));
|
|
2229
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
2230
|
+
ok: true,
|
|
2231
|
+
json: async () => ({ runtimeToken: "alan_runtime_new_secret" }),
|
|
2232
|
+
});
|
|
2233
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
2234
|
+
const { startDaemon } = await import("../daemon.js");
|
|
2235
|
+
const controller = await startDaemon(["--local-api-port", "0"]);
|
|
2236
|
+
const rejection = Object.assign(new Error("Invalid runtime token"), {
|
|
2237
|
+
data: { code: "invalid_runtime_token" },
|
|
2238
|
+
});
|
|
2239
|
+
socket.emit("connect_error", rejection);
|
|
2240
|
+
socket.emit("connect_error", rejection);
|
|
2241
|
+
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledWith("http://api.test/public/runtimes/11111111-1111-4111-8111-111111111111/tokens/renew", expect.objectContaining({
|
|
2242
|
+
method: "POST",
|
|
2243
|
+
headers: {
|
|
2244
|
+
authorization: "Bearer alan_runtime_renew_secret",
|
|
2245
|
+
"content-type": "application/json",
|
|
2246
|
+
},
|
|
2247
|
+
body: "{}",
|
|
2248
|
+
})));
|
|
2249
|
+
await vi.waitFor(() => expect(JSON.parse(readFileSync(configPath, "utf8")).runtimeToken).toBe("alan_runtime_new_secret"));
|
|
2250
|
+
await vi.waitFor(() => expect(socket.connectCalls).toBe(1));
|
|
2251
|
+
expect(fetchMock.mock.calls.filter(([url]) => String(url).endsWith("/tokens/renew"))).toHaveLength(1);
|
|
2252
|
+
expect(socket.auth).toEqual({ token: "alan_runtime_new_secret" });
|
|
2253
|
+
expect(JSON.parse(readFileSync(configPath, "utf8"))).toMatchObject({
|
|
2254
|
+
runtimeToken: "alan_runtime_new_secret",
|
|
2255
|
+
runtimeRenewalToken: "alan_runtime_renew_secret",
|
|
2256
|
+
});
|
|
2257
|
+
await controller.stop();
|
|
2258
|
+
});
|
|
2259
|
+
it("reports a proven DNS failure through local daemon status", async () => {
|
|
2260
|
+
const socket = new TestSocket();
|
|
2261
|
+
mocks.io.mockReturnValue(socket);
|
|
2262
|
+
mocks.spawnSync.mockReturnValue({ status: 1, stdout: "", stderr: "" });
|
|
2263
|
+
const { startDaemon } = await import("../daemon.js");
|
|
2264
|
+
const controller = await startDaemon([
|
|
2265
|
+
"--runtime-id",
|
|
2266
|
+
"11111111-1111-4111-8111-111111111111",
|
|
2267
|
+
"--token",
|
|
2268
|
+
"alan_runtime_secret",
|
|
2269
|
+
"--ws-url",
|
|
2270
|
+
"wss://ws.tryalan.ai",
|
|
2271
|
+
"--local-api-port",
|
|
2272
|
+
"0",
|
|
2273
|
+
]);
|
|
2274
|
+
try {
|
|
2275
|
+
socket.emit("connect_error", Object.assign(new Error("getaddrinfo ENOTFOUND ws.tryalan.ai"), { code: "ENOTFOUND" }));
|
|
2276
|
+
const response = await fetch(`http://127.0.0.1:${controller.localApiPort}/status`, {
|
|
2277
|
+
headers: { authorization: `Bearer ${controller.localApiToken}` },
|
|
2278
|
+
});
|
|
2279
|
+
expect(await response.json()).toMatchObject({
|
|
2280
|
+
lastConnectErrorCode: "network_dns_failure",
|
|
2281
|
+
});
|
|
2282
|
+
}
|
|
2283
|
+
finally {
|
|
2284
|
+
await controller.stop();
|
|
2285
|
+
}
|
|
2286
|
+
});
|
|
2287
|
+
it("keeps a live-PID recovered run interrupted and duplicate-fenced after restart", async () => {
|
|
2288
|
+
const firstSocket = new TestSocket();
|
|
2289
|
+
mocks.io.mockReturnValue(firstSocket);
|
|
2290
|
+
const binDir = seedCliBinaries(tempDir);
|
|
2291
|
+
process.env.ALAN_CODEX_PATH = join(binDir, "codex");
|
|
2292
|
+
mocks.spawnSync.mockReturnValue({ status: 1, stdout: "", stderr: "" });
|
|
2293
|
+
mocks.agentRun.mockReturnValue(new Promise(() => { }));
|
|
2294
|
+
const { startDaemon } = await import("../daemon.js");
|
|
2295
|
+
const args = [
|
|
2296
|
+
"--runtime-id",
|
|
2297
|
+
"11111111-1111-4111-8111-111111111111",
|
|
2298
|
+
"--token",
|
|
2299
|
+
"alan_runtime_secret",
|
|
2300
|
+
"--ws-url",
|
|
2301
|
+
"ws://ws.test",
|
|
2302
|
+
"--local-api-port",
|
|
2303
|
+
"0",
|
|
2304
|
+
];
|
|
2305
|
+
const firstController = await startDaemon(args);
|
|
2306
|
+
firstSocket.emit("connect");
|
|
2307
|
+
firstSocket.emit("agent.execute", {
|
|
2308
|
+
conversationId: "conversation-durable",
|
|
2309
|
+
runId: "run-durable",
|
|
2310
|
+
content: "Execute exactly once",
|
|
2311
|
+
backendKind: "codex",
|
|
2312
|
+
});
|
|
2313
|
+
await vi.waitFor(() => expect(firstSocket.outbound).toEqual(expect.arrayContaining([{ event: "agent.accepted", payload: { runId: "run-durable" } }])), { timeout: 5_000 });
|
|
2314
|
+
expect(mocks.agentRun).toHaveBeenCalledTimes(1);
|
|
2315
|
+
await vi.waitFor(() => {
|
|
2316
|
+
const journal = JSON.parse(readFileSync(`${configPath}.run-journal.json`, "utf8"));
|
|
2317
|
+
expect(journal.entries).toEqual(expect.arrayContaining([
|
|
2318
|
+
expect.objectContaining({ runId: "run-durable", providerPid: process.pid }),
|
|
2319
|
+
]));
|
|
2320
|
+
});
|
|
2321
|
+
await firstController.stop();
|
|
2322
|
+
const secondSocket = new TestSocket();
|
|
2323
|
+
mocks.io.mockReturnValue(secondSocket);
|
|
2324
|
+
const secondController = await startDaemon(args);
|
|
2325
|
+
try {
|
|
2326
|
+
secondSocket.emit("connect");
|
|
2327
|
+
secondSocket.emit("agent.execute", {
|
|
2328
|
+
conversationId: "conversation-durable",
|
|
2329
|
+
runId: "run-durable",
|
|
2330
|
+
content: "Redelivered after lost ACK",
|
|
2331
|
+
backendKind: "codex",
|
|
2332
|
+
});
|
|
2333
|
+
await vi.waitFor(() => expect(secondSocket.outbound).toEqual(expect.arrayContaining([
|
|
2334
|
+
{
|
|
2335
|
+
event: "agent.rejected",
|
|
2336
|
+
payload: expect.objectContaining({
|
|
2337
|
+
runId: "run-durable",
|
|
2338
|
+
code: "run_interrupted_by_restart",
|
|
2339
|
+
}),
|
|
2340
|
+
},
|
|
2341
|
+
])));
|
|
2342
|
+
expect(mocks.agentRun).toHaveBeenCalledTimes(1);
|
|
2343
|
+
secondSocket.emit("agent.execute", {
|
|
2344
|
+
conversationId: "another-conversation",
|
|
2345
|
+
runId: "run-durable",
|
|
2346
|
+
content: "Conflicting redelivery after restart",
|
|
2347
|
+
backendKind: "codex",
|
|
2348
|
+
});
|
|
2349
|
+
await vi.waitFor(() => expect(secondSocket.outbound).toEqual(expect.arrayContaining([
|
|
2350
|
+
{
|
|
2351
|
+
event: "agent.rejected",
|
|
2352
|
+
payload: expect.objectContaining({
|
|
2353
|
+
runId: "run-durable",
|
|
2354
|
+
code: "run_id_conflict",
|
|
2355
|
+
}),
|
|
2356
|
+
},
|
|
2357
|
+
])));
|
|
2358
|
+
expect(mocks.agentRun).toHaveBeenCalledTimes(1);
|
|
2359
|
+
}
|
|
2360
|
+
finally {
|
|
2361
|
+
await secondController.stop();
|
|
2362
|
+
}
|
|
2363
|
+
});
|
|
2364
|
+
it("recovers a moved project by exact repository identity before provider spawn", async () => {
|
|
2365
|
+
const socket = new TestSocket();
|
|
2366
|
+
mocks.io.mockReturnValue(socket);
|
|
2367
|
+
const binDir = seedCliBinaries(tempDir);
|
|
2368
|
+
process.env.ALAN_CODEX_PATH = join(binDir, "codex");
|
|
2369
|
+
mocks.spawnSync.mockReturnValue({ status: 1, stdout: "", stderr: "" });
|
|
2370
|
+
mocks.agentRun.mockReturnValue(new Promise(() => { }));
|
|
2371
|
+
const missingPath = join(tempDir, "old-project-name");
|
|
2372
|
+
const relocatedPath = join(tempDir, "new-project-name");
|
|
2373
|
+
mkdirSync(relocatedPath, { recursive: true });
|
|
2374
|
+
mocks.discoverRelocatedWorkspace.mockReturnValue(relocatedPath);
|
|
2375
|
+
const { startDaemon } = await import("../daemon.js");
|
|
2376
|
+
const controller = await startDaemon([
|
|
2377
|
+
"--runtime-id",
|
|
2378
|
+
"11111111-1111-4111-8111-111111111111",
|
|
2379
|
+
"--token",
|
|
2380
|
+
"alan_runtime_secret",
|
|
2381
|
+
"--ws-url",
|
|
2382
|
+
"ws://ws.test",
|
|
2383
|
+
"--local-api-port",
|
|
2384
|
+
"0",
|
|
2385
|
+
]);
|
|
2386
|
+
try {
|
|
2387
|
+
socket.emit("connect");
|
|
2388
|
+
socket.emit("agent.execute", {
|
|
2389
|
+
conversationId: "conversation-relocated",
|
|
2390
|
+
runId: "run-relocated",
|
|
2391
|
+
content: "Use the relocated project",
|
|
2392
|
+
backendKind: "codex",
|
|
2393
|
+
projectPath: missingPath,
|
|
2394
|
+
repoLocalPaths: [
|
|
2395
|
+
{
|
|
2396
|
+
repoId: "repo-1",
|
|
2397
|
+
repoUrl: "https://github.com/alan-ai/alan.git",
|
|
2398
|
+
localPath: missingPath,
|
|
2399
|
+
},
|
|
2400
|
+
],
|
|
2401
|
+
});
|
|
2402
|
+
await vi.waitFor(() => expect(socket.outbound).toEqual(expect.arrayContaining([{ event: "agent.accepted", payload: { runId: "run-relocated" } }])));
|
|
2403
|
+
expect(mocks.discoverRelocatedWorkspace).toHaveBeenCalledWith({
|
|
2404
|
+
missingPath,
|
|
2405
|
+
approvedWorkspacePaths: undefined,
|
|
2406
|
+
expectedRepoUrls: ["https://github.com/alan-ai/alan.git"],
|
|
2407
|
+
});
|
|
2408
|
+
expect(socket.outbound).toEqual(expect.arrayContaining([
|
|
2409
|
+
{
|
|
2410
|
+
event: "runtime.workspace.relocated",
|
|
2411
|
+
payload: expect.objectContaining({
|
|
2412
|
+
conversationId: "conversation-relocated",
|
|
2413
|
+
runId: "run-relocated",
|
|
2414
|
+
previousPath: missingPath,
|
|
2415
|
+
canonicalPath: relocatedPath,
|
|
2416
|
+
}),
|
|
2417
|
+
},
|
|
2418
|
+
]));
|
|
2419
|
+
expect(mocks.agentRun).toHaveBeenCalledWith(expect.objectContaining({ cwd: relocatedPath, teamPath: relocatedPath }));
|
|
2420
|
+
}
|
|
2421
|
+
finally {
|
|
2422
|
+
await controller.stop();
|
|
2423
|
+
}
|
|
2424
|
+
});
|
|
2425
|
+
it("rejects an existing workspace that belongs to a different repository", async () => {
|
|
2426
|
+
const socket = new TestSocket();
|
|
2427
|
+
mocks.io.mockReturnValue(socket);
|
|
2428
|
+
const binDir = seedCliBinaries(tempDir);
|
|
2429
|
+
process.env.ALAN_CODEX_PATH = join(binDir, "codex");
|
|
2430
|
+
mocks.spawnSync.mockReturnValue({ status: 1, stdout: "", stderr: "" });
|
|
2431
|
+
mocks.agentRun.mockReturnValue(new Promise(() => { }));
|
|
2432
|
+
mocks.workspaceMatchesExpectedRepositories.mockReturnValue(false);
|
|
2433
|
+
const workspacePath = join(tempDir, "reused-project-path");
|
|
2434
|
+
mkdirSync(workspacePath, { recursive: true });
|
|
2435
|
+
const { startDaemon } = await import("../daemon.js");
|
|
2436
|
+
const controller = await startDaemon([
|
|
2437
|
+
"--runtime-id",
|
|
2438
|
+
"11111111-1111-4111-8111-111111111111",
|
|
2439
|
+
"--token",
|
|
2440
|
+
"alan_runtime_secret",
|
|
2441
|
+
"--ws-url",
|
|
2442
|
+
"ws://ws.test",
|
|
2443
|
+
"--local-api-port",
|
|
2444
|
+
"0",
|
|
2445
|
+
]);
|
|
2446
|
+
try {
|
|
2447
|
+
socket.emit("connect");
|
|
2448
|
+
socket.emit("agent.execute", {
|
|
2449
|
+
conversationId: "conversation-repository-mismatch",
|
|
2450
|
+
runId: "run-repository-mismatch",
|
|
2451
|
+
content: "Do not run in the wrong repository",
|
|
2452
|
+
backendKind: "codex",
|
|
2453
|
+
projectPath: workspacePath,
|
|
2454
|
+
localRepoUrls: ["https://github.com/alan-ai/expected.git"],
|
|
2455
|
+
});
|
|
2456
|
+
await vi.waitFor(() => expect(socket.outbound).toEqual(expect.arrayContaining([
|
|
2457
|
+
{
|
|
2458
|
+
event: "agent.rejected",
|
|
2459
|
+
payload: expect.objectContaining({
|
|
2460
|
+
runId: "run-repository-mismatch",
|
|
2461
|
+
code: "workspace_repository_mismatch",
|
|
2462
|
+
}),
|
|
2463
|
+
},
|
|
2464
|
+
])));
|
|
2465
|
+
expect(mocks.workspaceMatchesExpectedRepositories).toHaveBeenCalledWith({
|
|
2466
|
+
workspacePath,
|
|
2467
|
+
expectedRepoUrls: ["https://github.com/alan-ai/expected.git"],
|
|
2468
|
+
});
|
|
2469
|
+
expect(mocks.agentRun).not.toHaveBeenCalled();
|
|
2470
|
+
}
|
|
2471
|
+
finally {
|
|
2472
|
+
await controller.stop();
|
|
2473
|
+
}
|
|
2474
|
+
});
|
|
2475
|
+
it("rejects a multi-repo task worktree before agent execution", async () => {
|
|
2476
|
+
const socket = new TestSocket();
|
|
2477
|
+
mocks.io.mockReturnValue(socket);
|
|
2478
|
+
const binDir = seedCliBinaries(tempDir);
|
|
2479
|
+
process.env.ALAN_CODEX_PATH = join(binDir, "codex");
|
|
2480
|
+
mocks.spawnSync.mockReturnValue({ status: 1, stdout: "", stderr: "" });
|
|
2481
|
+
mocks.agentRun.mockReturnValue(new Promise(() => { }));
|
|
2482
|
+
const container = join(tempDir, "multi-repo-task");
|
|
2483
|
+
const worktreeA = join(container, "repo-a");
|
|
2484
|
+
const worktreeB = join(container, "repo-b");
|
|
2485
|
+
mkdirSync(worktreeA, { recursive: true });
|
|
2486
|
+
mkdirSync(worktreeB, { recursive: true });
|
|
2487
|
+
const repoUrlA = "https://github.com/alan-ai/repo-a.git";
|
|
2488
|
+
const repoUrlB = "https://github.com/alan-ai/repo-b.git";
|
|
2489
|
+
const { startDaemon } = await import("../daemon.js");
|
|
2490
|
+
const controller = await startDaemon([
|
|
2491
|
+
"--runtime-id",
|
|
2492
|
+
"11111111-1111-4111-8111-111111111111",
|
|
2493
|
+
"--token",
|
|
2494
|
+
"alan_runtime_secret",
|
|
2495
|
+
"--ws-url",
|
|
2496
|
+
"ws://ws.test",
|
|
2497
|
+
"--local-api-port",
|
|
2498
|
+
"0",
|
|
2499
|
+
]);
|
|
2500
|
+
try {
|
|
2501
|
+
socket.emit("connect");
|
|
2502
|
+
socket.emit("agent.execute", {
|
|
2503
|
+
conversationId: "conversation-multi-repo",
|
|
2504
|
+
runId: "run-multi-repo",
|
|
2505
|
+
content: "Work across both repos",
|
|
2506
|
+
backendKind: "codex",
|
|
2507
|
+
localRepoUrls: [repoUrlA, repoUrlB],
|
|
2508
|
+
worktreeInfo: {
|
|
2509
|
+
worktreePath: worktreeA,
|
|
2510
|
+
branchName: "codex/task-a",
|
|
2511
|
+
allWorktrees: [
|
|
2512
|
+
{ worktreePath: worktreeA, branchName: "codex/task-a" },
|
|
2513
|
+
{ worktreePath: worktreeB, branchName: "codex/task-b" },
|
|
2514
|
+
],
|
|
2515
|
+
},
|
|
2516
|
+
});
|
|
2517
|
+
await vi.waitFor(() => expect(socket.outbound).toEqual(expect.arrayContaining([
|
|
2518
|
+
{
|
|
2519
|
+
event: "agent.rejected",
|
|
2520
|
+
payload: {
|
|
2521
|
+
runId: "run-multi-repo",
|
|
2522
|
+
message: "A task worktree requires exactly one Git repository.",
|
|
2523
|
+
code: "single_repository_worktree_required",
|
|
2524
|
+
},
|
|
2525
|
+
},
|
|
2526
|
+
])), { timeout: 5_000 });
|
|
2527
|
+
expect(mocks.workspaceMatchesExpectedRepositories).not.toHaveBeenCalled();
|
|
2528
|
+
expect(mocks.agentRun).not.toHaveBeenCalled();
|
|
2529
|
+
}
|
|
2530
|
+
finally {
|
|
2531
|
+
await controller.stop();
|
|
2532
|
+
}
|
|
2533
|
+
});
|
|
2534
|
+
it("allows multiple repository paths for a shared checkout", async () => {
|
|
2535
|
+
const socket = new TestSocket();
|
|
2536
|
+
mocks.io.mockReturnValue(socket);
|
|
2537
|
+
const binDir = seedCliBinaries(tempDir);
|
|
2538
|
+
process.env.ALAN_CODEX_PATH = join(binDir, "codex");
|
|
2539
|
+
mocks.spawnSync.mockReturnValue({ status: 1, stdout: "", stderr: "" });
|
|
2540
|
+
mocks.agentRun.mockReturnValue(new Promise(() => { }));
|
|
2541
|
+
const projectPath = join(tempDir, "multi-repo-shared-checkout");
|
|
2542
|
+
const repoA = join(projectPath, "repo-a");
|
|
2543
|
+
const repoB = join(projectPath, "repo-b");
|
|
2544
|
+
mkdirSync(repoA, { recursive: true });
|
|
2545
|
+
mkdirSync(repoB, { recursive: true });
|
|
2546
|
+
const { startDaemon } = await import("../daemon.js");
|
|
2547
|
+
const controller = await startDaemon([
|
|
2548
|
+
"--runtime-id",
|
|
2549
|
+
"11111111-1111-4111-8111-111111111111",
|
|
2550
|
+
"--token",
|
|
2551
|
+
"alan_runtime_secret",
|
|
2552
|
+
"--ws-url",
|
|
2553
|
+
"ws://ws.test",
|
|
2554
|
+
"--local-api-port",
|
|
2555
|
+
"0",
|
|
2556
|
+
]);
|
|
2557
|
+
try {
|
|
2558
|
+
socket.emit("connect");
|
|
2559
|
+
socket.emit("agent.execute", {
|
|
2560
|
+
conversationId: "conversation-multi-repo-shared",
|
|
2561
|
+
runId: "run-multi-repo-shared",
|
|
2562
|
+
content: "Work across both shared checkout repositories",
|
|
2563
|
+
backendKind: "codex",
|
|
2564
|
+
projectPath,
|
|
2565
|
+
repoLocalPaths: [
|
|
2566
|
+
{ repoUrl: "https://github.com/alan-ai/repo-a.git", localPath: repoA },
|
|
2567
|
+
{ repoUrl: "https://github.com/alan-ai/repo-b.git", localPath: repoB },
|
|
2568
|
+
],
|
|
2569
|
+
});
|
|
2570
|
+
await vi.waitFor(() => expect(socket.outbound).toEqual(expect.arrayContaining([
|
|
2571
|
+
{ event: "agent.accepted", payload: { runId: "run-multi-repo-shared" } },
|
|
2572
|
+
])));
|
|
2573
|
+
expect(mocks.agentRun).toHaveBeenCalled();
|
|
2574
|
+
expect(socket.outbound.find((event) => event.event === "agent.rejected" &&
|
|
2575
|
+
event.payload.runId === "run-multi-repo-shared")).toBeUndefined();
|
|
2576
|
+
}
|
|
2577
|
+
finally {
|
|
2578
|
+
await controller.stop();
|
|
2579
|
+
}
|
|
2580
|
+
});
|
|
2581
|
+
it("stops an active run with a typed recovery cause when its branch lease changes", async () => {
|
|
2582
|
+
const socket = new TestSocket();
|
|
2583
|
+
mocks.io.mockReturnValue(socket);
|
|
2584
|
+
const binDir = seedCliBinaries(tempDir);
|
|
2585
|
+
process.env.ALAN_CODEX_PATH = join(binDir, "codex");
|
|
2586
|
+
mocks.spawnSync.mockReturnValue({ status: 1, stdout: "", stderr: "" });
|
|
2587
|
+
mocks.agentRun.mockReturnValue(new Promise(() => { }));
|
|
2588
|
+
const workspacePath = join(tempDir, "leased-worktree");
|
|
2589
|
+
mkdirSync(workspacePath, { recursive: true });
|
|
2590
|
+
const { startDaemon } = await import("../daemon.js");
|
|
2591
|
+
const controller = await startDaemon([
|
|
2592
|
+
"--runtime-id",
|
|
2593
|
+
"11111111-1111-4111-8111-111111111111",
|
|
2594
|
+
"--token",
|
|
2595
|
+
"alan_runtime_secret",
|
|
2596
|
+
"--ws-url",
|
|
2597
|
+
"ws://ws.test",
|
|
2598
|
+
"--local-api-port",
|
|
2599
|
+
"0",
|
|
2600
|
+
]);
|
|
2601
|
+
try {
|
|
2602
|
+
socket.emit("connect");
|
|
2603
|
+
socket.emit("agent.execute", {
|
|
2604
|
+
conversationId: "conversation-branch-lease",
|
|
2605
|
+
runId: "run-branch-lease",
|
|
2606
|
+
content: "Keep work on the leased branch",
|
|
2607
|
+
backendKind: "codex",
|
|
2608
|
+
projectPath: workspacePath,
|
|
2609
|
+
worktreeInfo: {
|
|
2610
|
+
worktreePath: workspacePath,
|
|
2611
|
+
branchName: "codex/task-1",
|
|
2612
|
+
},
|
|
2613
|
+
});
|
|
2614
|
+
await vi.waitFor(() => expect(socket.outbound).toEqual(expect.arrayContaining([
|
|
2615
|
+
{ event: "agent.accepted", payload: { runId: "run-branch-lease" } },
|
|
2616
|
+
])));
|
|
2617
|
+
expect(mocks.captureWorkspaceRunLease).toHaveBeenCalledWith([
|
|
2618
|
+
{ workspacePath, expectedBranch: "codex/task-1" },
|
|
2619
|
+
]);
|
|
2620
|
+
mocks.workspaceLeaseVerify.mockReturnValue({
|
|
2621
|
+
code: "workspace_lease_branch_changed",
|
|
2622
|
+
message: "The workspace branch changed from codex/task-1 to main while the run was active.",
|
|
2623
|
+
workspacePath,
|
|
2624
|
+
expectedBranch: "codex/task-1",
|
|
2625
|
+
actualBranch: "main",
|
|
2626
|
+
});
|
|
2627
|
+
const presenter = mocks.coreAgentConstructor.mock.calls.at(-1)?.[0];
|
|
2628
|
+
presenter.onToolUse("Bash", { command: "touch unsafe.txt" }, "tool-after-branch-change");
|
|
2629
|
+
await vi.waitFor(() => expect(mocks.agentKill).toHaveBeenCalledTimes(1));
|
|
2630
|
+
expect(socket.outbound.some((item) => item.event === "agent.event" &&
|
|
2631
|
+
item.payload.event?.id ===
|
|
2632
|
+
"tool-after-branch-change")).toBe(false);
|
|
2633
|
+
expect(socket.outbound).toEqual(expect.arrayContaining([
|
|
2634
|
+
{
|
|
2635
|
+
event: "agent.event",
|
|
2636
|
+
payload: expect.objectContaining({
|
|
2637
|
+
conversationId: "conversation-branch-lease",
|
|
2638
|
+
runId: "run-branch-lease",
|
|
2639
|
+
event: expect.objectContaining({
|
|
2640
|
+
type: "application_problem",
|
|
2641
|
+
problem: expect.objectContaining({
|
|
2642
|
+
domain: "application",
|
|
2643
|
+
code: "app.workspace.missing",
|
|
2644
|
+
}),
|
|
2645
|
+
}),
|
|
2646
|
+
}),
|
|
2647
|
+
},
|
|
2648
|
+
]));
|
|
2649
|
+
}
|
|
2650
|
+
finally {
|
|
2651
|
+
await controller.stop();
|
|
2652
|
+
}
|
|
2653
|
+
});
|
|
2654
|
+
it("reuses a durably remembered relocation after daemon restart until backend persistence", async () => {
|
|
2655
|
+
const firstSocket = new TestSocket();
|
|
2656
|
+
firstSocket.acknowledgeWorkspaceRelocations = false;
|
|
2657
|
+
const secondSocket = new TestSocket();
|
|
2658
|
+
secondSocket.acknowledgeWorkspaceRelocations = false;
|
|
2659
|
+
mocks.io.mockReturnValueOnce(firstSocket).mockReturnValueOnce(secondSocket);
|
|
2660
|
+
const binDir = seedCliBinaries(tempDir);
|
|
2661
|
+
process.env.ALAN_CODEX_PATH = join(binDir, "codex");
|
|
2662
|
+
mocks.spawnSync.mockReturnValue({ status: 1, stdout: "", stderr: "" });
|
|
2663
|
+
mocks.agentRun.mockReturnValue(new Promise(() => { }));
|
|
2664
|
+
const missingPath = join(tempDir, "old-project-name");
|
|
2665
|
+
const relocatedPath = join(tempDir, "new-project-name");
|
|
2666
|
+
mkdirSync(relocatedPath, { recursive: true });
|
|
2667
|
+
mocks.discoverRelocatedWorkspace.mockReturnValue(relocatedPath);
|
|
2668
|
+
const { startDaemon } = await import("../daemon.js");
|
|
2669
|
+
const args = [
|
|
2670
|
+
"--runtime-id",
|
|
2671
|
+
"11111111-1111-4111-8111-111111111111",
|
|
2672
|
+
"--token",
|
|
2673
|
+
"alan_runtime_secret",
|
|
2674
|
+
"--ws-url",
|
|
2675
|
+
"ws://ws.test",
|
|
2676
|
+
"--local-api-port",
|
|
2677
|
+
"0",
|
|
2678
|
+
];
|
|
2679
|
+
const firstController = await startDaemon(args);
|
|
2680
|
+
firstSocket.emit("connect");
|
|
2681
|
+
firstSocket.emit("agent.execute", {
|
|
2682
|
+
conversationId: "conversation-relocated-1",
|
|
2683
|
+
runId: "run-relocated-1",
|
|
2684
|
+
content: "Find the moved project",
|
|
2685
|
+
backendKind: "codex",
|
|
2686
|
+
projectPath: missingPath,
|
|
2687
|
+
localRepoUrls: ["https://github.com/alan-ai/alan.git"],
|
|
2688
|
+
});
|
|
2689
|
+
await vi.waitFor(() => expect(firstSocket.outbound).toEqual(expect.arrayContaining([
|
|
2690
|
+
{ event: "agent.accepted", payload: { runId: "run-relocated-1" } },
|
|
2691
|
+
])), { timeout: 5_000 });
|
|
2692
|
+
expect(JSON.parse(readFileSync(configPath, "utf8")).workspaceRelocations).toEqual([
|
|
2693
|
+
expect.objectContaining({ previousPath: missingPath, canonicalPath: relocatedPath }),
|
|
2694
|
+
]);
|
|
2695
|
+
await firstController.stop();
|
|
2696
|
+
mocks.discoverRelocatedWorkspace.mockClear();
|
|
2697
|
+
mocks.workspaceMatchesExpectedRepositories.mockReturnValue(true);
|
|
2698
|
+
const secondController = await startDaemon(args);
|
|
2699
|
+
try {
|
|
2700
|
+
secondSocket.emit("connect");
|
|
2701
|
+
secondSocket.emit("agent.execute", {
|
|
2702
|
+
conversationId: "conversation-relocated-2",
|
|
2703
|
+
runId: "run-relocated-2",
|
|
2704
|
+
content: "Reuse the moved project",
|
|
2705
|
+
backendKind: "codex",
|
|
2706
|
+
projectPath: missingPath,
|
|
2707
|
+
localRepoUrls: ["https://github.com/alan-ai/alan.git"],
|
|
2708
|
+
});
|
|
2709
|
+
await vi.waitFor(() => expect(secondSocket.outbound).toEqual(expect.arrayContaining([
|
|
2710
|
+
{ event: "agent.accepted", payload: { runId: "run-relocated-2" } },
|
|
2711
|
+
])), { timeout: 5_000 });
|
|
2712
|
+
expect(mocks.workspaceMatchesExpectedRepositories).toHaveBeenCalledWith({
|
|
2713
|
+
workspacePath: relocatedPath,
|
|
2714
|
+
expectedRepoUrls: ["https://github.com/alan-ai/alan.git"],
|
|
2715
|
+
});
|
|
2716
|
+
expect(mocks.discoverRelocatedWorkspace).not.toHaveBeenCalled();
|
|
2717
|
+
expect(mocks.agentRun).toHaveBeenLastCalledWith(expect.objectContaining({ cwd: relocatedPath, teamPath: relocatedPath }));
|
|
2718
|
+
}
|
|
2719
|
+
finally {
|
|
2720
|
+
await secondController.stop();
|
|
2721
|
+
}
|
|
2722
|
+
});
|
|
2723
|
+
it("creates and runs inside the managed Alan workspace for a no-Project execution", async () => {
|
|
2724
|
+
const socket = new TestSocket();
|
|
2725
|
+
const infoSpy = vi.spyOn(console, "info").mockImplementation(() => { });
|
|
2726
|
+
mocks.io.mockReturnValue(socket);
|
|
2727
|
+
const binDir = seedCliBinaries(tempDir);
|
|
2728
|
+
process.env.ALAN_CODEX_PATH = join(binDir, "codex");
|
|
2729
|
+
mocks.spawnSync.mockReturnValue({ status: 1, stdout: "", stderr: "" });
|
|
2730
|
+
mocks.agentRun.mockReturnValue(new Promise(() => { }));
|
|
2731
|
+
const { startDaemon } = await import("../daemon.js");
|
|
2732
|
+
const controller = await startDaemon([
|
|
2733
|
+
"--runtime-id",
|
|
2734
|
+
"11111111-1111-4111-8111-111111111111",
|
|
2735
|
+
"--token",
|
|
2736
|
+
"alan_runtime_secret",
|
|
2737
|
+
"--ws-url",
|
|
2738
|
+
"ws://ws.test",
|
|
2739
|
+
"--local-api-port",
|
|
2740
|
+
"0",
|
|
2741
|
+
]);
|
|
2742
|
+
try {
|
|
2743
|
+
socket.emit("connect");
|
|
2744
|
+
await waitForOutbound(socket, "runtime.hello");
|
|
2745
|
+
socket.emit("agent.execute", {
|
|
2746
|
+
conversationId: "conversation-default",
|
|
2747
|
+
runId: "run-default",
|
|
2748
|
+
content: "Work without a project",
|
|
2749
|
+
backendKind: "codex",
|
|
2750
|
+
projectPath: ALAN_DEFAULT_WORKSPACE_URI,
|
|
2751
|
+
});
|
|
2752
|
+
await vi.waitFor(() => expect(socket.outbound).toEqual(expect.arrayContaining([{ event: "agent.accepted", payload: { runId: "run-default" } }])));
|
|
2753
|
+
expect(mocks.agentRun).toHaveBeenCalledWith(expect.objectContaining({
|
|
2754
|
+
cwd: mocks.defaultWorkspacePath,
|
|
2755
|
+
teamPath: mocks.defaultWorkspacePath,
|
|
2756
|
+
}));
|
|
2757
|
+
}
|
|
2758
|
+
finally {
|
|
2759
|
+
infoSpy.mockRestore();
|
|
2760
|
+
await controller.stop();
|
|
2761
|
+
}
|
|
2762
|
+
});
|
|
2763
|
+
it("rejects a definitively signed-out provider before acceptance or spawn", async () => {
|
|
2764
|
+
const socket = new TestSocket();
|
|
2765
|
+
mocks.io.mockReturnValue(socket);
|
|
2766
|
+
const binDir = seedCliBinaries(tempDir);
|
|
2767
|
+
const codexPath = join(binDir, "codex");
|
|
2768
|
+
writeFileSync(codexPath, '#!/bin/sh\nif [ "$1 $2" = "login status" ]; then echo "Not logged in"; exit 1; fi\nexit 0\n');
|
|
2769
|
+
chmodSync(codexPath, 0o755);
|
|
2770
|
+
process.env.ALAN_CODEX_PATH = codexPath;
|
|
2771
|
+
const { startDaemon } = await import("../daemon.js");
|
|
2772
|
+
const controller = await startDaemon([
|
|
2773
|
+
"--runtime-id",
|
|
2774
|
+
"11111111-1111-4111-8111-111111111111",
|
|
2775
|
+
"--token",
|
|
2776
|
+
"alan_runtime_secret",
|
|
2777
|
+
"--ws-url",
|
|
2778
|
+
"ws://ws.test",
|
|
2779
|
+
"--local-api-port",
|
|
2780
|
+
"0",
|
|
2781
|
+
]);
|
|
2782
|
+
socket.emit("connect");
|
|
2783
|
+
const workspacePath = join(tempDir, "workspace");
|
|
2784
|
+
mkdirSync(workspacePath, { recursive: true });
|
|
2785
|
+
try {
|
|
2786
|
+
socket.emit("agent.execute", {
|
|
2787
|
+
conversationId: "conversation-provider-auth",
|
|
2788
|
+
runId: "run-provider-auth",
|
|
2789
|
+
content: "Run with a signed-out provider",
|
|
2790
|
+
backendKind: "codex",
|
|
2791
|
+
projectPath: workspacePath,
|
|
2792
|
+
});
|
|
2793
|
+
const rejected = await waitForOutbound(socket, "agent.rejected");
|
|
2794
|
+
expect(rejected.payload).toEqual(expect.objectContaining({
|
|
2795
|
+
runId: "run-provider-auth",
|
|
2796
|
+
code: "provider_auth_required",
|
|
2797
|
+
message: expect.stringContaining("codex login"),
|
|
2798
|
+
}));
|
|
2799
|
+
expect(socket.outbound).not.toContainEqual({
|
|
2800
|
+
event: "agent.accepted",
|
|
2801
|
+
payload: { runId: "run-provider-auth" },
|
|
2802
|
+
});
|
|
2803
|
+
expect(mocks.agentRun).not.toHaveBeenCalled();
|
|
2804
|
+
}
|
|
2805
|
+
finally {
|
|
2806
|
+
await controller.stop();
|
|
2807
|
+
}
|
|
2808
|
+
});
|
|
2809
|
+
it("registers per-run Alan MCP config before spawning and rejects a missing run contract", async () => {
|
|
2810
|
+
const socket = new TestSocket();
|
|
2811
|
+
mocks.io.mockReturnValue(socket);
|
|
2812
|
+
const binDir = seedCliBinaries(tempDir);
|
|
2813
|
+
process.env.ALAN_CODEX_PATH = join(binDir, "codex");
|
|
2814
|
+
mocks.spawnSync.mockReturnValue({ status: 1, stdout: "", stderr: "" });
|
|
2815
|
+
mocks.agentRun.mockReturnValue(new Promise(() => { }));
|
|
2816
|
+
const { startDaemon } = await import("../daemon.js");
|
|
2817
|
+
const controller = await startDaemon([
|
|
2818
|
+
"--runtime-id",
|
|
2819
|
+
"11111111-1111-4111-8111-111111111111",
|
|
2820
|
+
"--token",
|
|
2821
|
+
"alan_runtime_secret",
|
|
2822
|
+
"--ws-url",
|
|
2823
|
+
"ws://ws.test",
|
|
2824
|
+
"--local-api-port",
|
|
2825
|
+
"0",
|
|
2826
|
+
]);
|
|
2827
|
+
socket.emit("connect");
|
|
2828
|
+
const workspacePath = join(tempDir, "workspace");
|
|
2829
|
+
mkdirSync(workspacePath, { recursive: true });
|
|
2830
|
+
const alanMcp = {
|
|
2831
|
+
url: "https://api.alan.test/mcp",
|
|
2832
|
+
headers: { Authorization: "Bearer alan_pat", "x-conversation-id": "conversation-mcp" },
|
|
2833
|
+
...readinessContract,
|
|
2834
|
+
};
|
|
2835
|
+
try {
|
|
2836
|
+
socket.emit("agent.execute", {
|
|
2837
|
+
conversationId: "conversation-mcp",
|
|
2838
|
+
runId: "run-mcp-1",
|
|
2839
|
+
content: "Run with per-run MCP",
|
|
2840
|
+
backendKind: "codex",
|
|
2841
|
+
projectPath: workspacePath,
|
|
2842
|
+
alanMcp,
|
|
2843
|
+
});
|
|
2844
|
+
await vi.waitFor(() => expect(socket.outbound).toEqual(expect.arrayContaining([{ event: "agent.accepted", payload: { runId: "run-mcp-1" } }])));
|
|
2845
|
+
expect(mocks.ensureAlanMcpRegistered).toHaveBeenCalledWith("codex_app_server", alanMcp, undefined);
|
|
2846
|
+
expect(mocks.verifyAlanMcpRegistered).not.toHaveBeenCalled();
|
|
2847
|
+
// Registration must happen before the CLI is handed the run.
|
|
2848
|
+
expect(mocks.ensureAlanMcpRegistered.mock.invocationCallOrder[0]).toBeLessThan(mocks.agentRun.mock.invocationCallOrder[0]);
|
|
2849
|
+
socket.emit("agent.execute", {
|
|
2850
|
+
conversationId: "conversation-mcp-3",
|
|
2851
|
+
runId: "run-mcp-3",
|
|
2852
|
+
content: "Run without per-run MCP",
|
|
2853
|
+
backendKind: "codex",
|
|
2854
|
+
projectPath: workspacePath,
|
|
2855
|
+
alanMcp: null,
|
|
2856
|
+
});
|
|
2857
|
+
const rejected = await waitForOutbound(socket, "agent.rejected");
|
|
2858
|
+
expect(rejected.payload).toEqual(expect.objectContaining({
|
|
2859
|
+
runId: "run-mcp-3",
|
|
2860
|
+
code: "mcp_config_invalid",
|
|
2861
|
+
message: expect.stringContaining("run-scoped tools contract"),
|
|
2862
|
+
}));
|
|
2863
|
+
expect(socket.outbound).not.toContainEqual({
|
|
2864
|
+
event: "agent.accepted",
|
|
2865
|
+
payload: { runId: "run-mcp-3" },
|
|
2866
|
+
});
|
|
2867
|
+
expect(mocks.verifyAlanMcpRegistered).not.toHaveBeenCalled();
|
|
2868
|
+
expect(mocks.ensureAlanMcpRegistered).toHaveBeenCalledTimes(1);
|
|
2869
|
+
}
|
|
2870
|
+
finally {
|
|
2871
|
+
await controller.stop();
|
|
2872
|
+
}
|
|
2873
|
+
});
|
|
2874
|
+
it("serializes per-run MCP writes only until each provider process starts", async () => {
|
|
2875
|
+
const socket = new TestSocket();
|
|
2876
|
+
mocks.io.mockReturnValue(socket);
|
|
2877
|
+
const binDir = seedCliBinaries(tempDir);
|
|
2878
|
+
process.env.ALAN_CODEX_PATH = join(binDir, "codex");
|
|
2879
|
+
mocks.agentRun.mockReturnValue(new Promise(() => { }));
|
|
2880
|
+
const providerStarts = [];
|
|
2881
|
+
mocks.coreAgentConstructor.mockImplementation(function MockCoreAgent(_presenter, runtime) {
|
|
2882
|
+
return {
|
|
2883
|
+
run: (...args) => {
|
|
2884
|
+
providerStarts.push(() => runtime?.onProviderSpawned?.(20_000 + providerStarts.length));
|
|
2885
|
+
return mocks.agentRun(...args);
|
|
2886
|
+
},
|
|
2887
|
+
kill: mocks.agentKill,
|
|
2888
|
+
sendToolResponse: mocks.sendToolResponse,
|
|
2889
|
+
};
|
|
2890
|
+
});
|
|
2891
|
+
const { startDaemon } = await import("../daemon.js");
|
|
2892
|
+
const controller = await startDaemon([
|
|
2893
|
+
"--runtime-id",
|
|
2894
|
+
"11111111-1111-4111-8111-111111111111",
|
|
2895
|
+
"--token",
|
|
2896
|
+
"alan_runtime_secret",
|
|
2897
|
+
"--ws-url",
|
|
2898
|
+
"ws://ws.test",
|
|
2899
|
+
"--local-api-port",
|
|
2900
|
+
"0",
|
|
2901
|
+
]);
|
|
2902
|
+
socket.emit("connect");
|
|
2903
|
+
const workspacePath = join(tempDir, "workspace");
|
|
2904
|
+
mkdirSync(workspacePath, { recursive: true });
|
|
2905
|
+
try {
|
|
2906
|
+
socket.emit("agent.execute", {
|
|
2907
|
+
conversationId: "conversation-org-a",
|
|
2908
|
+
runId: "run-org-a",
|
|
2909
|
+
content: "Run org-a",
|
|
2910
|
+
backendKind: "codex",
|
|
2911
|
+
projectPath: workspacePath,
|
|
2912
|
+
alanMcp: {
|
|
2913
|
+
url: "https://api.alan.test/mcp",
|
|
2914
|
+
headers: { "x-alan-run-reference": "capability-org-a" },
|
|
2915
|
+
...readinessContract,
|
|
2916
|
+
},
|
|
2917
|
+
});
|
|
2918
|
+
await vi.waitFor(() => expect(providerStarts).toHaveLength(1), { timeout: 5_000 });
|
|
2919
|
+
expect(mocks.ensureAlanMcpRegistered).toHaveBeenCalledTimes(1);
|
|
2920
|
+
expect(mocks.ensureAlanMcpRegistered).toHaveBeenNthCalledWith(1, "codex_app_server", {
|
|
2921
|
+
url: "https://api.alan.test/mcp",
|
|
2922
|
+
headers: { "x-alan-run-reference": "capability-org-a" },
|
|
2923
|
+
...readinessContract,
|
|
2924
|
+
}, undefined);
|
|
2925
|
+
socket.emit("agent.execute", {
|
|
2926
|
+
conversationId: "conversation-org-b",
|
|
2927
|
+
runId: "run-org-b",
|
|
2928
|
+
content: "Run org-b",
|
|
2929
|
+
backendKind: "codex",
|
|
2930
|
+
projectPath: workspacePath,
|
|
2931
|
+
alanMcp: {
|
|
2932
|
+
url: "https://api.alan.test/mcp",
|
|
2933
|
+
headers: { "x-alan-run-reference": "capability-org-b" },
|
|
2934
|
+
...readinessContract,
|
|
2935
|
+
},
|
|
2936
|
+
});
|
|
2937
|
+
// Org B is queued behind the MCP config used by Org A until the first
|
|
2938
|
+
// provider has spawned and inherited that configuration.
|
|
2939
|
+
await Promise.resolve();
|
|
2940
|
+
expect(mocks.ensureAlanMcpRegistered).toHaveBeenCalledTimes(1);
|
|
2941
|
+
providerStarts[0]?.();
|
|
2942
|
+
await vi.waitFor(() => expect(providerStarts).toHaveLength(2), { timeout: 5_000 });
|
|
2943
|
+
expect(mocks.ensureAlanMcpRegistered).toHaveBeenCalledTimes(2);
|
|
2944
|
+
expect(mocks.ensureAlanMcpRegistered).toHaveBeenNthCalledWith(2, "codex_app_server", {
|
|
2945
|
+
url: "https://api.alan.test/mcp",
|
|
2946
|
+
headers: { "x-alan-run-reference": "capability-org-b" },
|
|
2947
|
+
...readinessContract,
|
|
2948
|
+
}, undefined);
|
|
2949
|
+
providerStarts[1]?.();
|
|
2950
|
+
expect(mocks.agentRun).toHaveBeenCalledTimes(2);
|
|
2951
|
+
}
|
|
2952
|
+
finally {
|
|
2953
|
+
await controller.stop();
|
|
2954
|
+
}
|
|
2955
|
+
});
|
|
2956
|
+
it("keeps a delayed gated run duplicate-fenced beyond the inherited-artifact TTL", async () => {
|
|
2957
|
+
let nowMs = 1_000;
|
|
2958
|
+
const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => nowMs);
|
|
2959
|
+
const socket = new TestSocket();
|
|
2960
|
+
mocks.io.mockReturnValue(socket);
|
|
2961
|
+
const binDir = seedCliBinaries(tempDir);
|
|
2962
|
+
process.env.ALAN_CODEX_PATH = join(binDir, "codex");
|
|
2963
|
+
mocks.agentRun.mockReturnValue(new Promise(() => { }));
|
|
2964
|
+
const providerStarts = [];
|
|
2965
|
+
mocks.coreAgentConstructor.mockImplementation(function MockCoreAgent(_presenter, runtime) {
|
|
2966
|
+
return {
|
|
2967
|
+
run: (...args) => {
|
|
2968
|
+
providerStarts.push(() => runtime?.onProviderSpawned?.(30_000 + providerStarts.length));
|
|
2969
|
+
return mocks.agentRun(...args);
|
|
2970
|
+
},
|
|
2971
|
+
kill: mocks.agentKill,
|
|
2972
|
+
sendToolResponse: mocks.sendToolResponse,
|
|
2973
|
+
};
|
|
2974
|
+
});
|
|
2975
|
+
const { startDaemon } = await import("../daemon.js");
|
|
2976
|
+
const controller = await startDaemon([
|
|
2977
|
+
"--runtime-id",
|
|
2978
|
+
"11111111-1111-4111-8111-111111111111",
|
|
2979
|
+
"--token",
|
|
2980
|
+
"alan_runtime_secret",
|
|
2981
|
+
"--ws-url",
|
|
2982
|
+
"ws://ws.test",
|
|
2983
|
+
"--local-api-port",
|
|
2984
|
+
"0",
|
|
2985
|
+
]);
|
|
2986
|
+
socket.emit("connect");
|
|
2987
|
+
const workspacePath = join(tempDir, "workspace");
|
|
2988
|
+
mkdirSync(workspacePath, { recursive: true });
|
|
2989
|
+
const execute = (runId, conversationId) => {
|
|
2990
|
+
socket.emit("agent.execute", {
|
|
2991
|
+
conversationId,
|
|
2992
|
+
runId,
|
|
2993
|
+
content: `Run ${runId}`,
|
|
2994
|
+
backendKind: "codex",
|
|
2995
|
+
projectPath: workspacePath,
|
|
2996
|
+
alanMcp: {
|
|
2997
|
+
url: "https://api.alan.test/mcp",
|
|
2998
|
+
headers: { "x-conversation-id": conversationId },
|
|
2999
|
+
...readinessContract,
|
|
3000
|
+
},
|
|
3001
|
+
});
|
|
3002
|
+
};
|
|
3003
|
+
try {
|
|
3004
|
+
execute("run-gate-owner", "conversation-gate-owner");
|
|
3005
|
+
await vi.waitFor(() => expect(providerStarts).toHaveLength(1), { timeout: 5_000 });
|
|
3006
|
+
execute("run-delayed", "conversation-delayed");
|
|
3007
|
+
const readDelayedFence = () => JSON.parse(readFileSync(`${configPath}.pending-runs.json`, "utf8")).entries.find((entry) => entry.runId === "run-delayed");
|
|
3008
|
+
await vi.waitFor(() => expect(readDelayedFence()?.startedAtMs).toBe(1_000));
|
|
3009
|
+
nowMs += 10 * 60_000 + 1;
|
|
3010
|
+
execute("run-delayed", "conversation-delayed");
|
|
3011
|
+
expect(readDelayedFence()?.startedAtMs).toBe(1_000);
|
|
3012
|
+
providerStarts[0]?.();
|
|
3013
|
+
await vi.waitFor(() => expect(providerStarts).toHaveLength(2), { timeout: 5_000 });
|
|
3014
|
+
providerStarts[1]?.();
|
|
3015
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
3016
|
+
expect(providerStarts).toHaveLength(2);
|
|
3017
|
+
expect(mocks.agentRun).toHaveBeenCalledTimes(2);
|
|
3018
|
+
expect(mocks.ensureAlanMcpRegistered).toHaveBeenCalledTimes(2);
|
|
3019
|
+
}
|
|
3020
|
+
finally {
|
|
3021
|
+
nowSpy.mockRestore();
|
|
3022
|
+
await controller.stop();
|
|
3023
|
+
}
|
|
3024
|
+
});
|
|
3025
|
+
it("rejects local runs when required Alan MCP setup is unavailable", async () => {
|
|
3026
|
+
const socket = new TestSocket();
|
|
3027
|
+
mocks.io.mockReturnValue(socket);
|
|
3028
|
+
const binDir = seedCliBinaries(tempDir);
|
|
3029
|
+
process.env.ALAN_CODEX_PATH = join(binDir, "codex");
|
|
3030
|
+
mocks.spawnSync.mockReturnValue({ status: 1, stdout: "", stderr: "" });
|
|
3031
|
+
mocks.ensureAlanMcpRegistered.mockResolvedValue({
|
|
3032
|
+
ok: false,
|
|
3033
|
+
paths: [],
|
|
3034
|
+
error: "config is read-only",
|
|
3035
|
+
});
|
|
3036
|
+
const { startDaemon } = await import("../daemon.js");
|
|
3037
|
+
const controller = await startDaemon([
|
|
3038
|
+
"--runtime-id",
|
|
3039
|
+
"11111111-1111-4111-8111-111111111111",
|
|
3040
|
+
"--token",
|
|
3041
|
+
"alan_runtime_secret",
|
|
3042
|
+
"--ws-url",
|
|
3043
|
+
"ws://ws.test",
|
|
3044
|
+
"--local-api-port",
|
|
3045
|
+
"0",
|
|
3046
|
+
]);
|
|
3047
|
+
socket.emit("connect");
|
|
3048
|
+
const workspacePath = join(tempDir, "workspace");
|
|
3049
|
+
mkdirSync(workspacePath, { recursive: true });
|
|
3050
|
+
try {
|
|
3051
|
+
socket.emit("agent.execute", {
|
|
3052
|
+
conversationId: "conversation-mcp-failure",
|
|
3053
|
+
runId: "run-mcp-failure",
|
|
3054
|
+
content: "Run without required tools",
|
|
3055
|
+
backendKind: "codex",
|
|
3056
|
+
projectPath: workspacePath,
|
|
3057
|
+
alanMcp: {
|
|
3058
|
+
url: "https://api.alan.test/mcp",
|
|
3059
|
+
headers: { Authorization: "Bearer alan_pat" },
|
|
3060
|
+
...readinessContract,
|
|
3061
|
+
},
|
|
3062
|
+
});
|
|
3063
|
+
await vi.waitFor(() => expect(socket.outbound).toEqual(expect.arrayContaining([
|
|
3064
|
+
{
|
|
3065
|
+
event: "agent.rejected",
|
|
3066
|
+
payload: expect.objectContaining({
|
|
3067
|
+
runId: "run-mcp-failure",
|
|
3068
|
+
code: "mcp_config_invalid",
|
|
3069
|
+
}),
|
|
3070
|
+
},
|
|
3071
|
+
])));
|
|
3072
|
+
expect(socket.outbound).not.toContainEqual({
|
|
3073
|
+
event: "agent.accepted",
|
|
3074
|
+
payload: { runId: "run-mcp-failure" },
|
|
3075
|
+
});
|
|
3076
|
+
expect(mocks.agentRun).not.toHaveBeenCalled();
|
|
3077
|
+
}
|
|
3078
|
+
finally {
|
|
3079
|
+
await controller.stop();
|
|
3080
|
+
}
|
|
3081
|
+
});
|
|
3082
|
+
it("local daemon API reports status, protects endpoints, streams logs, and stops active runs", async () => {
|
|
3083
|
+
const socket = new TestSocket();
|
|
3084
|
+
mocks.io.mockReturnValue(socket);
|
|
3085
|
+
mocks.spawnSync.mockReturnValue({ status: 1, stdout: "", stderr: "" });
|
|
3086
|
+
mocks.agentRun.mockReturnValue(new Promise(() => { }));
|
|
3087
|
+
const { startDaemon } = await import("../daemon.js");
|
|
3088
|
+
const controller = await startDaemon([
|
|
3089
|
+
"--runtime-id",
|
|
3090
|
+
"11111111-1111-4111-8111-111111111111",
|
|
3091
|
+
"--token",
|
|
3092
|
+
"alan_runtime_secret",
|
|
3093
|
+
"--ws-url",
|
|
3094
|
+
"ws://ws.test",
|
|
3095
|
+
"--local-api-port",
|
|
3096
|
+
"0",
|
|
3097
|
+
]);
|
|
3098
|
+
try {
|
|
3099
|
+
const baseUrl = `http://127.0.0.1:${controller.localApiPort}`;
|
|
3100
|
+
const unauthorized = await fetch(`${baseUrl}/status`);
|
|
3101
|
+
expect(unauthorized.status).toBe(401);
|
|
3102
|
+
const missing = await fetch(`${baseUrl}/missing`, {
|
|
3103
|
+
headers: { authorization: `Bearer ${controller.localApiToken}` },
|
|
3104
|
+
});
|
|
3105
|
+
expect(missing.status).toBe(404);
|
|
3106
|
+
socket.emit("connect");
|
|
3107
|
+
socket.emit("agent.execute", {
|
|
3108
|
+
conversationId: "conversation-1",
|
|
3109
|
+
runId: "run-local-api",
|
|
3110
|
+
content: "Keep running",
|
|
3111
|
+
});
|
|
3112
|
+
await vi.waitFor(() => expect(socket.outbound).toEqual(expect.arrayContaining([
|
|
3113
|
+
{ event: "agent.accepted", payload: { runId: "run-local-api" } },
|
|
3114
|
+
])), { timeout: 5_000 });
|
|
3115
|
+
const headers = { authorization: `Bearer ${controller.localApiToken}` };
|
|
3116
|
+
const status = await fetch(`${baseUrl}/status`, { headers }).then((res) => res.json());
|
|
3117
|
+
expect(status).toMatchObject({
|
|
3118
|
+
configured: true,
|
|
3119
|
+
runtimeId: "11111111-1111-4111-8111-111111111111",
|
|
3120
|
+
connected: true,
|
|
3121
|
+
activeRunIds: ["run-local-api"],
|
|
3122
|
+
activeRunCount: 1,
|
|
3123
|
+
// Concurrency is unbounded: an active run does not stop the computer
|
|
3124
|
+
// from accepting more runs.
|
|
3125
|
+
acceptingRuns: true,
|
|
3126
|
+
recovering: false,
|
|
3127
|
+
restartRecommended: false,
|
|
3128
|
+
staleRunCount: 0,
|
|
3129
|
+
});
|
|
3130
|
+
expect(typeof status.lastRunActivityAtMs).toBe("number");
|
|
3131
|
+
expect(status.disconnectedSinceMs).toBeNull();
|
|
3132
|
+
const capabilities = await fetch(`${baseUrl}/capabilities`, { headers }).then((res) => res.json());
|
|
3133
|
+
expect(capabilities).toMatchObject({ hasTerminal: true, supportsFilesystem: true });
|
|
3134
|
+
const logs = await fetch(`${baseUrl}/logs`, { headers }).then((res) => res.json());
|
|
3135
|
+
expect(logs.lines.some((line) => line.includes("accepted run=run-local-api"))).toBe(true);
|
|
3136
|
+
const stopped = await fetch(`${baseUrl}/stop`, { method: "POST", headers }).then((res) => res.json());
|
|
3137
|
+
expect(stopped).toEqual({ ok: true });
|
|
3138
|
+
expect(mocks.agentKill).toHaveBeenCalled();
|
|
3139
|
+
const afterStop = await fetch(`${baseUrl}/status`, { headers }).then((res) => res.json());
|
|
3140
|
+
expect(afterStop.activeRunIds).toEqual([]);
|
|
3141
|
+
expect(afterStop.activeRunCount).toBe(0);
|
|
3142
|
+
expect(afterStop.acceptingRuns).toBe(true);
|
|
3143
|
+
}
|
|
3144
|
+
finally {
|
|
3145
|
+
await controller.stop();
|
|
3146
|
+
}
|
|
3147
|
+
});
|
|
3148
|
+
it("clears active local API runs when a continuation-mode provider reports query complete", async () => {
|
|
3149
|
+
const socket = new TestSocket();
|
|
3150
|
+
mocks.io.mockReturnValue(socket);
|
|
3151
|
+
mocks.spawnSync.mockReturnValue({ status: 1, stdout: "", stderr: "" });
|
|
3152
|
+
mocks.agentRun.mockReturnValue(new Promise(() => { }));
|
|
3153
|
+
const { startDaemon } = await import("../daemon.js");
|
|
3154
|
+
const controller = await startDaemon([
|
|
3155
|
+
"--runtime-id",
|
|
3156
|
+
"11111111-1111-4111-8111-111111111111",
|
|
3157
|
+
"--token",
|
|
3158
|
+
"alan_runtime_secret",
|
|
3159
|
+
"--ws-url",
|
|
3160
|
+
"ws://ws.test",
|
|
3161
|
+
"--local-api-port",
|
|
3162
|
+
"0",
|
|
3163
|
+
]);
|
|
3164
|
+
try {
|
|
3165
|
+
const baseUrl = `http://127.0.0.1:${controller.localApiPort}`;
|
|
3166
|
+
const headers = { authorization: `Bearer ${controller.localApiToken}` };
|
|
3167
|
+
socket.emit("connect");
|
|
3168
|
+
socket.emit("agent.execute", {
|
|
3169
|
+
conversationId: "conversation-1",
|
|
3170
|
+
runId: "run-continuation",
|
|
3171
|
+
content: "Keep provider process open",
|
|
3172
|
+
});
|
|
3173
|
+
await vi.waitFor(() => expect(socket.outbound).toEqual(expect.arrayContaining([
|
|
3174
|
+
{ event: "agent.accepted", payload: { runId: "run-continuation" } },
|
|
3175
|
+
])), { timeout: 5_000 });
|
|
3176
|
+
const activeStatus = await fetch(`${baseUrl}/status`, { headers }).then((res) => res.json());
|
|
3177
|
+
expect(activeStatus).toMatchObject({
|
|
3178
|
+
activeRunIds: ["run-continuation"],
|
|
3179
|
+
activeRunCount: 1,
|
|
3180
|
+
acceptingRuns: true,
|
|
3181
|
+
});
|
|
3182
|
+
const presenter = mocks.coreAgentConstructor.mock.calls.at(-1)?.[0];
|
|
3183
|
+
presenter.onComplete({ success: true, summary: "done" });
|
|
3184
|
+
const releasedStatus = await fetch(`${baseUrl}/status`, { headers }).then((res) => res.json());
|
|
3185
|
+
expect(releasedStatus).toMatchObject({
|
|
3186
|
+
activeRunIds: [],
|
|
3187
|
+
activeRunCount: 0,
|
|
3188
|
+
acceptingRuns: true,
|
|
3189
|
+
});
|
|
3190
|
+
}
|
|
3191
|
+
finally {
|
|
3192
|
+
await controller.stop();
|
|
3193
|
+
}
|
|
3194
|
+
});
|
|
3195
|
+
it("clears stale active runs while disconnected and recommends restart after grace", async () => {
|
|
3196
|
+
const socket = new TestSocket();
|
|
3197
|
+
mocks.io.mockReturnValue(socket);
|
|
3198
|
+
mocks.spawnSync.mockReturnValue({ status: 1, stdout: "", stderr: "" });
|
|
3199
|
+
mocks.agentRun.mockReturnValue(new Promise(() => { }));
|
|
3200
|
+
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_000_000);
|
|
3201
|
+
const { startDaemon } = await import("../daemon.js");
|
|
3202
|
+
const controller = await startDaemon([
|
|
3203
|
+
"--runtime-id",
|
|
3204
|
+
"11111111-1111-4111-8111-111111111111",
|
|
3205
|
+
"--token",
|
|
3206
|
+
"alan_runtime_secret",
|
|
3207
|
+
"--ws-url",
|
|
3208
|
+
"ws://ws.test",
|
|
3209
|
+
"--local-api-port",
|
|
3210
|
+
"0",
|
|
3211
|
+
]);
|
|
3212
|
+
try {
|
|
3213
|
+
const baseUrl = `http://127.0.0.1:${controller.localApiPort}`;
|
|
3214
|
+
const headers = { authorization: `Bearer ${controller.localApiToken}` };
|
|
3215
|
+
socket.emit("connect");
|
|
3216
|
+
socket.emit("agent.execute", {
|
|
3217
|
+
conversationId: "conversation-1",
|
|
3218
|
+
runId: "run-stale",
|
|
3219
|
+
content: "Keep running",
|
|
3220
|
+
});
|
|
3221
|
+
await vi.waitFor(() => expect(socket.outbound).toEqual(expect.arrayContaining([{ event: "agent.accepted", payload: { runId: "run-stale" } }])), { timeout: 5_000 });
|
|
3222
|
+
socket.connected = false;
|
|
3223
|
+
socket.emit("disconnect", "transport close");
|
|
3224
|
+
nowSpy.mockReturnValue(1_000_000 + 121_000);
|
|
3225
|
+
const status = await fetch(`${baseUrl}/status`, { headers }).then((res) => res.json());
|
|
3226
|
+
expect(mocks.agentKill).toHaveBeenCalled();
|
|
3227
|
+
expect(status).toMatchObject({
|
|
3228
|
+
connected: false,
|
|
3229
|
+
activeRunIds: [],
|
|
3230
|
+
activeRunCount: 0,
|
|
3231
|
+
acceptingRuns: false,
|
|
3232
|
+
recovering: false,
|
|
3233
|
+
restartRecommended: true,
|
|
3234
|
+
});
|
|
3235
|
+
}
|
|
3236
|
+
finally {
|
|
3237
|
+
nowSpy.mockRestore();
|
|
3238
|
+
await controller.stop();
|
|
3239
|
+
}
|
|
3240
|
+
});
|
|
3241
|
+
it("clears stale active runs while connected after grace", async () => {
|
|
3242
|
+
const socket = new TestSocket();
|
|
3243
|
+
mocks.io.mockReturnValue(socket);
|
|
3244
|
+
mocks.spawnSync.mockReturnValue({ status: 1, stdout: "", stderr: "" });
|
|
3245
|
+
mocks.agentRun.mockReturnValue(new Promise(() => { }));
|
|
3246
|
+
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_000_000);
|
|
3247
|
+
const { startDaemon } = await import("../daemon.js");
|
|
3248
|
+
const controller = await startDaemon([
|
|
3249
|
+
"--runtime-id",
|
|
3250
|
+
"11111111-1111-4111-8111-111111111111",
|
|
3251
|
+
"--token",
|
|
3252
|
+
"alan_runtime_secret",
|
|
3253
|
+
"--ws-url",
|
|
3254
|
+
"ws://ws.test",
|
|
3255
|
+
"--local-api-port",
|
|
3256
|
+
"0",
|
|
3257
|
+
]);
|
|
3258
|
+
try {
|
|
3259
|
+
const baseUrl = `http://127.0.0.1:${controller.localApiPort}`;
|
|
3260
|
+
const headers = { authorization: `Bearer ${controller.localApiToken}` };
|
|
3261
|
+
socket.emit("connect");
|
|
3262
|
+
socket.emit("agent.execute", {
|
|
3263
|
+
conversationId: "conversation-1",
|
|
3264
|
+
runId: "run-stale-connected",
|
|
3265
|
+
content: "Keep running",
|
|
3266
|
+
});
|
|
3267
|
+
await vi.waitFor(() => expect(socket.outbound).toEqual(expect.arrayContaining([
|
|
3268
|
+
{ event: "agent.accepted", payload: { runId: "run-stale-connected" } },
|
|
3269
|
+
])), { timeout: 5_000 });
|
|
3270
|
+
nowSpy.mockReturnValue(1_000_000 + 121_000);
|
|
3271
|
+
const status = await fetch(`${baseUrl}/status`, { headers }).then((res) => res.json());
|
|
3272
|
+
expect(mocks.agentKill).toHaveBeenCalled();
|
|
3273
|
+
expect(status).toMatchObject({
|
|
3274
|
+
connected: true,
|
|
3275
|
+
activeRunIds: [],
|
|
3276
|
+
activeRunCount: 0,
|
|
3277
|
+
acceptingRuns: true,
|
|
3278
|
+
recovering: false,
|
|
3279
|
+
restartRecommended: false,
|
|
3280
|
+
staleRunCount: 0,
|
|
3281
|
+
});
|
|
3282
|
+
}
|
|
3283
|
+
finally {
|
|
3284
|
+
nowSpy.mockRestore();
|
|
3285
|
+
await controller.stop();
|
|
3286
|
+
}
|
|
3287
|
+
});
|
|
3288
|
+
it("replays the real terminal event for a run that completes while disconnected, instead of a generic delivery_lost session_error", async () => {
|
|
3289
|
+
const socket = new TestSocket();
|
|
3290
|
+
mocks.io.mockReturnValue(socket);
|
|
3291
|
+
mocks.spawnSync.mockReturnValue({ status: 1, stdout: "", stderr: "" });
|
|
3292
|
+
let resolveRun;
|
|
3293
|
+
mocks.agentRun.mockReturnValue(new Promise((resolve) => {
|
|
3294
|
+
resolveRun = () => resolve();
|
|
3295
|
+
}));
|
|
3296
|
+
const { startDaemon } = await import("../daemon.js");
|
|
3297
|
+
const controller = await startDaemon([
|
|
3298
|
+
"--runtime-id",
|
|
3299
|
+
"11111111-1111-4111-8111-111111111111",
|
|
3300
|
+
"--token",
|
|
3301
|
+
"alan_runtime_secret",
|
|
3302
|
+
"--ws-url",
|
|
3303
|
+
"ws://ws.test",
|
|
3304
|
+
"--local-api-port",
|
|
3305
|
+
"0",
|
|
3306
|
+
]);
|
|
3307
|
+
try {
|
|
3308
|
+
socket.emit("connect");
|
|
3309
|
+
socket.emit("agent.execute", {
|
|
3310
|
+
conversationId: "conversation-1",
|
|
3311
|
+
runId: "run-completes-offline",
|
|
3312
|
+
content: "Finish while offline",
|
|
3313
|
+
});
|
|
3314
|
+
await vi.waitFor(() => expect(socket.outbound).toEqual(expect.arrayContaining([
|
|
3315
|
+
{ event: "agent.accepted", payload: { runId: "run-completes-offline" } },
|
|
3316
|
+
])), { timeout: 5_000 });
|
|
3317
|
+
const presenter = mocks.coreAgentConstructor.mock.calls.at(-1)?.[0];
|
|
3318
|
+
// Socket drops mid-run.
|
|
3319
|
+
socket.connected = false;
|
|
3320
|
+
socket.emit("disconnect", "transport close");
|
|
3321
|
+
socket.outbound.length = 0;
|
|
3322
|
+
// The CLI keeps working locally and actually finishes while the socket is down — this is
|
|
3323
|
+
// the real event the fix must preserve instead of discarding it for a generic error.
|
|
3324
|
+
presenter.onAssistantText("final answer produced while offline");
|
|
3325
|
+
presenter.onComplete({ success: true, summary: "done offline" });
|
|
3326
|
+
resolveRun?.();
|
|
3327
|
+
await Promise.resolve();
|
|
3328
|
+
await Promise.resolve();
|
|
3329
|
+
// While still disconnected, no agent.event for this run should have gone out over the
|
|
3330
|
+
// (dead) socket — the real events must be buffered, not fired into the void.
|
|
3331
|
+
expect(socket.outbound.filter((item) => item.event === "agent.event" &&
|
|
3332
|
+
item.payload.runId === "run-completes-offline")).toHaveLength(0);
|
|
3333
|
+
// Reconnect.
|
|
3334
|
+
socket.connected = true;
|
|
3335
|
+
socket.emit("connect");
|
|
3336
|
+
await vi.waitFor(() => {
|
|
3337
|
+
const replayed = socket.outbound.filter((item) => item.event === "agent.event" &&
|
|
3338
|
+
item.payload.runId === "run-completes-offline");
|
|
3339
|
+
expect(replayed).toHaveLength(3);
|
|
3340
|
+
});
|
|
3341
|
+
const events = socket.outbound.filter((item) => item.event === "agent.event");
|
|
3342
|
+
const eventsForRun = events.filter((item) => item.payload.runId === "run-completes-offline");
|
|
3343
|
+
// The real buffered events replay in order...
|
|
3344
|
+
expect(eventsForRun.map((item) => item.payload.event.type)).toEqual([
|
|
3345
|
+
"text",
|
|
3346
|
+
"agent_run_finished",
|
|
3347
|
+
"query_complete",
|
|
3348
|
+
]);
|
|
3349
|
+
// ...and no generic delivery_lost session_error is sent for this run, since the daemon
|
|
3350
|
+
// already knows — and told the server — how it actually ended.
|
|
3351
|
+
expect(eventsForRun.some((item) => item.payload.event.type === "session_error" &&
|
|
3352
|
+
item.payload.event.error?.includes("delivery was lost"))).toBe(false);
|
|
3353
|
+
}
|
|
3354
|
+
finally {
|
|
3355
|
+
await controller.stop();
|
|
3356
|
+
}
|
|
3357
|
+
});
|
|
3358
|
+
it("does NOT emit delivery_lost for a run still executing in the daemon after reconnect (issue #3)", async () => {
|
|
3359
|
+
const socket = new TestSocket();
|
|
3360
|
+
mocks.io.mockReturnValue(socket);
|
|
3361
|
+
mocks.spawnSync.mockReturnValue({ status: 1, stdout: "", stderr: "" });
|
|
3362
|
+
let resolveRun;
|
|
3363
|
+
mocks.agentRun.mockReturnValue(new Promise((resolve) => {
|
|
3364
|
+
resolveRun = () => resolve();
|
|
3365
|
+
}));
|
|
3366
|
+
const { startDaemon } = await import("../daemon.js");
|
|
3367
|
+
const controller = await startDaemon([
|
|
3368
|
+
"--runtime-id",
|
|
3369
|
+
"11111111-1111-4111-8111-111111111111",
|
|
3370
|
+
"--token",
|
|
3371
|
+
"alan_runtime_secret",
|
|
3372
|
+
"--ws-url",
|
|
3373
|
+
"ws://ws.test",
|
|
3374
|
+
"--local-api-port",
|
|
3375
|
+
"0",
|
|
3376
|
+
]);
|
|
3377
|
+
try {
|
|
3378
|
+
socket.emit("connect");
|
|
3379
|
+
socket.emit("agent.execute", {
|
|
3380
|
+
conversationId: "conversation-1",
|
|
3381
|
+
runId: "run-still-in-flight",
|
|
3382
|
+
content: "Quiet build still running",
|
|
3383
|
+
});
|
|
3384
|
+
await vi.waitFor(() => expect(socket.outbound).toEqual(expect.arrayContaining([
|
|
3385
|
+
{ event: "agent.accepted", payload: { runId: "run-still-in-flight" } },
|
|
3386
|
+
])), { timeout: 5_000 });
|
|
3387
|
+
const presenter = mocks.coreAgentConstructor.mock.calls.at(-1)?.[0];
|
|
3388
|
+
// Socket drops during a quiet stretch; the run keeps executing locally.
|
|
3389
|
+
socket.connected = false;
|
|
3390
|
+
socket.emit("disconnect", "transport close");
|
|
3391
|
+
socket.outbound.length = 0;
|
|
3392
|
+
// Reconnect while the run is still in-flight (never resolved). The daemon
|
|
3393
|
+
// still holds it in activeAgents, so a synthetic delivery_lost here would
|
|
3394
|
+
// fail a live run and wipe the API accumulator mid-turn — it must not fire.
|
|
3395
|
+
socket.connected = true;
|
|
3396
|
+
socket.emit("connect");
|
|
3397
|
+
await Promise.resolve();
|
|
3398
|
+
await Promise.resolve();
|
|
3399
|
+
const deliveryLost = socket.outbound.filter((item) => item.event === "agent.event" &&
|
|
3400
|
+
item.payload.runId === "run-still-in-flight" &&
|
|
3401
|
+
item.payload.event?.type ===
|
|
3402
|
+
"session_error");
|
|
3403
|
+
expect(deliveryLost).toHaveLength(0);
|
|
3404
|
+
// The rest of the turn keeps streaming live and IS relayed (not discarded).
|
|
3405
|
+
presenter.onAssistantText("output after reconnect");
|
|
3406
|
+
resolveRun?.();
|
|
3407
|
+
await vi.waitFor(() => expect(socket.outbound.some((item) => item.event === "agent.event" &&
|
|
3408
|
+
item.payload.runId === "run-still-in-flight" &&
|
|
3409
|
+
item.payload.event?.type === "text")).toBe(true));
|
|
3410
|
+
}
|
|
3411
|
+
finally {
|
|
3412
|
+
await controller.stop();
|
|
3413
|
+
}
|
|
3414
|
+
});
|
|
3415
|
+
describe("flushPendingDeliveryLostEvents (issue #3)", () => {
|
|
3416
|
+
function makeHarness() {
|
|
3417
|
+
const enqueued = [];
|
|
3418
|
+
const terminalRunIds = new Set();
|
|
3419
|
+
const eventOutbox = {
|
|
3420
|
+
flush: vi.fn(),
|
|
3421
|
+
hasTerminalEvent: (runId) => terminalRunIds.has(runId),
|
|
3422
|
+
enqueue: (conversationId, runId, event) => enqueued.push({ conversationId, runId, event }),
|
|
3423
|
+
};
|
|
3424
|
+
const socket = { connected: true };
|
|
3425
|
+
return { enqueued, terminalRunIds, eventOutbox, socket, pushLog: vi.fn() };
|
|
3426
|
+
}
|
|
3427
|
+
it("skips runs still active in the daemon and restores their stage", async () => {
|
|
3428
|
+
const { flushPendingDeliveryLostEvents } = await import("../daemon.js");
|
|
3429
|
+
const h = makeHarness();
|
|
3430
|
+
const entry = { stage: "delivery_lost" };
|
|
3431
|
+
const activeAgents = new Map([["run-alive", entry]]);
|
|
3432
|
+
const pending = [{ conversationId: "conv-1", runId: "run-alive" }];
|
|
3433
|
+
flushPendingDeliveryLostEvents({
|
|
3434
|
+
socket: h.socket,
|
|
3435
|
+
activeAgents,
|
|
3436
|
+
pending,
|
|
3437
|
+
eventOutbox: h.eventOutbox,
|
|
3438
|
+
pushLog: h.pushLog,
|
|
3439
|
+
});
|
|
3440
|
+
expect(h.enqueued).toHaveLength(0);
|
|
3441
|
+
// Stage restored so a later disconnect re-tracks the still-live run.
|
|
3442
|
+
expect(entry.stage).toBe("running");
|
|
3443
|
+
expect(pending).toHaveLength(0);
|
|
3444
|
+
});
|
|
3445
|
+
it("emits delivery_lost only for a run the daemon no longer has active and has no terminal", async () => {
|
|
3446
|
+
const { flushPendingDeliveryLostEvents } = await import("../daemon.js");
|
|
3447
|
+
const h = makeHarness();
|
|
3448
|
+
const activeAgents = new Map();
|
|
3449
|
+
const pending = [{ conversationId: "conv-2", runId: "run-gone" }];
|
|
3450
|
+
flushPendingDeliveryLostEvents({
|
|
3451
|
+
socket: h.socket,
|
|
3452
|
+
activeAgents,
|
|
3453
|
+
pending,
|
|
3454
|
+
eventOutbox: h.eventOutbox,
|
|
3455
|
+
pushLog: h.pushLog,
|
|
3456
|
+
});
|
|
3457
|
+
expect(h.enqueued.map((entry) => entry.event.type)).toEqual([
|
|
3458
|
+
"application_problem",
|
|
3459
|
+
"agent_run_finished",
|
|
3460
|
+
"query_complete",
|
|
3461
|
+
]);
|
|
3462
|
+
expect(h.enqueued[0]?.event).toMatchObject({
|
|
3463
|
+
type: "application_problem",
|
|
3464
|
+
problem: {
|
|
3465
|
+
domain: "application",
|
|
3466
|
+
code: "app.transport.disconnected",
|
|
3467
|
+
},
|
|
3468
|
+
});
|
|
3469
|
+
expect(h.enqueued[1]?.event).toMatchObject({
|
|
3470
|
+
type: "agent_run_finished",
|
|
3471
|
+
failureDomain: "application",
|
|
3472
|
+
causeProblemId: expect.any(String),
|
|
3473
|
+
});
|
|
3474
|
+
expect((h.enqueued[1]?.event).causeProblemId).toBe((h.enqueued[0]?.event).problem.id);
|
|
3475
|
+
});
|
|
3476
|
+
it("skips a run whose real terminal event was buffered", async () => {
|
|
3477
|
+
const { flushPendingDeliveryLostEvents } = await import("../daemon.js");
|
|
3478
|
+
const h = makeHarness();
|
|
3479
|
+
h.terminalRunIds.add("run-finished");
|
|
3480
|
+
const activeAgents = new Map();
|
|
3481
|
+
const pending = [{ conversationId: "conv-3", runId: "run-finished" }];
|
|
3482
|
+
flushPendingDeliveryLostEvents({
|
|
3483
|
+
socket: h.socket,
|
|
3484
|
+
activeAgents,
|
|
3485
|
+
pending,
|
|
3486
|
+
eventOutbox: h.eventOutbox,
|
|
3487
|
+
pushLog: h.pushLog,
|
|
3488
|
+
});
|
|
3489
|
+
expect(h.enqueued).toHaveLength(0);
|
|
3490
|
+
});
|
|
3491
|
+
});
|
|
3492
|
+
it("POST /shutdown stops the daemon and notifies whenExternallyStopped", async () => {
|
|
3493
|
+
const socket = new TestSocket();
|
|
3494
|
+
mocks.io.mockReturnValue(socket);
|
|
3495
|
+
mocks.spawnSync.mockReturnValue({ status: 1, stdout: "", stderr: "" });
|
|
3496
|
+
const { startDaemon } = await import("../daemon.js");
|
|
3497
|
+
const controller = await startDaemon([
|
|
3498
|
+
"--runtime-id",
|
|
3499
|
+
"11111111-1111-4111-8111-111111111111",
|
|
3500
|
+
"--token",
|
|
3501
|
+
"alan_runtime_secret",
|
|
3502
|
+
"--ws-url",
|
|
3503
|
+
"ws://ws.test",
|
|
3504
|
+
"--local-api-port",
|
|
3505
|
+
"0",
|
|
3506
|
+
]);
|
|
3507
|
+
const onStopped = vi.fn();
|
|
3508
|
+
controller.whenExternallyStopped = onStopped;
|
|
3509
|
+
const baseUrl = `http://127.0.0.1:${controller.localApiPort}`;
|
|
3510
|
+
const headers = { authorization: `Bearer ${controller.localApiToken}` };
|
|
3511
|
+
const shutdown = await fetch(`${baseUrl}/shutdown`, { method: "POST", headers }).then((res) => res.json());
|
|
3512
|
+
expect(shutdown).toEqual({ ok: true });
|
|
3513
|
+
await vi.waitFor(() => expect(onStopped).toHaveBeenCalled());
|
|
3514
|
+
expect(socket.disconnected).toBe(true);
|
|
3515
|
+
});
|
|
3516
|
+
it("stopDaemon calls POST /shutdown on a running local daemon", async () => {
|
|
3517
|
+
const socket = new TestSocket();
|
|
3518
|
+
mocks.io.mockReturnValue(socket);
|
|
3519
|
+
mocks.spawnSync.mockReturnValue({ status: 1, stdout: "", stderr: "" });
|
|
3520
|
+
const { startDaemon, stopDaemon } = await import("../daemon.js");
|
|
3521
|
+
const controller = await startDaemon([
|
|
3522
|
+
"--runtime-id",
|
|
3523
|
+
"11111111-1111-4111-8111-111111111111",
|
|
3524
|
+
"--token",
|
|
3525
|
+
"alan_runtime_secret",
|
|
3526
|
+
"--ws-url",
|
|
3527
|
+
"ws://ws.test",
|
|
3528
|
+
"--local-api-port",
|
|
3529
|
+
"0",
|
|
3530
|
+
]);
|
|
3531
|
+
writeFileSync(configPath, JSON.stringify({
|
|
3532
|
+
runtimeId: controller.runtimeId,
|
|
3533
|
+
runtimeToken: "alan_runtime_secret",
|
|
3534
|
+
wsUrl: "ws://ws.test",
|
|
3535
|
+
localApiPort: controller.localApiPort,
|
|
3536
|
+
localApiToken: controller.localApiToken,
|
|
3537
|
+
}));
|
|
3538
|
+
const info = vi.spyOn(console, "info").mockImplementation(() => { });
|
|
3539
|
+
await stopDaemon([]);
|
|
3540
|
+
expect(info).toHaveBeenCalledWith("[alan-agent] Daemon stopped", expect.objectContaining({
|
|
3541
|
+
port: controller.localApiPort,
|
|
3542
|
+
}));
|
|
3543
|
+
await vi.waitFor(() => expect(socket.disconnected).toBe(true));
|
|
3544
|
+
});
|
|
3545
|
+
it("stopDaemon reports when no daemon is listening", async () => {
|
|
3546
|
+
const freePort = await new Promise((resolve, reject) => {
|
|
3547
|
+
const server = createServer();
|
|
3548
|
+
server.listen(0, "127.0.0.1", () => {
|
|
3549
|
+
const address = server.address();
|
|
3550
|
+
const port = typeof address === "object" && address ? address.port : 0;
|
|
3551
|
+
server.close((error) => (error ? reject(error) : resolve(port)));
|
|
3552
|
+
});
|
|
3553
|
+
});
|
|
3554
|
+
writeFileSync(configPath, JSON.stringify({
|
|
3555
|
+
runtimeId: "11111111-1111-4111-8111-111111111111",
|
|
3556
|
+
runtimeToken: "alan_runtime_secret",
|
|
3557
|
+
wsUrl: "ws://ws.test",
|
|
3558
|
+
localApiPort: freePort,
|
|
3559
|
+
localApiToken: "local-secret",
|
|
3560
|
+
}));
|
|
3561
|
+
const info = vi.spyOn(console, "info").mockImplementation(() => { });
|
|
3562
|
+
const { stopDaemon } = await import("../daemon.js");
|
|
3563
|
+
await stopDaemon(["--local-api-port", String(freePort)]);
|
|
3564
|
+
expect(info).toHaveBeenCalledWith("[alan-agent] No daemon listening", { port: freePort });
|
|
3565
|
+
});
|
|
3566
|
+
it("forwards browser tool responses to the active CoreAgent", async () => {
|
|
3567
|
+
const socket = new TestSocket();
|
|
3568
|
+
mocks.io.mockReturnValue(socket);
|
|
3569
|
+
mocks.spawnSync.mockReturnValue({ status: 1, stdout: "", stderr: "" });
|
|
3570
|
+
mocks.agentRun.mockReturnValue(new Promise(() => { }));
|
|
3571
|
+
const { startDaemon } = await import("../daemon.js");
|
|
3572
|
+
const controller = await startDaemon([
|
|
3573
|
+
"--runtime-id",
|
|
3574
|
+
"11111111-1111-4111-8111-111111111111",
|
|
3575
|
+
"--token",
|
|
3576
|
+
"alan_runtime_secret",
|
|
3577
|
+
"--ws-url",
|
|
3578
|
+
"ws://ws.test",
|
|
3579
|
+
"--local-api-port",
|
|
3580
|
+
"0",
|
|
3581
|
+
]);
|
|
3582
|
+
try {
|
|
3583
|
+
socket.emit("agent.execute", {
|
|
3584
|
+
conversationId: "conversation-1",
|
|
3585
|
+
runId: "run-interactive",
|
|
3586
|
+
content: "Wait for user input",
|
|
3587
|
+
});
|
|
3588
|
+
await vi.waitFor(() => expect(socket.outbound).toEqual(expect.arrayContaining([
|
|
3589
|
+
{ event: "agent.accepted", payload: { runId: "run-interactive" } },
|
|
3590
|
+
])), { timeout: 5_000 });
|
|
3591
|
+
socket.emit("agent.tool_response", {
|
|
3592
|
+
runId: "run-interactive",
|
|
3593
|
+
toolId: "tool-1",
|
|
3594
|
+
response: "Approved",
|
|
3595
|
+
});
|
|
3596
|
+
expect(mocks.sendToolResponse).toHaveBeenCalledWith("tool-1", "Approved");
|
|
3597
|
+
socket.emit("agent.tool_response", {
|
|
3598
|
+
runId: "missing-run",
|
|
3599
|
+
toolId: "missing-tool",
|
|
3600
|
+
response: "Ignored",
|
|
3601
|
+
});
|
|
3602
|
+
const logs = await fetch(`http://127.0.0.1:${controller.localApiPort}/logs`, {
|
|
3603
|
+
headers: { authorization: `Bearer ${controller.localApiToken}` },
|
|
3604
|
+
}).then((res) => res.json());
|
|
3605
|
+
expect(logs.lines.some((line) => line.includes("tool_response missed tool=missing-tool"))).toBe(true);
|
|
3606
|
+
// A missed delivery must surface to the conversation as an error event,
|
|
3607
|
+
// not just a daemon log line — otherwise the run hangs silently until a
|
|
3608
|
+
// timeout while the user believes their answer was received.
|
|
3609
|
+
const missedErrorEvent = socket.outbound.find((entry) => entry.event === "agent.event" &&
|
|
3610
|
+
entry.payload?.event?.type ===
|
|
3611
|
+
"error" &&
|
|
3612
|
+
entry.payload?.event?.message?.includes("could not be delivered"));
|
|
3613
|
+
expect(missedErrorEvent).toBeDefined();
|
|
3614
|
+
expect(missedErrorEvent?.payload?.conversationId).toBe("conversation-1");
|
|
3615
|
+
}
|
|
3616
|
+
finally {
|
|
3617
|
+
await controller.stop();
|
|
3618
|
+
}
|
|
3619
|
+
});
|
|
3620
|
+
it("scopes a browser tool_response to the matching conversation instead of fanning out", async () => {
|
|
3621
|
+
const socket = new TestSocket();
|
|
3622
|
+
mocks.io.mockReturnValue(socket);
|
|
3623
|
+
mocks.spawnSync.mockReturnValue({ status: 1, stdout: "", stderr: "" });
|
|
3624
|
+
mocks.agentRun.mockReturnValue(new Promise(() => { }));
|
|
3625
|
+
// Give each CoreAgent instance its own sendToolResponse so we can see which run
|
|
3626
|
+
// received the answer. Construction order matches agent.execute order below.
|
|
3627
|
+
const instanceSends = [];
|
|
3628
|
+
mocks.coreAgentConstructor.mockImplementation(function MockCoreAgent(_presenter, runtime) {
|
|
3629
|
+
const sendToolResponse = vi.fn().mockReturnValue(true);
|
|
3630
|
+
instanceSends.push(sendToolResponse);
|
|
3631
|
+
return {
|
|
3632
|
+
run: (...args) => {
|
|
3633
|
+
runtime?.onProviderSpawned?.(process.pid);
|
|
3634
|
+
return mocks.agentRun(...args);
|
|
3635
|
+
},
|
|
3636
|
+
kill: mocks.agentKill,
|
|
3637
|
+
sendToolResponse,
|
|
3638
|
+
};
|
|
3639
|
+
});
|
|
3640
|
+
const { startDaemon } = await import("../daemon.js");
|
|
3641
|
+
const controller = await startDaemon([
|
|
3642
|
+
"--runtime-id",
|
|
3643
|
+
"11111111-1111-4111-8111-111111111111",
|
|
3644
|
+
"--token",
|
|
3645
|
+
"alan_runtime_secret",
|
|
3646
|
+
"--ws-url",
|
|
3647
|
+
"ws://ws.test",
|
|
3648
|
+
"--local-api-port",
|
|
3649
|
+
"0",
|
|
3650
|
+
]);
|
|
3651
|
+
try {
|
|
3652
|
+
socket.emit("agent.execute", {
|
|
3653
|
+
conversationId: "conversation-a",
|
|
3654
|
+
runId: "run-a",
|
|
3655
|
+
content: "Run A",
|
|
3656
|
+
});
|
|
3657
|
+
socket.emit("agent.execute", {
|
|
3658
|
+
conversationId: "conversation-b",
|
|
3659
|
+
runId: "run-b",
|
|
3660
|
+
content: "Run B",
|
|
3661
|
+
});
|
|
3662
|
+
await vi.waitFor(() => expect(socket.outbound).toEqual(expect.arrayContaining([
|
|
3663
|
+
{ event: "agent.accepted", payload: { runId: "run-a" } },
|
|
3664
|
+
{ event: "agent.accepted", payload: { runId: "run-b" } },
|
|
3665
|
+
])), { timeout: 5_000 });
|
|
3666
|
+
expect(instanceSends).toHaveLength(2);
|
|
3667
|
+
const [sendA, sendB] = instanceSends;
|
|
3668
|
+
// Answer for conversation B, no runId (legacy runtime payload shape).
|
|
3669
|
+
socket.emit("agent.tool_response", {
|
|
3670
|
+
conversationId: "conversation-b",
|
|
3671
|
+
toolId: "tool-b",
|
|
3672
|
+
response: "Approved",
|
|
3673
|
+
});
|
|
3674
|
+
// Only B's agent receives it — A must not be touched.
|
|
3675
|
+
expect(sendB).toHaveBeenCalledWith("tool-b", "Approved");
|
|
3676
|
+
expect(sendA).not.toHaveBeenCalled();
|
|
3677
|
+
}
|
|
3678
|
+
finally {
|
|
3679
|
+
await controller.stop();
|
|
3680
|
+
}
|
|
3681
|
+
});
|
|
3682
|
+
it("logout removes only Alan-owned local credential artifacts without logging secrets", async () => {
|
|
3683
|
+
writeFileSync(configPath, JSON.stringify({
|
|
3684
|
+
apiUrl: "http://api.test",
|
|
3685
|
+
wsUrl: "ws://ws.test",
|
|
3686
|
+
runtimeId: "11111111-1111-4111-8111-111111111111",
|
|
3687
|
+
runtimeToken: "old_runtime_secret",
|
|
3688
|
+
}));
|
|
3689
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
3690
|
+
ok: true,
|
|
3691
|
+
json: async () => ({ runtimeToken: "new_runtime_secret" }),
|
|
3692
|
+
});
|
|
3693
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
3694
|
+
const { rotateRuntimeToken, logoutDaemon } = await import("../daemon.js");
|
|
3695
|
+
await rotateRuntimeToken(["--token", "alan_pat_secret"]);
|
|
3696
|
+
expect(fetchMock).toHaveBeenCalledWith("http://api.test/public/runtimes/11111111-1111-4111-8111-111111111111/tokens/rotate", expect.objectContaining({
|
|
3697
|
+
method: "POST",
|
|
3698
|
+
headers: { authorization: "Bearer alan_pat_secret" },
|
|
3699
|
+
}));
|
|
3700
|
+
expect(JSON.parse(readFileSync(configPath, "utf8")).runtimeToken).toBe("new_runtime_secret");
|
|
3701
|
+
const ownedArtifacts = [
|
|
3702
|
+
`${configPath}.pending`,
|
|
3703
|
+
`${configPath}.last-known-good`,
|
|
3704
|
+
`${configPath}.setup-pending`,
|
|
3705
|
+
`${configPath}.device-auth-pending`,
|
|
3706
|
+
`${configPath}.device-auth-operation.json`,
|
|
3707
|
+
`${configPath}.event-outbox.enc`,
|
|
3708
|
+
`${configPath}.run-journal.json`,
|
|
3709
|
+
`${configPath}.run-journal.json.pending`,
|
|
3710
|
+
`${configPath}.pending-123-abcdef123456`,
|
|
3711
|
+
`${configPath}.device-auth-pending.pending-123-abcdef123456`,
|
|
3712
|
+
`${configPath}.device-auth-operation.json.pending-123-abcdef123456`,
|
|
3713
|
+
`${configPath}.device-auth-operation.json.release-123-abcdef123456`,
|
|
3714
|
+
`${configPath}.device-auth-operation.json.corrupt-123-abcdef123456`,
|
|
3715
|
+
`${configPath}.maintenance-lease.json.release-123-abcdef123456`,
|
|
3716
|
+
`${configPath}.event-outbox.enc.pending-123-abcdef123456`,
|
|
3717
|
+
];
|
|
3718
|
+
for (const artifact of ownedArtifacts)
|
|
3719
|
+
writeFileSync(artifact, "alan-owned", { mode: 0o600 });
|
|
3720
|
+
const thirdPartyConfig = join(tempDir, ".claude.json");
|
|
3721
|
+
const userOwnedSimilarName = `${configPath}.pending-user-owned`;
|
|
3722
|
+
writeFileSync(thirdPartyConfig, JSON.stringify({ userOwned: true }));
|
|
3723
|
+
writeFileSync(userOwnedSimilarName, "keep me");
|
|
3724
|
+
const info = vi.spyOn(console, "info").mockImplementation(() => { });
|
|
3725
|
+
await logoutDaemon();
|
|
3726
|
+
expect(() => readFileSync(configPath, "utf8")).toThrow();
|
|
3727
|
+
for (const artifact of ownedArtifacts)
|
|
3728
|
+
expect(existsSync(artifact)).toBe(false);
|
|
3729
|
+
expect(readFileSync(thirdPartyConfig, "utf8")).toContain("userOwned");
|
|
3730
|
+
expect(readFileSync(userOwnedSimilarName, "utf8")).toBe("keep me");
|
|
3731
|
+
expect(JSON.stringify(info.mock.calls)).not.toContain("new_runtime_secret");
|
|
3732
|
+
expect(JSON.stringify(info.mock.calls)).not.toContain("alan_pat_secret");
|
|
3733
|
+
});
|
|
3734
|
+
it("defers logout while a run is active and fences new dispatches", async () => {
|
|
3735
|
+
writeFileSync(configPath, JSON.stringify({
|
|
3736
|
+
runtimeId: "11111111-1111-4111-8111-111111111111",
|
|
3737
|
+
runtimeToken: "runtime-secret-must-not-leak",
|
|
3738
|
+
wsUrl: "ws://ws.test",
|
|
3739
|
+
localApiToken: "local-secret-that-is-long-enough-for-tests",
|
|
3740
|
+
}));
|
|
3741
|
+
mocks.agentRun.mockReturnValue(new Promise(() => { }));
|
|
3742
|
+
const socket = new TestSocket();
|
|
3743
|
+
mocks.io.mockReturnValue(socket);
|
|
3744
|
+
const { logoutDaemon, readLocalDaemonMaintenanceLease, startDaemon } = await import("../daemon.js");
|
|
3745
|
+
const controller = await startDaemon(["--local-api-port", "0"]);
|
|
3746
|
+
writeFileSync(configPath, JSON.stringify({
|
|
3747
|
+
...JSON.parse(readFileSync(configPath, "utf8")),
|
|
3748
|
+
localApiPort: controller.localApiPort,
|
|
3749
|
+
}));
|
|
3750
|
+
try {
|
|
3751
|
+
socket.emit("agent.execute", {
|
|
3752
|
+
conversationId: "22222222-2222-4222-8222-222222222222",
|
|
3753
|
+
runId: "33333333-3333-4333-8333-333333333333",
|
|
3754
|
+
content: "Keep working",
|
|
3755
|
+
});
|
|
3756
|
+
await vi.waitFor(() => expect(mocks.agentRun).toHaveBeenCalledTimes(1));
|
|
3757
|
+
const error = (await logoutDaemon([]).catch((caught) => caught));
|
|
3758
|
+
expect(error).toBeInstanceOf(Error);
|
|
3759
|
+
expect(error.message).toContain("deferred while 1 active run");
|
|
3760
|
+
expect(error.message).not.toContain("runtime-secret-must-not-leak");
|
|
3761
|
+
expect(existsSync(configPath)).toBe(true);
|
|
3762
|
+
expect(readLocalDaemonMaintenanceLease(configPath, Date.now() + 24 * 60 * 60_000)).toMatchObject({
|
|
3763
|
+
reason: "credential_cleanup",
|
|
3764
|
+
});
|
|
3765
|
+
const drainingStatus = await fetch(`http://127.0.0.1:${controller.localApiPort}/status`, {
|
|
3766
|
+
headers: { authorization: `Bearer ${controller.localApiToken}` },
|
|
3767
|
+
}).then((response) => response.json());
|
|
3768
|
+
expect(drainingStatus).toMatchObject({
|
|
3769
|
+
acceptingRuns: false,
|
|
3770
|
+
runnerStatus: expect.objectContaining({
|
|
3771
|
+
acceptingRuns: false,
|
|
3772
|
+
primaryMessage: "This computer is finishing logout.",
|
|
3773
|
+
}),
|
|
3774
|
+
});
|
|
3775
|
+
socket.emit("agent.execute", {
|
|
3776
|
+
conversationId: "44444444-4444-4444-8444-444444444444",
|
|
3777
|
+
runId: "55555555-5555-4555-8555-555555555555",
|
|
3778
|
+
content: "Do not start",
|
|
3779
|
+
});
|
|
3780
|
+
await vi.waitFor(() => expect(socket.outbound).toEqual(expect.arrayContaining([
|
|
3781
|
+
{
|
|
3782
|
+
event: "agent.rejected",
|
|
3783
|
+
payload: expect.objectContaining({
|
|
3784
|
+
runId: "55555555-5555-4555-8555-555555555555",
|
|
3785
|
+
code: "runtime_logout_pending",
|
|
3786
|
+
}),
|
|
3787
|
+
},
|
|
3788
|
+
])));
|
|
3789
|
+
}
|
|
3790
|
+
finally {
|
|
3791
|
+
await controller.stop();
|
|
3792
|
+
}
|
|
3793
|
+
});
|
|
3794
|
+
it("stops an idle daemon before removing its in-memory credentials", async () => {
|
|
3795
|
+
writeFileSync(configPath, JSON.stringify({
|
|
3796
|
+
runtimeId: "11111111-1111-4111-8111-111111111111",
|
|
3797
|
+
runtimeToken: "runtime-secret",
|
|
3798
|
+
wsUrl: "ws://ws.test",
|
|
3799
|
+
localApiToken: "local-secret-that-is-long-enough-for-tests",
|
|
3800
|
+
}));
|
|
3801
|
+
mocks.io.mockReturnValue(new TestSocket());
|
|
3802
|
+
const { logoutDaemon, startDaemon } = await import("../daemon.js");
|
|
3803
|
+
const controller = await startDaemon(["--local-api-port", "0"]);
|
|
3804
|
+
writeFileSync(configPath, JSON.stringify({
|
|
3805
|
+
...JSON.parse(readFileSync(configPath, "utf8")),
|
|
3806
|
+
localApiPort: controller.localApiPort,
|
|
3807
|
+
}));
|
|
3808
|
+
try {
|
|
3809
|
+
await logoutDaemon([]);
|
|
3810
|
+
expect(existsSync(configPath)).toBe(false);
|
|
3811
|
+
}
|
|
3812
|
+
finally {
|
|
3813
|
+
await controller.stop();
|
|
3814
|
+
}
|
|
3815
|
+
});
|
|
3816
|
+
it("doctor --sync pushes sanitized capabilities using the runtime token", async () => {
|
|
3817
|
+
writeFileSync(configPath, JSON.stringify({
|
|
3818
|
+
apiUrl: "http://api.test",
|
|
3819
|
+
wsUrl: "ws://ws.test",
|
|
3820
|
+
runtimeId: "11111111-1111-4111-8111-111111111111",
|
|
3821
|
+
runtimeToken: "alan_runtime_secret",
|
|
3822
|
+
}));
|
|
3823
|
+
const binDir = seedCliBinaries(tempDir);
|
|
3824
|
+
process.env.PATH = binDir;
|
|
3825
|
+
mocks.spawnSync.mockImplementation((command) => {
|
|
3826
|
+
if (command.includes("codex"))
|
|
3827
|
+
return { status: 0, stdout: "codex 1.2.3\n", stderr: "" };
|
|
3828
|
+
if (command.includes("git"))
|
|
3829
|
+
return { status: 0, stdout: "git version 2.50.0\n", stderr: "" };
|
|
3830
|
+
return { status: 1, stdout: "", stderr: "not found" };
|
|
3831
|
+
});
|
|
3832
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
3833
|
+
ok: true,
|
|
3834
|
+
json: async () => ({
|
|
3835
|
+
capabilities: {
|
|
3836
|
+
agents: [{ provider: "codex", available: true, version: "codex 1.2.3" }],
|
|
3837
|
+
hasGit: true,
|
|
3838
|
+
},
|
|
3839
|
+
}),
|
|
3840
|
+
});
|
|
3841
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
3842
|
+
const info = vi.spyOn(console, "info").mockImplementation(() => { });
|
|
3843
|
+
const { printDoctor } = await import("../daemon.js");
|
|
3844
|
+
await printDoctor(["--sync"]);
|
|
3845
|
+
expect(fetchMock).toHaveBeenCalledWith("http://api.test/public/runtimes/11111111-1111-4111-8111-111111111111/capabilities/self", expect.objectContaining({
|
|
3846
|
+
method: "PATCH",
|
|
3847
|
+
headers: {
|
|
3848
|
+
authorization: "Bearer alan_runtime_secret",
|
|
3849
|
+
"content-type": "application/json",
|
|
3850
|
+
},
|
|
3851
|
+
}));
|
|
3852
|
+
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
3853
|
+
expect(JSON.stringify(body.capabilities)).not.toContain("/Users/");
|
|
3854
|
+
expect(body.metadata).toEqual(expect.objectContaining({
|
|
3855
|
+
platform: expect.any(String),
|
|
3856
|
+
arch: expect.any(String),
|
|
3857
|
+
agentVersion: expect.any(String),
|
|
3858
|
+
memoryBytes: expect.any(Number),
|
|
3859
|
+
}));
|
|
3860
|
+
expect(JSON.parse(info.mock.calls.at(-1)[0])).toMatchObject({ synced: true });
|
|
3861
|
+
});
|
|
3862
|
+
it("status explains durable runtime config separately from ephemeral sessions", async () => {
|
|
3863
|
+
const info = vi.spyOn(console, "info").mockImplementation(() => { });
|
|
3864
|
+
const { printStatus } = await import("../daemon.js");
|
|
3865
|
+
await printStatus();
|
|
3866
|
+
expect(JSON.parse(info.mock.calls.at(-1)[0])).toMatchObject({
|
|
3867
|
+
mode: "runtime",
|
|
3868
|
+
configured: false,
|
|
3869
|
+
localDaemonRunning: false,
|
|
3870
|
+
apiUrl: null,
|
|
3871
|
+
wsUrl: null,
|
|
3872
|
+
note: expect.stringContaining("Cloud sandbox sessions are launched by Alan"),
|
|
3873
|
+
warnings: [],
|
|
3874
|
+
});
|
|
3875
|
+
});
|
|
3876
|
+
it("removes legacy tenant pointers whenever durable runtime config is read", async () => {
|
|
3877
|
+
writeFileSync(configPath, JSON.stringify({
|
|
3878
|
+
apiUrl: "http://api.test",
|
|
3879
|
+
wsUrl: "ws://ws.test",
|
|
3880
|
+
runtimeId: "11111111-1111-4111-8111-111111111111",
|
|
3881
|
+
runtimeToken: "alan_runtime_secret",
|
|
3882
|
+
teamId: "legacy-team",
|
|
3883
|
+
organizationId: "legacy-org",
|
|
3884
|
+
}));
|
|
3885
|
+
const info = vi.spyOn(console, "info").mockImplementation(() => { });
|
|
3886
|
+
const { printStatus } = await import("../daemon.js");
|
|
3887
|
+
await printStatus();
|
|
3888
|
+
expect(JSON.parse(readFileSync(configPath, "utf8"))).toEqual({
|
|
3889
|
+
apiUrl: "http://api.test",
|
|
3890
|
+
wsUrl: "ws://ws.test",
|
|
3891
|
+
runtimeId: "11111111-1111-4111-8111-111111111111",
|
|
3892
|
+
runtimeToken: "alan_runtime_secret",
|
|
3893
|
+
});
|
|
3894
|
+
expect(JSON.parse(info.mock.calls.at(-1)[0])).toMatchObject({ configured: true });
|
|
3895
|
+
});
|
|
3896
|
+
it("resolves the runtime config path at call time", async () => {
|
|
3897
|
+
const { printStatus } = await import("../daemon.js");
|
|
3898
|
+
const alternateConfigPath = join(tempDir, "alternate-config.json");
|
|
3899
|
+
writeFileSync(alternateConfigPath, JSON.stringify({
|
|
3900
|
+
apiUrl: "http://api.test",
|
|
3901
|
+
wsUrl: "ws://ws.test",
|
|
3902
|
+
endpointProfile: "custom",
|
|
3903
|
+
runtimeId: "22222222-2222-4222-8222-222222222222",
|
|
3904
|
+
runtimeToken: "alan_runtime_secret",
|
|
3905
|
+
}));
|
|
3906
|
+
process.env.ALAN_AGENT_CONFIG_PATH = alternateConfigPath;
|
|
3907
|
+
const info = vi.spyOn(console, "info").mockImplementation(() => { });
|
|
3908
|
+
await printStatus();
|
|
3909
|
+
expect(JSON.parse(info.mock.calls.at(-1)[0])).toMatchObject({
|
|
3910
|
+
configured: true,
|
|
3911
|
+
runtimeId: "22222222-2222-4222-8222-222222222222",
|
|
3912
|
+
configPath: alternateConfigPath,
|
|
3913
|
+
localDaemonRunning: false,
|
|
3914
|
+
});
|
|
3915
|
+
});
|
|
3916
|
+
it("token revoke revokes server-side tokens and removes local runtime config", async () => {
|
|
3917
|
+
writeFileSync(configPath, JSON.stringify({
|
|
3918
|
+
apiUrl: "http://api.test",
|
|
3919
|
+
wsUrl: "ws://ws.test",
|
|
3920
|
+
runtimeId: "11111111-1111-4111-8111-111111111111",
|
|
3921
|
+
runtimeToken: "old_runtime_secret",
|
|
3922
|
+
}));
|
|
3923
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
3924
|
+
ok: true,
|
|
3925
|
+
json: async () => ({ ok: true }),
|
|
3926
|
+
});
|
|
3927
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
3928
|
+
const { revokeRuntimeToken } = await import("../daemon.js");
|
|
3929
|
+
await revokeRuntimeToken(["--token", "alan_pat_secret"]);
|
|
3930
|
+
expect(fetchMock).toHaveBeenCalledWith("http://api.test/public/runtimes/11111111-1111-4111-8111-111111111111/tokens/revoke", expect.objectContaining({
|
|
3931
|
+
method: "POST",
|
|
3932
|
+
headers: { authorization: "Bearer alan_pat_secret" },
|
|
3933
|
+
}));
|
|
3934
|
+
expect(() => readFileSync(configPath, "utf8")).toThrow();
|
|
3935
|
+
});
|
|
3936
|
+
it("keeps credentials and redacts the response body when token revoke fails", async () => {
|
|
3937
|
+
writeFileSync(configPath, JSON.stringify({
|
|
3938
|
+
apiUrl: "http://api.test",
|
|
3939
|
+
wsUrl: "ws://ws.test",
|
|
3940
|
+
runtimeId: "11111111-1111-4111-8111-111111111111",
|
|
3941
|
+
runtimeToken: "old_runtime_secret",
|
|
3942
|
+
}));
|
|
3943
|
+
vi.stubGlobal("fetch", vi
|
|
3944
|
+
.fn()
|
|
3945
|
+
.mockRejectedValueOnce(new Error("daemon absent"))
|
|
3946
|
+
.mockResolvedValueOnce({
|
|
3947
|
+
ok: false,
|
|
3948
|
+
status: 500,
|
|
3949
|
+
text: async () => "alan_pat_secret old_runtime_secret",
|
|
3950
|
+
}));
|
|
3951
|
+
const { revokeRuntimeToken } = await import("../daemon.js");
|
|
3952
|
+
const error = (await revokeRuntimeToken(["--token", "alan_pat_secret"]).catch((caught) => caught));
|
|
3953
|
+
expect(error.message).toBe("runtime token revoke failed: HTTP 500");
|
|
3954
|
+
expect(error.message).not.toContain("alan_pat_secret");
|
|
3955
|
+
expect(error.message).not.toContain("old_runtime_secret");
|
|
3956
|
+
expect(JSON.parse(readFileSync(configPath, "utf8")).runtimeToken).toBe("old_runtime_secret");
|
|
3957
|
+
});
|
|
3958
|
+
it("does not overwrite the runtime token when token rotation fails", async () => {
|
|
3959
|
+
writeFileSync(configPath, JSON.stringify({
|
|
3960
|
+
apiUrl: "http://api.test",
|
|
3961
|
+
wsUrl: "ws://ws.test",
|
|
3962
|
+
runtimeId: "11111111-1111-4111-8111-111111111111",
|
|
3963
|
+
runtimeToken: "old_runtime_secret",
|
|
3964
|
+
}));
|
|
3965
|
+
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
|
|
3966
|
+
ok: false,
|
|
3967
|
+
status: 500,
|
|
3968
|
+
text: async () => "alan_pat_secret old_runtime_secret",
|
|
3969
|
+
}));
|
|
3970
|
+
const { rotateRuntimeToken } = await import("../daemon.js");
|
|
3971
|
+
await expect(rotateRuntimeToken(["--token", "alan_pat_secret"])).rejects.toThrow("runtime token rotation failed: HTTP 500");
|
|
3972
|
+
expect(JSON.parse(readFileSync(configPath, "utf8")).runtimeToken).toBe("old_runtime_secret");
|
|
3973
|
+
});
|
|
3974
|
+
});
|
|
3975
|
+
//# sourceMappingURL=daemon.test.js.map
|