Skip to content
1 change: 1 addition & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## [Unreleased]

- Fixed resident daemon workers retaining their permitted launch environment across supervisor restarts while keeping client-owned credentials out of descriptor files.
- Added a configurable copy action to login dialogs so raw sign-in URLs can be copied without selecting wrapped text ([#643](https://github.com/PrimeIntellect-ai/prime-agent/issues/643)).
- Added privacy-safe pseudonymous product analytics for onboarding, command use, execution modes, run outcomes, TTFT, latency, usage, tools, retries, and compactions, with disclosure and opt-out controls ([ENG-4682](https://linear.app/primeintellect/issue/ENG-4682/add-privacy-safe-posthog-analytics-to-prime-agent)).
- Changed sent agent messages in the IPython cell UI to show only the message text with a `╰─` gutter when expanded, matching received messages, and hid the raw `agent_message.send` receipt dictionary.
Expand Down
30 changes: 23 additions & 7 deletions packages/coding-agent/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,15 @@ function toPrintOutputMode(appMode: AppMode): Exclude<Mode, "rpc" | "acp" | "dae
return appMode === "json" ? "json" : "text";
}

/**
* ACP sessions with a session file are resident so a subsequent --continue
* can reattach. Every other mode, and ACP --no-session, remains owned by the
* client that created it.
*/
export function isClientOwnedDaemonSession(appMode: AppMode, noSession?: boolean): boolean {
return appMode !== "acp" || noSession === true;
}

// `prime-agent agents` opens the agents view directly.
export function parseAgentsViewCommand(args: string[]): { explicitAgentsView: boolean; args: string[] } {
if (args[0] === "agents") {
Expand Down Expand Up @@ -955,11 +964,13 @@ async function createDaemonClientConnection(options: {
await client.connect();

try {
await client.waitForHello();
const clientOwned = options.clientOwned ?? false;
const attach = async (summary: SessionSummary) => {
const connection = await DaemonAgentConnection.attach(client, getDaemonSummaryActiveSessionId(summary), {
closeClientOnDispose: true,
sendClientEnv: true,
ownedSession: options.clientOwned,
ownedSession: clientOwned,
supportsExtensionUi: options.supportsExtensionUi,
recoverDaemon: () => ensureInteractiveDaemonRunning(options.socketPath),
telemetryDisabled: options.config.telemetryDisabled,
Expand All @@ -972,7 +983,7 @@ async function createDaemonClientConnection(options: {
return await attach(summary);
}

if (options.sessionPath && !options.clientOwned) {
if (options.sessionPath && !clientOwned) {
const activeSummary = findActiveDaemonSessionSummaryForSessionFile(
await listActiveDaemonSessionSummaries(client),
options.sessionPath,
Expand All @@ -981,8 +992,7 @@ async function createDaemonClientConnection(options: {
return await attach(activeSummary);
}
}
if (options.clientOwned) {
await client.waitForHello();
if (clientOwned) {
if (!client.supportsServerCapability("client_owned_sessions")) {
throw new DaemonCapabilityUnavailableError("create", "client_owned_sessions");
}
Expand All @@ -995,8 +1005,13 @@ async function createDaemonClientConnection(options: {
continueRecent: options.continueRecent,
noSession: options.noSession,
env: collectDaemonClientEnv(),
lifecycle: options.clientOwned ? "client_owned" : "resident",
launchEnv: options.clientOwned ? collectDaemonLaunchEnv() : undefined,
lifecycle: clientOwned ? "client_owned" : "resident",
// Forward the caller's environment for BOTH lifecycles. A resident
// worker still has to be launched with the caller's env: an embedder
// such as the verifiers ACP harness passes the model endpoint, its
// bearer token, and proxy settings that way, and a worker started
// without them cannot reach the model at all.
launchEnv: collectDaemonLaunchEnv(),
Comment thread
cursor[bot] marked this conversation as resolved.
});
if (!response.success) {
throw deserializeDaemonError(response);
Expand Down Expand Up @@ -1530,7 +1545,8 @@ export async function main(args: string[], options?: MainOptions) {
config: defaultSessionConfig,
sessionPath: parsed.noSession ? undefined : sessionManager.getSessionFile(),
continueRecent: parsed.continue,
clientOwned: true,
// A no-session ACP invocation has nothing to reattach to; complete its worker on disconnect.
clientOwned: isClientOwnedDaemonSession(appMode, parsed.noSession),
noSession: parsed.noSession,
supportsExtensionUi: appMode === "rpc",
}));
Expand Down
51 changes: 51 additions & 0 deletions packages/coding-agent/src/modes/daemon/daemon-protocol.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core";
import type { ImageContent, ServiceTier, TextContent, Transport } from "@earendil-works/pi-ai";
import { ENV_AGENT_DIR } from "../../config.js";
import type {
AgentSessionMessageDeliveryMode,
AgentSessionMessageReceipt,
Expand Down Expand Up @@ -198,6 +199,56 @@ export function collectDaemonClientEnv(source: NodeJS.ProcessEnv = process.env):
return Object.keys(env).length > 0 ? env : undefined;
}

/**
* Non-secret launch settings that may survive a supervisor restart in a
* resident worker descriptor. Model credentials deliberately do not belong
* here: the first worker launch inherits the caller environment, but a JSON
* descriptor must never become an at-rest copy of a caller's credentials.
*/
export const DAEMON_PERSISTED_LAUNCH_ENV_KEYS = [
// Process/runtime locations needed to relaunch the same installed CLI.
"HOME",
"PATH",
"TMPDIR",
"TMP",
"TEMP",
"XDG_CACHE_HOME",
"XDG_CONFIG_HOME",
"XDG_DATA_HOME",
"XDG_RUNTIME_DIR",
"XDG_STATE_HOME",
// ENV_AGENT_DIR is the current application's configurable agent directory.
// PI_CODING_AGENT_DIR remains for compatibility with the upstream CLI.
ENV_AGENT_DIR,
"PI_CODING_AGENT_DIR",
// Deliberately non-secret Prime Agent behavior, telemetry, and package settings.
"PI_OFFLINE",
"PI_PACKAGE_DIR",
"PI_SKIP_VERSION_CHECK",
"DO_NOT_TRACK",
"PRIME_AGENT_TELEMETRY",
"PRIME_AGENT_TELEMETRY_ENDPOINT",
"PRIME_AGENT_TRACES_BASE_URL",
"PRIME_AGENT_DOWNLOAD_BASE_URL",
] as const;

/** Select the explicitly non-secret launch settings safe to persist on disk. */
export function filterPersistedDaemonLaunchEnv(
source: Readonly<Record<string, string>> | undefined,
): Record<string, string> | undefined {
if (!source) return undefined;
const env: Record<string, string> = {};
for (const key of DAEMON_PERSISTED_LAUNCH_ENV_KEYS) {
const value = source[key];
if (value !== undefined) env[key] = value;
}
return Object.keys(env).length > 0 ? env : undefined;
}

/**
* Collect the caller environment for the initial worker spawn. The
* supervisor filters it before it is written to a resident-worker descriptor.
*/
export function collectDaemonLaunchEnv(source: NodeJS.ProcessEnv = process.env): Record<string, string> {
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(source)) {
Expand Down
58 changes: 50 additions & 8 deletions packages/coding-agent/src/modes/daemon/daemon-supervisor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import { DAEMON_CATALOG_ROLE_ENV, DaemonCatalogClient } from "./daemon-catalog-p
import { deserializeDaemonError, serializeDaemonError } from "./daemon-errors.js";
import {
collectDaemonClientEnv,
collectDaemonLaunchEnv,
createDaemonEventMeta,
DAEMON_COMMAND_COMPATIBILITY,
DAEMON_COMMAND_ENVELOPE_MIN_PROTOCOL_VERSION,
Expand All @@ -71,6 +72,7 @@ import {
type DaemonResponse,
type DaemonUpdateRestartManifest,
failure,
filterPersistedDaemonLaunchEnv,
isDaemonCommandEnvelope,
isDaemonMutatingCommand,
salvageDaemonCommandId,
Expand Down Expand Up @@ -416,6 +418,12 @@ function isDaemonWorkerDescriptor(value: unknown, socketPath: string): value is
(descriptor.pid ?? 0) > 0 &&
(descriptor.processStartId === undefined || typeof descriptor.processStartId === "string") &&
(descriptor.ownerClientId === undefined || typeof descriptor.ownerClientId === "string") &&
(descriptor.launchEnv === undefined ||
(typeof descriptor.launchEnv === "object" &&
descriptor.launchEnv !== null &&
Object.entries(descriptor.launchEnv).every(
([key, value]) => typeof key === "string" && typeof value === "string",
))) &&
typeof descriptor.socketPath === "string" &&
typeof descriptor.authenticationToken === "string" &&
typeof descriptor.rootActiveSessionId === "string" &&
Expand Down Expand Up @@ -923,10 +931,12 @@ export class DaemonSupervisor {
if (!isDaemonWorkerDescriptor(descriptor, this.socketPath)) {
continue;
}
const storedLaunchEnv = descriptor.launchEnv;
descriptor.launchEnv = filterPersistedDaemonLaunchEnv(storedLaunchEnv);
descriptor.lifecycle = "recovering";
descriptor.recoveryJournalPath ??= join(this.descriptorDir, `${descriptor.workerId}.recovery.jsonl`);
descriptor.orphanProcessJournalPath ??= join(this.descriptorDir, `${descriptor.workerId}.orphans.jsonl`);
this.workers.set(descriptor.workerId, {
const worker: ResidentWorker = {
descriptor,
descriptorPath: path,
summaries: new Map(),
Expand All @@ -936,7 +946,12 @@ export class DaemonSupervisor {
snapshotLoads: new Map(),
intentionalStop: descriptor.stopRequestedAt !== undefined,
stopRevision: 0,
});
launchEnv: descriptor.launchEnv,
};
this.workers.set(descriptor.workerId, worker);
if (JSON.stringify(storedLaunchEnv) !== JSON.stringify(descriptor.launchEnv)) {
this.persistWorker(worker);
}
} catch (error) {
this.log(`Ignoring invalid worker descriptor ${path}: ${String(error)}`);
}
Expand Down Expand Up @@ -2070,7 +2085,7 @@ export class DaemonSupervisor {
throw new Error("Session is not owned by this client");
}
const previousDescriptor = worker.descriptor;
worker.descriptor = { ...previousDescriptor, ownerClientId: undefined };
worker.descriptor = { ...previousDescriptor, ownerClientId: undefined, launchEnv: undefined };
try {
this.persistWorker(worker);
} catch (error) {
Expand All @@ -2096,8 +2111,29 @@ export class DaemonSupervisor {
throw new Error(`Session worker ${existing.descriptor.workerId} recovery was cancelled`);
}
const recoveryStopRevision = existing?.stopRevision;
const launchEnv =
ownerClientId || existing?.descriptor.ownerClientId ? (command.launchEnv ?? existing?.launchEnv) : undefined;
const ownerClientIdForDescriptor = existing?.descriptor.ownerClientId ?? ownerClientId;
// Only a first resident launch consumes the caller's full transient
// environment. A resident recovery uses the descriptor's allowlisted copy
// even while the old worker object still exists in memory. Client-owned
// workers are different: their reconnecting owner supplies fresh transient
// launch settings, which are never written to a descriptor.
const launchEnv = existing
? ownerClientIdForDescriptor === undefined
? existing.descriptor.launchEnv
: existing.launchEnv
: command.launchEnv;
// Only non-secret, explicitly allowed settings are durable. The initial
// spawn may still receive caller credentials through launchEnv, but those
// credentials must never be serialized into a worker descriptor.
const persistedLaunchEnv = filterPersistedDaemonLaunchEnv(launchEnv);
// A replacement supervisor can itself have been restarted by the old worker
// and therefore inherit that worker's original credentials. Automatic
// resident recovery must not copy those ambient secrets into the replacement
// worker. Client-owned recovery instead uses its live owner's transient env.
const inheritedEnv =
existing && ownerClientIdForDescriptor === undefined
? filterPersistedDaemonLaunchEnv(collectDaemonLaunchEnv(process.env))
: process.env;
const createCommand: DaemonCreateCommand = {
...withoutSupervisorCreateFields(command),
config: mergeAgentSessionRuntimeConfig(this.defaultSessionConfig, command.config),
Expand All @@ -2118,7 +2154,7 @@ export class DaemonSupervisor {
cwd: createCommand.config?.cwd ?? process.cwd(),
detached: true,
env: createCliSubprocessEnv({
...process.env,
...inheritedEnv,
...launchEnv,
[DAEMON_WORKER_ROLE_ENV]: "1",
[DAEMON_WORKER_TOKEN_ENV]: token,
Expand Down Expand Up @@ -2174,7 +2210,10 @@ export class DaemonSupervisor {
supervisorSocketPath: this.socketPath,
authenticationToken: token,
rootActiveSessionId,
ownerClientId: existing?.descriptor.ownerClientId ?? ownerClientId,
ownerClientId: ownerClientIdForDescriptor,
...(ownerClientIdForDescriptor === undefined && persistedLaunchEnv
? { launchEnv: persistedLaunchEnv }
: {}),
createdAt: existing?.descriptor.createdAt ?? now,
updatedAt: now,
lifecycle: "starting",
Expand All @@ -2195,7 +2234,10 @@ export class DaemonSupervisor {
};
await this.assertRecoveryAllowed();
worker.descriptor = descriptor;
worker.launchEnv = launchEnv;
// Resident workers retain only the durable allowlist even in memory, so an
// automatic same-supervisor recovery cannot resurrect initial credentials.
// Client-owned workers may retain a fresh owner's transient environment.
worker.launchEnv = ownerClientIdForDescriptor === undefined ? persistedLaunchEnv : launchEnv;
descriptorAssigned = true;
this.persistWorker(worker);
worker.intentionalStop = false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ export interface DaemonWorkerDescriptor {
rootActiveSessionId: string;
/** Stable protocol client that owns this worker. Omitted for resident sessions. */
ownerClientId?: string;
/** Environment required to relaunch a resident worker after supervisor restart. */
launchEnv?: Record<string, string>;
rootSessionId?: string;
sessionFile?: string;
createdAt: string;
Expand Down
Loading
Loading