Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/project-bootstrap-activation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@sapiom/harness": minor
---

Activate one recoverable first-session map bootstrap for newly opened Studio projects. Durable project intent, input receipts, readiness, preemption, and restart recovery share the ordinary session lifecycle.

Breaking changes for embedders and HTTP clients:

- Remove `HarnessSession.planning`, `SessionManager.setPlanningMetadata()`, and the trusted create/resume `planning` options. Read the neutral `agentMapIdentity` for project identity and optional `projectBootstrap` for lifecycle state. The server migrates valid persisted legacy metadata and input queues automatically.
- Remove the planner contracts `PlannerGreetingErrorCode`, `PlannerGreetingState`, `PlannerSessionMetadata`, `PlannerQueuedInput`, `PlannerSessionRequest`, `PlannerSessionResponse`, `PlannerMessageRequest`, `PlannerSessionMetadataResponse`, and `PlannerLifecycleEvent`. Use ordinary session request/response types and `ProjectBootstrapMetadata`, `ProjectBootstrapState`, and `ProjectBootstrapInputReceipt` for bootstrap state and input acknowledgements.
- Remove `POST /api/projects/:projectId/planner-sessions`, `POST /api/projects/:projectId/planner-sessions/:sessionId/messages`, and `POST /api/projects/:projectId/planner-sessions/:sessionId/greeting/retry`. Create, resume, and send input through `POST /api/sessions`, `POST /api/sessions/:id/resume`, and `POST /api/sessions/:id/input`. Bootstrap recovery is server-owned. Clients supplying the first prompt should set `initialUserInputPending: true` when creating a session.
- Replace the `planner_session.*` and `planner_greeting.*` analytics event names with `project_agent.identity_*` and `project_bootstrap.*`. Their remote projections remain content-free.

Project creation and root binding can return `202` with a committed project identity when automatic initialization must retry; treat that identity as successfully created. Settings updates return their committed values while new-root initialization continues in the background. Server shutdown keeps admission fenced and bounds its wait for bootstrap, persistence, archive, and telemetry drains before releasing the listener.
60 changes: 60 additions & 0 deletions packages/harness-desktop/scripts/smoke-agent-stub.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
const fs = require("node:fs");

function environmentCapturePath(base, sessionId) {
return `${base}.${encodeURIComponent(sessionId)}.json`;
}

function captureAgentEnvironment(env = process.env) {
const base = env.SAPIOM_SMOKE_AGENT_ENV;
const sessionId = env.SAPIOM_HARNESS_SESSION_ID;
if (!base || !sessionId) return null;

const file = environmentCapturePath(base, sessionId);
const snapshot = {
schemaVersion: 1,
sessionId,
variableCount: Object.keys(env).length,
hasEsbuildBinaryPath: Object.prototype.hasOwnProperty.call(
env,
"ESBUILD_BINARY_PATH",
),
hasPath: typeof env.PATH === "string" && env.PATH.length > 0,
};
fs.writeFileSync(file, `${JSON.stringify(snapshot)}\n`, {
encoding: "utf8",
mode: 0o600,
});
return file;
}

function runSessionStartHook() {
try {
const settingsIndex = process.argv.indexOf("--settings");
const settingsPath =
settingsIndex > -1 ? process.argv[settingsIndex + 1] : null;
if (!settingsPath) return;
const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
const command = settings.hooks.SessionStart[0].hooks[0].command;
const { execFileSync, execSync } = require("node:child_process");
if (process.platform === "win32") {
const bash = "C:\\Program Files\\Git\\bin\\bash.exe";
if (fs.existsSync(bash)) {
execFileSync(bash, ["-c", command], { stdio: "ignore" });
} else {
execSync(command, { stdio: "ignore" });
}
} else {
execFileSync("/bin/sh", ["-c", command], { stdio: "ignore" });
}
} catch {
// A failed hook is exactly what checkSessionCreate's ready poll reports.
}
}

module.exports = { captureAgentEnvironment, environmentCapturePath };

if (require.main === module) {
captureAgentEnvironment();
runSessionStartHook();
setTimeout(() => process.exit(0), 3000);
}
37 changes: 8 additions & 29 deletions packages/harness-desktop/scripts/smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -75,52 +75,31 @@ export SAPIOM_SMOKE_OUT="$(native "$report_file")"
# check can assert the WHOLE readiness chain (settings → hook command → node
# resolution under the hook shell → POST → ready), the exact seam that broke
# silently on Windows and dropped every held first prompt.
cat > "$smoke_home/stub-agent.js" <<'STUBJS'
const fs = require("fs");
const envFile = process.env.SAPIOM_SMOKE_AGENT_ENV;
if (envFile) fs.writeFileSync(envFile, Object.entries(process.env).map(([k, v]) => k + "=" + v).join("\n") + "\n");
try {
const i = process.argv.indexOf("--settings");
const settingsPath = i > -1 ? process.argv[i + 1] : null;
if (settingsPath) {
const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
const command = settings.hooks.SessionStart[0].hooks[0].command;
const { execFileSync, execSync } = require("child_process");
if (process.platform === "win32") {
const bash = "C:\\Program Files\\Git\\bin\\bash.exe";
if (fs.existsSync(bash)) execFileSync(bash, ["-c", command], { stdio: "ignore" });
else execSync(command, { stdio: "ignore" });
} else {
execFileSync("/bin/sh", ["-c", command], { stdio: "ignore" });
}
}
} catch {
// A failed hook is exactly what the ready-poll in checkSessionCreate reports.
}
setTimeout(() => process.exit(0), 3000);
STUBJS
cp "$here/smoke-agent-stub.cjs" "$smoke_home/stub-agent.cjs"
if [ "$(uname -s)" != "Linux" ] && [ "$(uname -s)" != "Darwin" ]; then
# Shaped like an npm shim on purpose — a `.cmd` that runs `node <script>` — because
# that is exactly what `claude.cmd` is, and it's the shape resolveSpawnTarget has
# to see through. A stub that were a plain .cmd (or an .exe) would exercise a path
# real agents never take, and is now correctly refused rather than shelled out.
stub="$smoke_home/stub-agent.cmd"
printf '@echo off\r\n"%%dp0%%\\node.exe" "%%dp0%%\\stub-agent.js" %%*\r\n' > "$stub"
printf '@echo off\r\n"%%dp0%%\\node.exe" "%%dp0%%\\stub-agent.cjs" %%*\r\n' > "$stub"
else
# `node` rather than a hardcoded path: the app's PATH augmentation (runtime
# shims) must make it resolvable — that resolution is part of what's under test.
stub="$smoke_home/stub-agent.sh"
printf '#!/bin/sh\nexec node "%s" "$@"\n' "$smoke_home/stub-agent.js" > "$stub"
printf '#!/bin/sh\nexec node "%s" "$@"\n' "$smoke_home/stub-agent.cjs" > "$stub"
chmod +x "$stub"
fi
# Where the stub agent writes its environment, so a check can assert on what the
# AGENT actually inherited rather than on what the main process meant to pass.
# Base path where each stub agent writes a session-keyed environment snapshot,
# so concurrent project sessions cannot overwrite one another's evidence and a
# check can assert on what the exact AGENT inherited rather than on what the
# main process meant to pass.
# This caught a real regression: the desktop host pins ESBUILD_BINARY_PATH so its
# own bundler can exec a binary outside app.asar, and the whole parent env is
# copied into the pty — so every agent, and every tool it ran in the user's repo,
# inherited a pin to OUR esbuild build ("Host version X does not match binary
# version Y" on a project that builds fine outside the app).
export SAPIOM_SMOKE_AGENT_ENV="$(native "$smoke_home/agent-env.txt")"
export SAPIOM_SMOKE_AGENT_ENV="$(native "$smoke_home/agent-env")"
# Native, because resolveSpawnTarget resolves this inside the app: a POSIX
# path has no drive letter, so the Windows lookup would never find it.
export SAPIOM_SMOKE_STUB_AGENT="$(native "$stub")"
Expand Down
60 changes: 60 additions & 0 deletions packages/harness-desktop/src/main/smoke-agent-stub.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { createRequire } from "node:module";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import * as path from "node:path";
import { afterEach, describe, expect, it } from "vitest";

const require = createRequire(import.meta.url);
const { captureAgentEnvironment, environmentCapturePath } =
require("../../scripts/smoke-agent-stub.cjs") as {
captureAgentEnvironment: (env: Record<string, string>) => string | null;
environmentCapturePath: (base: string, sessionId: string) => string;
};

describe("packaged smoke agent environment evidence", () => {
const roots: string[] = [];

afterEach(() => {
for (const root of roots.splice(0)) {
rmSync(root, { recursive: true, force: true });
}
});

it("retains exact per-session snapshots regardless of competing write order", () => {
const root = mkdtempSync(path.join(tmpdir(), "smoke-agent-env-"));
roots.push(root);
const base = path.join(root, "agent-env");
const firstId = "11111111-1111-4111-8111-111111111111";
const secondId = "22222222-2222-4222-8222-222222222222";

const secondFile = captureAgentEnvironment({
SAPIOM_SMOKE_AGENT_ENV: base,
SAPIOM_HARNESS_SESSION_ID: secondId,
PATH: "/bin",
PRIVATE_VALUE: "must-not-be-copied",
});
const firstFile = captureAgentEnvironment({
SAPIOM_SMOKE_AGENT_ENV: base,
SAPIOM_HARNESS_SESSION_ID: firstId,
PATH: "/bin",
});

expect(firstFile).toBe(environmentCapturePath(base, firstId));
expect(secondFile).toBe(environmentCapturePath(base, secondId));
expect(firstFile).not.toBe(secondFile);
expect(JSON.parse(readFileSync(firstFile!, "utf8"))).toMatchObject({
schemaVersion: 1,
sessionId: firstId,
hasEsbuildBinaryPath: false,
hasPath: true,
});
const secondRaw = readFileSync(secondFile!, "utf8");
expect(JSON.parse(secondRaw)).toMatchObject({
schemaVersion: 1,
sessionId: secondId,
hasEsbuildBinaryPath: false,
hasPath: true,
});
expect(secondRaw).not.toContain("must-not-be-copied");
});
});
Loading
Loading