Skip to content

Commit 3becbe7

Browse files
committed
feat(sdk): isolate caller-owned daemon sessions
1 parent 70b6530 commit 3becbe7

21 files changed

Lines changed: 2557 additions & 71 deletions

.github/workflows/ci.yml

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,18 +188,46 @@ jobs:
188188
working-directory: ${{ matrix.package }}
189189
run: ${{ matrix.command }}
190190

191+
owned-session-contract-windows:
192+
name: Caller-owned session contract (Windows named pipe)
193+
needs: trust
194+
if: needs.trust.outputs.allowed == 'true'
195+
runs-on: windows-latest
196+
timeout-minutes: 10
197+
steps:
198+
- name: Checkout
199+
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
200+
with:
201+
ref: ${{ inputs.checkout_ref || github.ref }}
202+
persist-credentials: false
203+
204+
- name: Setup Node.js
205+
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
206+
with:
207+
node-version: 22
208+
cache: npm
209+
210+
- name: Install dependencies
211+
run: npm ci
212+
213+
- name: Test named-pipe owned cleanup
214+
working-directory: packages/coding-agent
215+
run: npx tsx ../../node_modules/vitest/dist/cli.js --run test/daemon-supervisor-process.test.ts -t "proves exact owned cleanup over a Windows named pipe"
216+
191217
build-check-test:
192218
name: build-check-test
193219
if: always() && (needs.trust.outputs.allowed == 'true' || inputs.require_trusted)
194-
needs: [trust, build-check, test]
220+
needs: [trust, build-check, test, owned-session-contract-windows]
195221
runs-on: ubuntu-latest
196222
steps:
197223
- name: Verify CI results
198224
env:
199225
TRUST_ALLOWED: ${{ needs.trust.outputs.allowed }}
200226
BUILD_CHECK_RESULT: ${{ needs.build-check.result }}
201227
TEST_RESULT: ${{ needs.test.result }}
228+
WINDOWS_OWNED_SESSION_RESULT: ${{ needs.owned-session-contract-windows.result }}
202229
run: |
203230
test "$TRUST_ALLOWED" = true
204231
test "$BUILD_CHECK_RESULT" = success
205232
test "$TEST_RESULT" = success
233+
test "$WINDOWS_OWNED_SESSION_RESULT" = success

.pylon/features.yaml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,3 +265,19 @@ decisions:
265265
revisit_when:
266266
- Prime upstream exposes an equivalent generation-scoped, post-attach public proof that is false before attach and after invalidation.
267267
- Pylon and Comet can remove the fork SDK token/accessor without enabling optional behavior from a server offer, version, or method presence.
268+
269+
caller-owned-session-environment-cleanup:
270+
area: runtime-reliability
271+
state: shipped
272+
owner: shared
273+
decision: retain
274+
pylon_refs:
275+
- https://github.com/pylon-code/prime-agent/issues/33
276+
- https://github.com/pylon-code/pylon/issues/199
277+
upstream_refs:
278+
- https://github.com/PrimeIntellect-ai/prime-agent/tree/a903d4b6768f484bd6d459b7b0aa7dee38e461e2
279+
fork_change: caller-owned-session-environment-cleanup-v1
280+
upstream_support: Prime upstream does not expose a frozen caller-owned exact-environment contract, secret-free current-attachment proof, or bounded structured cleanup result that survives worker and supervisor replacement without weakening owner isolation.
281+
revisit_when:
282+
- Prime upstream exposes an equivalent public-root token, exact caller environment reuse, generation-scoped post-attach proof, and structured bounded cleanup outcomes.
283+
- Pylon can remove the fork token without weakening native multi-instance isolation or its pre-create ACP fallback.

.pylon/upstream-review.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,3 +118,12 @@ This ledger records Prime upstream evidence and the decision taken for each over
118118
- Large-transcript handling prepares one immutable payload, bounds framing and drain waits, avoids quadratic private-buffer shifting, and preserves spill ownership and cleanup across cancellation, crash, and stale generations. Stock/current `v0.8.1` supervisor and worker directions retain their mixed-version fallback.
119119
- The pre-ledger source candidate `8b504e3774875c241c5d0d3b4b588a09f4aa3f8e` passed `npm run check`, package build, 246 conflict-affected exact-head tests after rebase, 16 real supervisor-process tests with 8 fixture-gated skips, stock/current compatibility in both directions, a 36 MiB exact-package transfer, a 10,000-message preparation probe, a 131,000-fragment framing probe, and two independent adversarial reviews. The ledger correction changes the exact head and therefore requires renewed targeted checks and hosted CI before merge.
120120
- Revisit when Prime upstream supplies the same capability-gated fresh-generation identity, attachment-local retry containment, mixed-version behavior, and bounded preparation/framing guarantees without weakening Pylon's correlated lifecycle or cleanup contracts.
121+
122+
## 2026-08-31 — caller-owned session environment and cleanup contract candidate
123+
124+
- Prime issue [#33](https://github.com/pylon-code/prime-agent/issues/33) freezes `caller_owned_session_environment_cleanup_v1` for the Pylon [#199](https://github.com/pylon-code/pylon/issues/199) native multi-instance gate. The public-root SDK token plus the daemon hello offers for the contract and authoritative cleanup form the preflight that selects native before create. Native attach must then return a current successful proof; a missing post-attach proof triggers bounded native cleanup and fails closed rather than pretending ACP cleaned a partial native create.
125+
- Opted-in client-owned workers use one validated caller snapshot as the exact launch environment across create, attach fallback, worker recovery, and supervisor replacement. Legacy callers keep ambient merge behavior and never advertise the contract. Durable worker state stores only a non-secret contract marker, never the snapshot or an environment identity.
126+
- Public proof is limited to protocol/app/build and supervisor/transport generations. Observable cleanup is single-flight and bounded, with fixed completed, already-completed, replacement-settled, owner-mismatch, uncertain, transport-failure, and unsupported outcomes. Arbitrary owners still cannot attach to or complete another owner's worker.
127+
- The additive wire change keeps protocol version 7 and advances the schema revision from 28 to 29. Mixed-version clients remain usable through the legacy path when opt-in is absent.
128+
- Local validation covers protocol and environment validation, old/new offer and echo compatibility, two-owner isolation, worker recovery, supervisor replacement, proof invalidation/republishing, wrong-owner denial, descriptor/log redaction, and cleanup. The Windows named-pipe case is committed but must run on the hosted `windows-latest` gate before merge.
129+
- Cross-repository order remains Prime #33 and a reproducible package artifact first, then Pylon #199 consuming the exact proof. Revisit when upstream provides an equivalent frozen contract and Pylon can remove the fork token without weakening its ACP fallback.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- Added a negotiated caller-owned session environment proof and observable bounded cleanup outcomes for daemon SDK hosts. ([#33](https://github.com/pylon-code/prime-agent/issues/33))

packages/coding-agent/docs/sdk.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,24 @@ if (!connection.supportsNegotiatedCapability("correlated_prompt_lifecycle_v1"))
7777

7878
`supportsNegotiatedCapability()` is false before attach, while a new attach or reattach is pending, after transport or attachment invalidation, and after disposal. It becomes true only after the same physical transport returns a validated client capability echo and the exact snapshot commit succeeds. `supportsCorrelatedPromptLifecycle()` remains server-offer evidence used to construct the attach request. It is not negotiation proof. Do not substitute a hello offer, method presence, attach success, or package version for the post-attach accessor. Correlated runtime frames are withheld until the attach-side echo commits and are discarded when the echo omits the capability. Pre-proof retention is bounded by both frame count and conservative cumulative structural weight; overflow fails the adapter closed without retaining or reporting attributed payload content. A chunked replacement uses the same count and weight bounds for frames held behind its atomic snapshot fence. New same-connection attachment admission, attachment-epoch change, transport loss, disposal, or matching session close retires that old fence before any later proof can publish; delayed old snapshot frames are ignored until a fresh attachment commits.
7979

80+
### Caller-owned daemon session environments and cleanup
81+
82+
Native multi-instance hosts must gate caller-owned daemon sessions with the exact `caller_owned_session_environment_cleanup_v1` contract. Require all three proofs:
83+
84+
1. `PRIME_AGENT_SDK_FEATURES` from the package root includes the token.
85+
2. The connected daemon hello offers both the same token and `authoritative_owned_session_cleanup_v1`.
86+
3. `connection.getOwnedSessionContractProof()` returns the current post-attach proof.
87+
88+
If either package-root or daemon-offer preflight fails, select ACP fallback before sending the native client-owned `create`. ACP fallback never cleans up a partially created native session. If the later attach does not return the current proof, fail native admission, run bounded native cleanup, and report that failure; do not treat ACP as retroactive cleanup for that partial native create. Do not infer support from a package version, method presence, constructor shape, protocol version, schema revision, or daemon offer alone.
89+
90+
Pass one defensively captured `ownedSessionLaunchEnv` and a fresh `ownedSessionRecoveryConfig` to `DaemonAgentConnection`. Recovery config is required whenever the exact environment option is present; omitting it fails synchronously before attach. Use that same environment snapshot and `launchEnvMode: "replace"` on the preceding client-owned `create` request. The SDK validates and clones the option synchronously. A negotiated daemon uses the snapshot as the worker's whole caller-owned environment, adds only Prime-owned worker bootstrap variables, and reuses it with the recovery config for attach fallback and recovery. Without the exact options and negotiated token, legacy environment merging remains unchanged and the connection does not advertise this contract.
91+
92+
The contract proof contains only the feature/status, protocol identity, schema revision, app/build identity, supervisor generation, and SDK-local transport generation. It is absent before attach, during reattach, after transport or attachment invalidation, after promotion/disposal, and for unproved peers. It never contains the environment, home, path, secret, hash, fingerprint, PID, socket, owner token, or canary.
93+
94+
Use `disposeOwnedSession({ timeoutMs })` when cleanup must be observable. Concurrent calls join one operation, and `timeoutMs` is one strict total deadline for reconnection, authoritative queries, completion, side-question aborts, and unsupported-peer finalization. Its fixed statuses are `completed`, `already_completed`, `replacement_settled`, `owner_mismatch`, `uncertain`, `transport_failure`, and `unsupported`; `uncertain` also reports whether the last authoritative state was `active` or `stopping`. `replacement_settled` means an authenticated supervisor with a different generation answered the read-only cleanup query with `settled` for the connection's previously proved opaque route. Cleanup never sends completion on a replacement or pending route without a current internal attach proof. Each strict cleanup request is transport-bound and is never replayed after reconnect. Public attachment proof remains absent throughout disposal. Results never return raw errors or environment identity. `dispose()` remains the legacy best-effort `Promise<void>` API.
95+
96+
`DaemonClient.request()` keeps legacy reconnect replay by default. Pass `{ recoverAcrossReconnect: false }` only when one request must fail on transport close instead of crossing to a new daemon transport.
97+
8098
`DaemonClient.close()` is terminal owner disposal. `isClosed` becomes true, later `connect()` calls reject, and a live `DaemonAgentConnection` emits one terminal close. Normal and update recovery stop before any later restart, connect, attach, or restored-session query; already-running recovery callbacks are not cancellable but their results are discarded.
8199

82100
`DaemonClientOptions.maxInboundFrameBytes` is the maximum raw bytes before LF in one inbound JSONL frame. It defaults to `DEFAULT_DAEMON_CLIENT_MAX_INBOUND_FRAME_BYTES` (128 MiB) and must be a positive safe integer. LF is excluded. A CR immediately before LF is counted and then stripped.

packages/coding-agent/src/index.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,11 @@ export {
293293
type AgentConnectionSlashCommand,
294294
type AgentConnectionState,
295295
DaemonAgentConnection,
296+
type DaemonAgentConnectionOptions,
297+
type DaemonOwnedSessionContractProof,
298+
type DaemonOwnedSessionDaemonIdentity,
299+
type DaemonOwnedSessionDisposeOptions,
300+
type DaemonOwnedSessionDisposeResult,
296301
type ExpiredPromptLifecycle,
297302
InProcessAgentConnection,
298303
type PromptEventAttribution,
@@ -318,6 +323,7 @@ export {
318323
type DaemonClientId,
319324
type DaemonClientMessageListener,
320325
type DaemonClientOptions,
326+
type DaemonClientRequestOptions,
321327
type DaemonCommand,
322328
type DaemonCommandEnvelope,
323329
type DaemonCommandId,
@@ -330,6 +336,8 @@ export {
330336
type DaemonOutbound,
331337
type DaemonOwnedSessionCleanupResult,
332338
type DaemonOwnedSessionCleanupStatus,
339+
type DaemonOwnedSessionCompletionResult,
340+
type DaemonOwnedSessionCompletionStatus,
333341
type DaemonProtocolInfo,
334342
type DaemonProtocolName,
335343
type DaemonProtocolVersion,
@@ -413,7 +421,11 @@ export {
413421
Theme,
414422
type ThemeColor,
415423
} from "./modes/interactive/theme/theme.js";
416-
export { PRIME_AGENT_SDK_FEATURES, type PrimeAgentSdkFeature } from "./sdk-features.js";
424+
export {
425+
CALLER_OWNED_SESSION_ENVIRONMENT_CLEANUP_FEATURE,
426+
PRIME_AGENT_SDK_FEATURES,
427+
type PrimeAgentSdkFeature,
428+
} from "./sdk-features.js";
417429
// Clipboard utilities
418430
export { copyToClipboard } from "./utils/clipboard.js";
419431
export { parseFrontmatter, stripFrontmatter } from "./utils/frontmatter.js";

packages/coding-agent/src/main.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,11 @@ import { printTimings, resetTimings, time } from "./core/timings.js";
7575
import { runMigrations, showDeprecationWarnings } from "./migrations.js";
7676
import { isDaemonCatalogProcess, runDaemonCatalogProcess } from "./modes/daemon/daemon-catalog-process.js";
7777
import { deserializeDaemonError } from "./modes/daemon/daemon-errors.js";
78-
import { collectDaemonClientEnv, collectDaemonLaunchEnv } from "./modes/daemon/daemon-protocol.js";
78+
import {
79+
cloneCallerOwnedSessionLaunchEnv,
80+
collectDaemonClientEnv,
81+
collectDaemonLaunchEnv,
82+
} from "./modes/daemon/daemon-protocol.js";
7983
import {
8084
DAEMON_WORKER_ACTIVE_SESSION_ID_ENV,
8185
isDaemonWorkerProcess,
@@ -111,6 +115,7 @@ import { ExtensionSelectorComponent } from "./modes/interactive/components/exten
111115
import { shouldRunOnboarding } from "./modes/interactive/onboarding.js";
112116
import { initTheme, preloadCodeHighlighter, stopThemeWatcher } from "./modes/interactive/theme/theme.js";
113117
import { handleConfigCommand } from "./package-manager-cli.js";
118+
import { CALLER_OWNED_SESSION_ENVIRONMENT_CLEANUP_FEATURE } from "./sdk-features.js";
114119
import { isLocalPath } from "./utils/paths.js";
115120

116121
/**
@@ -956,17 +961,28 @@ async function createDaemonClientConnection(options: {
956961
noSession?: boolean;
957962
supportsExtensionUi?: boolean;
958963
}): Promise<{ connection: DaemonAgentConnection; summary: SessionSummary }> {
964+
// Capture before the first await so attach and every later recovery use one caller-owned snapshot.
965+
const callerOwnedLaunchEnv =
966+
options.clientOwned && options.activeSessionId === undefined
967+
? cloneCallerOwnedSessionLaunchEnv(collectDaemonLaunchEnv())
968+
: undefined;
959969
// Caller must have awaited ensureInteractiveDaemonRunning for this socket.
960970
const client = new DaemonClient(options.socketPath);
961971
await client.connect();
962972

963973
try {
974+
await client.waitForHello();
975+
const supportsCallerOwnedEnvironmentContract =
976+
callerOwnedLaunchEnv !== undefined &&
977+
client.supportsServerCapability(CALLER_OWNED_SESSION_ENVIRONMENT_CLEANUP_FEATURE) &&
978+
client.supportsServerCapability("authoritative_owned_session_cleanup_v1");
964979
const attach = async (summary: SessionSummary) => {
965980
const connection = await DaemonAgentConnection.attach(client, getDaemonSummaryActiveSessionId(summary), {
966981
closeClientOnDispose: true,
967982
sendClientEnv: true,
968983
ownedSession: options.clientOwned,
969984
ownedSessionRecoveryConfig: options.clientOwned ? options.config : undefined,
985+
ownedSessionLaunchEnv: supportsCallerOwnedEnvironmentContract ? callerOwnedLaunchEnv : undefined,
970986
supportsExtensionUi: options.supportsExtensionUi,
971987
recoverDaemon: () => ensureInteractiveDaemonRunning(options.socketPath),
972988
telemetryDisabled: options.config.telemetryDisabled,
@@ -989,7 +1005,6 @@ async function createDaemonClientConnection(options: {
9891005
}
9901006
}
9911007
if (options.clientOwned) {
992-
await client.waitForHello();
9931008
if (!client.supportsServerCapability("client_owned_sessions")) {
9941009
throw new DaemonCapabilityUnavailableError("create", "client_owned_sessions");
9951010
}
@@ -1003,7 +1018,10 @@ async function createDaemonClientConnection(options: {
10031018
noSession: options.noSession,
10041019
env: collectDaemonClientEnv(),
10051020
lifecycle: options.clientOwned ? "client_owned" : "resident",
1006-
launchEnv: collectDaemonLaunchEnv(),
1021+
launchEnv: supportsCallerOwnedEnvironmentContract
1022+
? (callerOwnedLaunchEnv as Record<string, string>)
1023+
: collectDaemonLaunchEnv(),
1024+
launchEnvMode: supportsCallerOwnedEnvironmentContract ? "replace" : undefined,
10071025
});
10081026
if (!response.success) {
10091027
throw deserializeDaemonError(response);

0 commit comments

Comments
 (0)