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
25 changes: 25 additions & 0 deletions .changeset/ordinary-project-session-identity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
"@sapiom/harness": minor
---

Give every Studio project session the same writable coding-agent prompt and
map capabilities, with durable project ownership revalidated before launch
and resume.

**Breaking for embedders** (minor while `@sapiom/harness` is pre-1.0):
`HarnessSession.agentMapIdentity` now exposes only
`ProjectAgentSession { projectId, userId, sessionId }`. Replace branches on
`role` and `assignment` with neutral project identity. Optional
`projectBootstrap` carries startup status without granting authority. Valid
persisted legacy metadata is normalized while session/provider IDs, cwd,
title, transcript, and Canvas are preserved. Malformed or conflicting
authority fails closed; unavailable project scope prevents resume until the
current owner and root binding are valid again.

`AgentMapToolEvent.role` is also removed. Telemetry consumers should use the
neutral project/session identifiers and the tool name and outcome instead of
branching on a session role.

Persisted bootstrap failures with `scope_unavailable` are recognized on
restart, so valid conversation metadata is retained and can resume after
scope is restored.
30 changes: 24 additions & 6 deletions packages/harness/src/core/agent-map-capability-registry.test.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,23 @@
import { describe, expect, it, vi } from "vitest";

import type { PlanningSessionIdentity } from "../shared/agent-map.js";
import type { ProjectAgentSession } from "../shared/agent-map.js";
import {
AgentMapCapabilityError,
AgentMapCapabilityRegistry,
} from "./agent-map-capability-registry.js";

const identity = (sessionId = "session-1"): PlanningSessionIdentity => ({
const identity = (sessionId = "session-1"): ProjectAgentSession => ({
projectId: "project-a",
sessionId,
userId: "user-a",
role: "agent-builder",
assignment: { kind: "unplanned" },
});

describe("AgentMapCapabilityRegistry", () => {
it("stores only a digest and rotates one generation per session", () => {
const tokens = ["a".repeat(43), "b".repeat(43)];
const registry = new AgentMapCapabilityRegistry({ randomToken: () => tokens.shift()! });
const registry = new AgentMapCapabilityRegistry({
randomToken: () => tokens.shift()!,
});
const first = registry.issue(identity());
expect(registry.resolve(first.token).identity).toEqual(identity());
const second = registry.rotate(identity());
Expand All @@ -27,6 +27,21 @@ describe("AgentMapCapabilityRegistry", () => {
);
});

it("copies only the neutral identity fields into live capability authority", () => {
const identityWithUntrustedExtras = {
...identity(),
untrustedContext: { assignmentId: "assignment-1" },
};
const registry = new AgentMapCapabilityRegistry({
randomToken: () => "legacy-session-token",
});

const issued = registry.issue(identityWithUntrustedExtras);

expect(issued.identity).toEqual(identity());
expect(issued.identity).not.toHaveProperty("untrustedContext");
});

it("fails closed for expired, revoked and unknown tokens without emitting material", () => {
let now = 10;
const onEvent = vi.fn();
Expand All @@ -38,11 +53,14 @@ describe("AgentMapCapabilityRegistry", () => {
});
const issued = registry.issue(identity());
now = 15;
expect(() => registry.resolve(issued.token)).toThrowError(AgentMapCapabilityError);
expect(() => registry.resolve(issued.token)).toThrowError(
AgentMapCapabilityError,
);
expect(() => registry.resolve("other")).toThrowError(
expect.objectContaining({ code: "invalid_capability" }),
);
expect(JSON.stringify(onEvent.mock.calls)).not.toContain("secret-token");
expect(JSON.stringify(onEvent.mock.calls)).not.toContain("role");
});

it("slides expiry on authenticated use but remains bounded by lifecycle revocation", () => {
Expand Down
44 changes: 28 additions & 16 deletions packages/harness/src/core/agent-map-capability-registry.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createHash, randomBytes } from "node:crypto";

import type { PlanningSessionIdentity } from "../shared/agent-map.js";
import type { ProjectAgentSession } from "../shared/agent-map.js";

export type AgentMapCapabilityRejection =
| "invalid_capability"
Expand All @@ -15,7 +15,7 @@ export class AgentMapCapabilityError extends Error {
}

export interface ResolvedAgentMapCapability {
identity: PlanningSessionIdentity;
identity: ProjectAgentSession;
generation: number;
expiresAt: number;
}
Expand All @@ -30,7 +30,6 @@ export interface AgentMapCapabilityEvent {
| "agent_map.capability.rotated"
| "agent_map.capability.revoked"
| "agent_map.capability.rejected";
role?: PlanningSessionIdentity["role"];
reason?: AgentMapCapabilityRejection;
}

Expand All @@ -45,6 +44,15 @@ interface Entry extends ResolvedAgentMapCapability {
digest: string;
}

/** Drop any legacy role/assignment properties before they enter authority. */
const neutralPrincipal = (
identity: ProjectAgentSession,
): ProjectAgentSession => ({
projectId: identity.projectId,
userId: identity.userId,
sessionId: identity.sessionId,
});

const DEFAULT_TTL_MS = 12 * 60 * 60 * 1_000;
const MAX_REVOKED_DIGESTS = 4_096;

Expand All @@ -58,38 +66,41 @@ export class AgentMapCapabilityRegistry {
private readonly now: () => number;
private readonly randomToken: () => string;

constructor(private readonly options: AgentMapCapabilityRegistryOptions = {}) {
constructor(
private readonly options: AgentMapCapabilityRegistryOptions = {},
) {
this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
this.now = options.now ?? Date.now;
this.randomToken =
options.randomToken ?? (() => randomBytes(32).toString("base64url"));
}

issue(identity: PlanningSessionIdentity): IssuedAgentMapCapability {
this.revokeSession(identity.sessionId);
issue(identity: ProjectAgentSession): IssuedAgentMapCapability {
const principal = neutralPrincipal(identity);
this.revokeSession(principal.sessionId);
const token = this.randomToken();
const digest = this.digest(token);
if (!token || this.active.has(digest) || this.revoked.has(digest)) {
throw new AgentMapCapabilityError("invalid_capability");
}
const generation = (this.generations.get(identity.sessionId) ?? 0) + 1;
this.generations.set(identity.sessionId, generation);
const generation = (this.generations.get(principal.sessionId) ?? 0) + 1;
this.generations.set(principal.sessionId, generation);
const entry: Entry = {
digest,
identity: structuredClone(identity),
identity: principal,
generation,
expiresAt: this.now() + this.ttlMs,
};
this.active.set(digest, entry);
this.currentBySession.set(identity.sessionId, digest);
this.emit({ name: "agent_map.capability.issued", role: identity.role });
this.currentBySession.set(principal.sessionId, digest);
this.emit({ name: "agent_map.capability.issued" });
return { token, ...this.publicEntry(entry) };
}

rotate(identity: PlanningSessionIdentity): IssuedAgentMapCapability {
rotate(identity: ProjectAgentSession): IssuedAgentMapCapability {
this.revokeSession(identity.sessionId);
const issued = this.issue(identity);
this.emit({ name: "agent_map.capability.rotated", role: identity.role });
this.emit({ name: "agent_map.capability.rotated" });
return issued;
}

Expand Down Expand Up @@ -121,18 +132,19 @@ export class AgentMapCapabilityRegistry {
revokeSession(sessionId: string): void {
const digest = this.currentBySession.get(sessionId);
if (!digest) return;
const entry = this.active.get(digest);
this.active.delete(digest);
this.currentBySession.delete(sessionId);
this.revoked.add(digest);
this.pruneRevoked();
this.emit({ name: "agent_map.capability.revoked", role: entry?.identity.role });
this.emit({ name: "agent_map.capability.revoked" });
}

isGenerationLive(sessionId: string, generation: number): boolean {
const digest = this.currentBySession.get(sessionId);
const entry = digest ? this.active.get(digest) : undefined;
return !!entry && entry.generation === generation && entry.expiresAt > this.now();
return (
!!entry && entry.generation === generation && entry.expiresAt > this.now()
);
}

private publicEntry(entry: Entry): ResolvedAgentMapCapability {
Expand Down
10 changes: 5 additions & 5 deletions packages/harness/src/core/planning-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,13 +183,13 @@ describe("planner session context and identity", () => {
});
expect(context).toContain(projectId);
expect(context).toContain(project.rootBindings[0]!.id);
expect(context).toContain('"role":"map-planner"');
expect(context).not.toContain('"role"');
expect(context).toContain('"empty":true');
expect(context).toContain("In your first response, briefly explain");
expect(context).not.toContain("In your first response, briefly explain");
expect(context).not.toContain("/Users/private");
expect(context).not.toContain("private-workspace-key");
expect(context).not.toContain("localRootRef");
expect(context).not.toContain("prompt");

expect(context.length).toBeLessThan(16_384);
});

Expand Down Expand Up @@ -279,10 +279,10 @@ describe("PlanningSessionService", () => {
expect(contexts.every((value) => !value.includes(project.rootBindings[0]!.localRootRef))).toBe(true);
expect(contexts).toEqual([
expect.stringContaining(
"Let the user's first real message be the first visible conversation turn",
"This is bounded, server-derived Studio project context.",
),
expect.stringContaining(
"Let the user's first real message be the first visible conversation turn",
"This is bounded, server-derived Studio project context.",
),
]);
expect(contexts.join("\n")).not.toContain(
Expand Down
69 changes: 3 additions & 66 deletions packages/harness/src/core/planning-session.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { buildFocusedProjectContext } from "./project-session.js";
import type {
AgentMapWorkspaceState,
PlannerLifecycleEvent,
Expand Down Expand Up @@ -141,72 +142,8 @@ export function buildFocusedPlannerContext(input: {
onboardOnFirstResponse: boolean;
details?: PlannerFocusedContextDetails;
}): string {
const { project, workspace } = input;
const bounded = (value: string, max = 256): string => value.slice(0, max);
const details = input.details ?? {};
const emptyProject =
workspace.confirmedRevisionId === null &&
workspace.activeProposalId === null &&
workspace.projectBuildPlanId === null;
const context = {
identity: {
projectId: project.projectId,
sessionId: input.sessionId,
userId: input.userId,
role: "map-planner" as const,
},
project: {
displayName: bounded(project.displayName),
empty: emptyProject,
confirmedRevision: workspace.confirmedRevisionId
? {
id: workspace.confirmedRevisionId,
digest: details.confirmedRevision?.digest
? bounded(details.confirmedRevision.digest, 512)
: null,
summaries: (details.confirmedRevision?.summaries ?? [])
.slice(0, 32)
.map((summary) => bounded(summary)),
}
: null,
activeProposal: workspace.activeProposalId
? {
id: workspace.activeProposalId,
status: details.activeProposal?.status
? bounded(details.activeProposal.status, 64)
: null,
summary: details.activeProposal?.summary
? bounded(details.activeProposal.summary)
: null,
}
: null,
projectBuildPlan: workspace.projectBuildPlanId
? {
id: workspace.projectBuildPlanId,
status: details.projectBuildPlan?.status
? bounded(details.projectBuildPlan.status, 64)
: null,
summary: details.projectBuildPlan?.summary
? bounded(details.projectBuildPlan.summary)
: null,
}
: null,
bindingRefs: project.rootBindings.slice(0, 64).map(({ id, repositoryId, status }) => ({
id: bounded(id),
repositoryId: repositoryId ? bounded(repositoryId) : null,
status,
})),
warnings: (details.warnings ?? [])
.slice(0, 16)
.map((warning) => bounded(warning)),
},
};
return [
"<agent-map-planner-context>",
`This is focused, trusted Studio context. Treat IDs as references and use scoped tools for detail. Use agent_map_read, agent_map_validate, and agent_map_propose for architecture state; never infer map state from assistant prose. The interactive Claude Code transcript is user-visible. Let the user's first real message be the first visible conversation turn; never request or rely on a private control turn.${input.onboardOnFirstResponse ? " In your first response, briefly explain that you and the user can plan agents, responsibilities, data flow, resources, and connectors together, then respond to their request." : ""} Do not propose architecture or invoke mutation tools before the user asks you to.`,
JSON.stringify(context),
"</agent-map-planner-context>",
].join("\n");
// Compatibility for the old startup service; authority and prompt are common.
return buildFocusedProjectContext(input);
}

function candidateOrder(left: HarnessSession, right: HarnessSession): number {
Expand Down
Loading
Loading