@cotal-ai/manager 0.20.1 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/manager.js CHANGED
@@ -3,7 +3,7 @@ import { createHash, randomUUID, randomBytes } from "node:crypto";
3
3
  import { connect, credsAuthenticator } from "@nats-io/transport-node";
4
4
  import { existsSync, lstatSync, readFileSync, rmSync } from "node:fs";
5
5
  import { join, dirname, resolve } from "node:path";
6
- import { CotalEndpoint, DEFAULT_SERVER, DEV_OWNER, MANAGER_LEASE_TTL_MS, MANAGER_LEASE_RENEW_MS, STANDING_RENEWABLE_TTL_SEC, agentFilePath, clearSpaceHistory, connectorServers, deprovisionAgent, firstFreeName, idFromCreds, inspectCredHealth, loadAgentFile, loadCotalConfig, mintCreds, mintLifecycleUid, mkSecretDir, newIdentity, actionContext, parsePrincipalKey, parseShareSelection, principalKey, probeConnect, provisionAgent, provisionAgentDurables, registry, resolveAuthProvider, saveAgentFile, subjectMatches, AUTH_ENDPOINT, EP_CMD_RETIRE_LIFECYCLE, epRequestSubject, epCallerReplyFilter, parseEpSubject, controlServiceSubject, } from "@cotal-ai/core";
6
+ import { CotalEndpoint, DEFAULT_SERVER, DEV_OWNER, MANAGER_LEASE_TTL_MS, MANAGER_LEASE_RENEW_MS, STANDING_RENEWABLE_TTL_SEC, agentFilePath, clearSpaceHistory, connectorServers, deprovisionAgent, firstFreeName, idFromCreds, inspectCredHealth, loadAgentFile, loadCotalConfig, mintCreds, mintLifecycleUid, mkSecretDir, newIdentity, actionContext, parsePrincipalKey, parseShareSelection, principalKey, probeConnect, provisionAgent, provisionAgentDurables, registry, resolveAuthProvider, saveAgentFile, subjectMatches, AUTH_ENDPOINT, EP_CMD_RETIRE_LIFECYCLE, epRequestSubject, epCallerReplyFilter, parseEpSubject, controlServiceSubject, eventChannelPrincipal, } from "@cotal-ai/core";
7
7
  import { agentAuthState, agentCredsDir, agentLifecycleSecretFilePaths, agentSecretFilePaths, agentSecretKeyForFile, authDir, connectorInstallHint, DEFAULT_CONNECTOR, defaultAgentType, DELIVERY_CREDS_KEY, findCotalRoot, getSpaceAuth, hasUserAuthState, loadManagerInstanceIdentity, loadMeshes, manifestExtensionNames, materializeFromManifest, materializeSecretToFile, MEMBERSHIP_RW_CREDS_KEY, mergeLaunchOptions, remintDaemonCreds, resolveOnPath, saveManagerInstanceIdentity, SYSTEM_CREDS_FILES, userAuthStateDir, workspaceSecretStore, writeRenewalRecord } from "@cotal-ai/workspace";
8
8
  import { createRuntime, } from "./runtime/index.js";
9
9
  import { AttachEndpoint } from "./attach-endpoint.js";
@@ -146,6 +146,32 @@ export async function epAwaitReply(nc, space, caller, nonce, requestId, requestS
146
146
  * through a pluggable {@link Runtime} (pty by default). It does NOT proxy agent
147
147
  * mesh traffic — terminal I/O streams over its own attach endpoint instead.
148
148
  */
149
+ /**
150
+ * Event channels in `channels` that do NOT belong to `{owner, actor}`.
151
+ *
152
+ * ONE HELPER FOR EVERY SEAM THAT ARMS AN ACL, and that is the whole design. The rule first existed
153
+ * only at the spawn accept seam, and a security review found the hole that shape guarantees: resume
154
+ * re-arms a managed row straight from the inventory document, and `renewManagedStaticCred` re-mints
155
+ * the JWT from that row, so an admin-supplied inventory could carry a foreign concrete event channel
156
+ * past a fence that only ever looked at spawns. A rule with one call site is a rule that covers one
157
+ * door.
158
+ *
159
+ * IT COMPARES PRINCIPALS, NOT STRINGS. `eventChannelPrincipal` decodes the channel back to the
160
+ * `{owner, actor}` it names, so this is mode-independent: static keys the actor on the allocated
161
+ * nkey and user mode keys it on the alias, and neither needs its own branch here.
162
+ *
163
+ * STATED LIMIT, unchanged and asserted by a cell: the decode refuses anything that is not exactly
164
+ * two principal tokens, so a WILDCARD is not an event channel to it. `events.<owner>.>` and
165
+ * `events.>` pass untouched and are governed by ordinary ACL authority. This closes the concrete
166
+ * form, which is what a caller writes when it knows which agent it wants to read, and not the
167
+ * wildcard form, which is what an operator writes deliberately for an observer.
168
+ */
169
+ function foreignEventChannels(channels, owner, actor) {
170
+ return channels.filter((ch) => {
171
+ const p = eventChannelPrincipal(ch);
172
+ return p !== null && !(p.owner === owner && p.actor === actor);
173
+ });
174
+ }
149
175
  export class Manager {
150
176
  space;
151
177
  servers;
@@ -366,6 +392,44 @@ export class Manager {
366
392
  get consoleUrl() {
367
393
  return this.attach.consoleUrl();
368
394
  }
395
+ /**
396
+ * The out-of-band route for the mesh the refusal is running on, as ONE paste-ready command.
397
+ *
398
+ * **A REMEDY A REFUSAL PRINTS IS AUTHORITY THE REFUSAL LENDS, AND BOTH HALVES OF THIS ONE WERE
399
+ * WIDER THAN THE SENTENCE AROUND THEM.** The static half named `--profile observer`, and `mint`
400
+ * reads `--allow-subscribe` only for the agent profile while the observer arm of `permissionsFor`
401
+ * hardcodes `chat.>`: an operator narrowing a reader to one event plane was handed a reader of every
402
+ * channel in the space. The user half named a bare `cotal actor grant`, and an omitted flag there is
403
+ * not "leave it alone" but the WIDE default (`>` read, `>` post, `spawn,role:default` scope), so the same
404
+ * sentence handed out a full-mesh reader-writer with spawn. The static half was found by RUNNING
405
+ * the printed command and decoding what it produced; the user half was found by READING it against
406
+ * `runActor`'s defaults. Neither is the only way in, and a remedy string is not proved by either
407
+ * one alone.
408
+ *
409
+ * So the command is spelled out in full and only ONE is printed: the one for the mesh this manager
410
+ * is actually running, because a sentence carrying both routes is a sentence an operator picks the
411
+ * wrong half of. `smoke:events-grant` section 9 runs the static half and grades the credential;
412
+ * `smoke:user-spawn:live` section E runs the user half and grades the row.
413
+ *
414
+ * It takes NO mode argument, on purpose. It used to, and the resume door passed the resumed
415
+ * DOCUMENT's (`entry.identity.mode === "user"`) rather than the manager's: those agree on an honest
416
+ * inventory and disagree on the shape section 8 exercises, a user-mode record under a static
417
+ * manager, where it handed a static operator `cotal actor grant` for a mesh with no actor ledger to
418
+ * write it to. The operator reading the refusal is on the manager's mesh, never on the record's. A
419
+ * boolean parameter is how that happened, so the mode is read from `this` and a third door cannot
420
+ * pass the wrong one.
421
+ */
422
+ readerRemedy(owner, channel) {
423
+ return this.userMode
424
+ ? `\`cotal actor grant <reader> --owner ${owner} --scope '' --allow-subscribe '${channel}' ` +
425
+ `--allow-publish ''\`, with every field spelled out: \`actor grant\` is an upsert of the ` +
426
+ `WHOLE row and an omitted flag means the WIDE default (\`>\` read, \`>\` post, \`spawn,role:default\` ` +
427
+ `scope), not "leave it alone".`
428
+ : `\`cotal mint <reader> --profile agent --allow-subscribe ${channel} --provision\`, where there ` +
429
+ `is no actor ledger for \`actor grant\` to write to. The AGENT profile, not the observer ` +
430
+ `one: \`mint\` reads --allow-subscribe only for that profile, so an observer mint is ` +
431
+ `refused outright and writes no creds file.`;
432
+ }
369
433
  async start() {
370
434
  await this.attach.start();
371
435
  // In auth mode the manager is just another user in the space's account — it mints
@@ -1051,7 +1115,7 @@ export class Manager {
1051
1115
  allowSubscribe: a.launch.allowSubscribe,
1052
1116
  allowPublish: a.launch.allowPublish,
1053
1117
  capabilities: a.launch.capabilities,
1054
- transcript: a.launch.transcript,
1118
+ events: a.launch.events,
1055
1119
  shareTools: a.launch.shareTools,
1056
1120
  forkSource: a.launch.forkSource,
1057
1121
  unresolvedLaunchOptionKeys: a.launch.unresolvedLaunchOptionKeys,
@@ -1625,6 +1689,16 @@ export class Manager {
1625
1689
  throw new EpEnvelopeError("permission-denied", denied);
1626
1690
  return unwrap(await this.attachAuthorized(a, ctx.subject.caller));
1627
1691
  }),
1692
+ // C3 `input`: the SAME authorization as `attach`, deliberately written out rather than
1693
+ // factored with it - the two share a policy, not a body, and a shared wrapper would be a
1694
+ // place for one of them to quietly acquire a condition the other does not have.
1695
+ input: (ctx) => this.serveGated(ctx, async () => {
1696
+ const a = targetAgent(ctx);
1697
+ const denied = await this.authorizeNamed(a, callerOf(ctx), await this.epAnyModeAdmin(ctx));
1698
+ if (denied)
1699
+ throw new EpEnvelopeError("permission-denied", denied);
1700
+ return this.inputAuthorized(a, args(ctx));
1701
+ }),
1628
1702
  stopSelf: (ctx) => this.serveGated(ctx, () => unwrap(this.opStopSelf(callerOf(ctx), args(ctx)))),
1629
1703
  definePersona: (ctx) => this.serveGated(ctx, () => unwrap(this.opDefinePersona(args(ctx), callerOf(ctx), false))),
1630
1704
  purge: (ctx) => this.serveGated(ctx, () => adminGated(ctx, async () => unwrap(await this.opPurge(args(ctx), callerOf(ctx))))),
@@ -2362,7 +2436,7 @@ export class Manager {
2362
2436
  variant: args.variant ? String(args.variant) : undefined,
2363
2437
  launchOptions: args.launchOptions,
2364
2438
  resume: args.resume ? String(args.resume) : undefined,
2365
- transcript: typeof args.transcript === "boolean" ? args.transcript : undefined,
2439
+ events: typeof args.events === "boolean" ? args.events : undefined,
2366
2440
  cwd: args.cwd ? String(args.cwd) : undefined,
2367
2441
  prompt: args.prompt ? String(args.prompt) : undefined,
2368
2442
  subscribe,
@@ -2722,20 +2796,19 @@ export class Manager {
2722
2796
  name = this.uniqueName(identityName);
2723
2797
  }
2724
2798
  this.reserved.add(name);
2725
- // Transcript mirroring (opt-in: `--transcript` / COTAL_TRANSCRIPT_DEFAULT=1) grant the agent pub
2726
- // on its OWN transcript channel; auth-mode publish is default-deny, so without the grant the mirror's
2727
- // publish is rejected. Ask the resolved connector for the channel the SAME one it publishes to, so
2728
- // the grant and the publish can't drift, and the literal stays out of core. Uses the spawned `name`
2729
- // (post-uniqueName) so the grant matches the actual identity. Mirroring is OPTIONAL per connector
2730
- // (like prompt): if it's requested for a connector that doesn't mirror, fail loud never silently
2731
- // skip the grant (that would surface later as a confusing auth-mode publish rejection).
2732
- const transcript = opts.transcript ?? process.env.COTAL_TRANSCRIPT_DEFAULT === "1";
2733
- if (transcript) {
2734
- if (!connector.transcriptChannel) {
2735
- this.reserved.delete(name); // release the just-reserved name on this fail-fast path
2736
- return { ok: false, error: `connector "${connector.name}" does not support transcript mirroring, but transcript was requested` };
2737
- }
2738
- allowPublish = [...(allowPublish ?? []), connector.transcriptChannel(name)];
2799
+ // The AG-UI event plane (opt-in: `--events` / COTAL_EVENTS_DEFAULT=1). Refused HERE, before
2800
+ // anything is minted: a connector that cannot
2801
+ // emit must fail before provisioning rather than after, exactly as an unsupported `resume` does.
2802
+ // The GRANT itself cannot be derived yet. It is keyed on the agent's PRINCIPAL, and in user mode
2803
+ // the principal's owner is resolved further down, so deriving it from anything in scope here
2804
+ // would mean guessing at the identity the child will actually connect as. It is added at the
2805
+ // accept seam below, where the allocated triple exists.
2806
+ const events = opts.events ?? process.env.COTAL_EVENTS_DEFAULT === "1";
2807
+ if (events && !connector.eventChannel) {
2808
+ // Release the just-reserved name on this fail-fast path. A leaked reserve is silent: it costs
2809
+ // the next spawn of this persona its un-suffixed name and nothing reports why.
2810
+ this.reserved.delete(name);
2811
+ return { ok: false, error: `connector "${connector.name}" does not publish an AG-UI event plane, but events was requested` };
2739
2812
  }
2740
2813
  // F2 (Unit B): a STATIC managed spawn REFUSES endpoint capabilities, fail-closed IN CODE (not
2741
2814
  // a doc note): the static terminal has no obligation-drain/frontier steps yet, so an accepted-
@@ -2778,6 +2851,50 @@ export class Manager {
2778
2851
  const agentTriple = this.userMode
2779
2852
  ? { owner: opts.owner ?? (spawner && parsePrincipalKey(spawner)?.owner.startsWith("u_") ? parsePrincipalKey(spawner).owner : DEV_OWNER), actor: name, uid: lifecycleUid }
2780
2853
  : { owner: DEV_OWNER, actor: identity.id, uid: lifecycleUid };
2854
+ // THE EVENT GRANT, derived from the principal that was actually ALLOCATED and never from the
2855
+ // display name. A display name is UI convenience: this mesh permits two live agents to carry
2856
+ // one, so a name-keyed channel fuses two principals onto one subject and, in auth mode,
2857
+ // authorizes both onto it from the same name-only value. `agentTriple` is the triple the child
2858
+ // will connect as in both modes, so the grant and the subject the session derives from its own
2859
+ // endpoint are the same derivation. Placed here rather than beside the refusal above
2860
+ // because this is the first point at which that triple exists, and still before every
2861
+ // provisioning call that consumes `allowPublish`.
2862
+ // THE OWN-CHANNEL RULE FOR THE EVENT PLANE, and it is the first thing this seam does.
2863
+ //
2864
+ // An event channel carries a session's tool inputs and outputs, which makes it the most
2865
+ // valuable read on the mesh, and `subscribe` / `allowSubscribe` / `allowPublish` are
2866
+ // caller-supplied on every spawn door. On a per-user-auth mesh the ledger's spawner envelope
2867
+ // already refuses a delegation wider than the spawner's own grant. On a STATIC mesh there is
2868
+ // no ledger, so nothing attenuates a caller-supplied ACL at all, and a caller that may spawn
2869
+ // may mint its child a read on any subject.
2870
+ //
2871
+ // What this rule asks is NOT who the caller is. That question has no answer on a static mesh:
2872
+ // an untargeted spawn carries no authorization mode, and the admin reach a static caller
2873
+ // holds is true by construction for everyone who can reach the handler. It asks whether the
2874
+ // event channel being minted BELONGS TO THE AGENT BEING CREATED, which the manager knows
2875
+ // because it has just allocated the principal.
2876
+ //
2877
+ // STATED LIMIT, because a fence whose gap is discovered later is worse than one whose gap is
2878
+ // written down. `eventChannelPrincipal` decodes a principal and refuses anything that is not exactly
2879
+ // two principal tokens, so a WILDCARD is not an event channel to it: `events.<owner>.>` and
2880
+ // `events.>` pass this rule untouched and are governed by ordinary ACL authority, which on a
2881
+ // user mesh is the envelope and on a static mesh is the spawn credential itself. So this
2882
+ // closes the concrete form and not the wildcard form. It is worth having anyway: it is the
2883
+ // form a caller writes when it knows which agent it wants to read, and the wildcard form is
2884
+ // the one an operator writes deliberately for an observer.
2885
+ const foreign = foreignEventChannels([...allowSubscribe, ...(allowPublish ?? [])], agentTriple.owner, agentTriple.actor);
2886
+ if (foreign.length) {
2887
+ // Throws rather than returning, because this seam is inside the accept body: the throw
2888
+ // unwinds before `onAccepted`, so no goal is bound and no identity is minted, and the
2889
+ // enclosing `finally` releases the reserved name. Returning here would be a value nobody
2890
+ // reads.
2891
+ throw new Error(`this spawn asks for another agent's event channel: ${foreign.join(", ")}. An ` +
2892
+ `agent may be granted its OWN event plane and no other, because that plane carries the ` +
2893
+ `session's tool inputs and outputs. Grant a reader out of band rather than through a ` +
2894
+ `spawn: ${this.readerRemedy(agentTriple.owner, foreign[0])}`);
2895
+ }
2896
+ if (events)
2897
+ allowPublish = [...(allowPublish ?? []), connector.eventChannel({ owner: agentTriple.owner, actor: agentTriple.actor })];
2781
2898
  await hooks?.onAccepted?.({ name, identity, lifecycleUid, agentTriple });
2782
2899
  // In auth mode, mint the agent's creds from the space signing key and write them where the
2783
2900
  // spawned session reads them (COTAL_CREDS path). Open mesh → no creds. Scope = the resolved
@@ -2895,7 +3012,7 @@ export class Manager {
2895
3012
  allowSubscribe,
2896
3013
  allowPublish,
2897
3014
  capabilities,
2898
- transcript,
3015
+ events,
2899
3016
  mcpServers,
2900
3017
  // So a connector that keeps per-agent local state can root it at the workspace, not the
2901
3018
  // (possibly per-agent) launch cwd below. The cwd itself rides runtime.spawn, not the launch.
@@ -2937,7 +3054,7 @@ export class Manager {
2937
3054
  allowSubscribe,
2938
3055
  allowPublish,
2939
3056
  capabilities,
2940
- transcript,
3057
+ events,
2941
3058
  shareTools: opts.shareTools,
2942
3059
  forkSource: opts.resume,
2943
3060
  // Opaque values may contain secrets. Preserve only their keys and require the referenced
@@ -3100,6 +3217,38 @@ export class Manager {
3100
3217
  const referenceError = this.inventoryReferenceError(entry);
3101
3218
  if (referenceError)
3102
3219
  throw new Error(`retained agent ${entry.name}: ${referenceError}`);
3220
+ // THE OWN-CHANNEL RULE, ON THE RESUME DOOR, and it belongs here rather than beside the launch
3221
+ // because BOTH resume paths funnel through this function and neither may skip it.
3222
+ //
3223
+ // A resume document is admin-supplied JSON. It carries the ACLs the managed row is re-armed
3224
+ // from, and `renewManagedStaticCred` re-mints the credential out of that row at half TTL, so a
3225
+ // foreign event channel written into an inventory becomes a minted read on another agent's tool
3226
+ // inputs and outputs one renewal later. In static mode nothing else stops it: the checks below
3227
+ // pin the credential's PATH, its IDENTITY and the broker's acceptance of it, and say nothing at
3228
+ // all about its ACL. User mode compares the adopted authority's ACL against the inventory's, so
3229
+ // it refuses a divergence on both fields already, but it refuses it as DRIFT rather than as this
3230
+ // rule, and an inventory whose record and credential agree with each other and disagree with
3231
+ // this rule is exactly the document an operator would not notice.
3232
+ //
3233
+ // Refuses rather than strips. Silently narrowing an admin document would leave the operator
3234
+ // holding a record that says one thing and a mesh that does another, and the whole reason this
3235
+ // is reachable is that nobody reads the record.
3236
+ {
3237
+ const owner = entry.identity.mode === "user" ? entry.identity.owner : DEV_OWNER;
3238
+ // The ACTOR HALF IS THE PRINCIPAL, never the display name. In user mode the row this
3239
+ // document re-arms is keyed by `identity.actor` (it is what the provider adopts below and
3240
+ // what every liveness check reads), and an inventory supplies `name` and `identity.actor`
3241
+ // independently, so judging the channel against `name` would judge it against the half that
3242
+ // does not own the plane.
3243
+ const actor = entry.identity.mode === "user" ? entry.identity.actor : entry.identity.id;
3244
+ const foreign = foreignEventChannels([...(entry.launch.allowSubscribe ?? []), ...(entry.launch.allowPublish ?? [])], owner, actor);
3245
+ if (foreign.length)
3246
+ throw new Error(`retained agent ${entry.name}: its record claims another agent's event channel ` +
3247
+ `(${foreign.join(", ")}). An agent may hold its OWN event plane and no other, because that ` +
3248
+ `plane carries the session's tool inputs and outputs. Remove it from the inventory and ` +
3249
+ `grant the reader out of band instead: ` +
3250
+ `${this.readerRemedy(owner, foreign[0])}`);
3251
+ }
3103
3252
  if (entry.identity.mode === "open") {
3104
3253
  if (this.auth || this.userMode)
3105
3254
  throw new Error(`retained agent ${entry.name} is open-mode but the current manager is authenticated`);
@@ -3328,7 +3477,7 @@ export class Manager {
3328
3477
  allowSubscribe: entry.launch.allowSubscribe,
3329
3478
  allowPublish: entry.launch.allowPublish,
3330
3479
  capabilities: entry.launch.capabilities,
3331
- transcript: entry.launch.transcript,
3480
+ events: entry.launch.events,
3332
3481
  mcpServers,
3333
3482
  workspaceRoot: this.workspaceRoot,
3334
3483
  });
@@ -3392,7 +3541,7 @@ export class Manager {
3392
3541
  allowSubscribe: entry.launch.allowSubscribe,
3393
3542
  allowPublish: entry.launch.allowPublish,
3394
3543
  capabilities: entry.launch.capabilities,
3395
- transcript: entry.launch.transcript,
3544
+ events: entry.launch.events,
3396
3545
  shareTools: entry.launch.shareTools,
3397
3546
  forkSource: entry.launch.forkSource,
3398
3547
  },
@@ -4924,6 +5073,44 @@ export class Manager {
4924
5073
  }
4925
5074
  return { ok: true, data: { name, path } };
4926
5075
  }
5076
+ /** The post-authorization `input` effect (C3): type `text` into the seat's terminal as if a human
5077
+ * had. The single call an external UI needs to deliver a harness command (`/compact`, `/clear`,
5078
+ * `/model`) without holding a terminal open on the caller's side.
5079
+ *
5080
+ * Why the runtime HANDLE and not an attach session: a session is a stream (backlog, subscriber
5081
+ * set, a lifetime the session plane accounts for and caps). Opening and discarding one per
5082
+ * keystroke line would burn a session slot for a write, and would put a capacity refusal in the
5083
+ * path of an operation that has no capacity cost. {@link AgentHandle.write} is the one-shot
5084
+ * sibling of `interrupt()`, which already writes into the same pty.
5085
+ *
5086
+ * Three refusals, each for a different reason and each with its own code, so a caller can tell
5087
+ * "never going to work" from "not right now":
5088
+ * - the name was refilled while authorization awaited: act on the incarnation the caller was
5089
+ * authorized for or on nothing at all (the same guard {@link attachAuthorized} carries, for
5090
+ * the same reason: a name is a reusable slot and `authorizeNamed` can await a ledger read);
5091
+ * - the agent is not running: there is no terminal to type into;
5092
+ * - the runtime cannot write (tmux/cmux/orca/herdr attach to an externally-owned process, so
5093
+ * they own no input stream for it): `unimplemented`, named by runtime kind. NOT a fallback
5094
+ * to an attach session and NOT a silent success - a dropped keystroke would leave the caller
5095
+ * believing a command was delivered that never was.
5096
+ *
5097
+ * `enter` defaults to true: a harness command typed but not submitted has not been delivered.
5098
+ * Nothing is echoed back; the caller reads the resulting turns from the event plane. */
5099
+ inputAuthorized(a, args) {
5100
+ if (this.agents.get(a.name) !== a)
5101
+ throw new EpEnvelopeError("failed-precondition", `agent "${a.name}" was replaced during authorization - retry`);
5102
+ if (a.handle.status() !== "running")
5103
+ throw new EpEnvelopeError("failed-precondition", `agent "${a.name}" is not running (${a.handle.status()}); nothing to type into`);
5104
+ const write = a.handle.write?.bind(a.handle);
5105
+ if (!write)
5106
+ throw new EpEnvelopeError("unimplemented", `input is not supported by runtime ${a.handle.kind}`);
5107
+ // The contract validated `text` (non-empty, <= 64KiB) and `enter` (boolean) before this ran, so
5108
+ // the only decision left is the carriage return. `!== false` and not `?? true`: an ABSENT enter
5109
+ // and an explicit `true` must behave identically, and only `false` may suppress the return.
5110
+ const data = `${String(args.text)}${args.enter !== false ? "\r" : ""}`;
5111
+ write(data);
5112
+ return { name: a.name, bytes: Buffer.byteLength(data, "utf8") };
5113
+ }
4927
5114
  /** The post-authorization attach effect (P2 item 6): mint the holder-bound §13.6 offer, redeem it
4928
5115
  * through the ONE session plane (one-use CAS + presenter-equality), and stand up the PTY bridge —
4929
5116
  * atomically. The reply is the SIGNED grant (no ws:// URL, non-bearer, never logged); the caller