Skip to content
Closed
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
26 changes: 26 additions & 0 deletions .changeset/unified-project-agents.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
"@sapiom/harness": minor
"@sapiom/harness-desktop": patch
---

Unify Agent Studio project sessions around one ordinary coding-agent identity, make the project name open the shared Agent Map, and seed new projects through a durable, retry-safe bootstrap in the first `Plan Agents` session.

**Breaking for embedders** (minor while `@sapiom/harness` is pre-1.0):
`HarnessSession.agentMapIdentity` is now the role-neutral
`ProjectAgentSession { projectId, userId, sessionId }`; `role` and `assignment`
are no longer present. Valid persisted `planning` metadata is migrated into the
optional `projectBootstrap` lifecycle field and then removed. The deprecated
planner-message alias now returns `ProjectBootstrapMetadata | null`, with
`projectId`, `userId`, `targetSessionId`, and `bootstrap` replacing the former
nested `identity` and `greeting` fields.

**Migration:** stop branching on `agentMapIdentity.role` or `.assignment`, read
optional `projectBootstrap` only for bootstrap status, and handle `metadata:
null` from the compatibility alias—or move to the generic session routes. An
embedder that already owns a new session's first prompt should send
`initialUserInputPending: true` in the same `CreateSessionRequest`, so automatic
bootstrap yields before launch. New telemetry consumers should recognize the
neutral `project_agent.*` and `project_bootstrap.*` events. Valid legacy state
keeps its session/provider IDs, cwd, title, transcript, and Canvas; malformed or
conflicting authority is retained and fails closed. Downgrading does not restore
the former planner coordinator semantics.
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