@cotal-ai/manager 0.140.8 → 0.141.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/commands.js +2 -2
- package/dist/commands.js.map +1 -1
- package/dist/launch.d.ts +21 -0
- package/dist/launch.d.ts.map +1 -1
- package/dist/launch.js +49 -1
- package/dist/launch.js.map +1 -1
- package/dist/manager-service-contract.d.ts +4 -1
- package/dist/manager-service-contract.d.ts.map +1 -1
- package/dist/manager-service-contract.js +28 -2
- package/dist/manager-service-contract.js.map +1 -1
- package/dist/manager.d.ts +79 -5
- package/dist/manager.d.ts.map +1 -1
- package/dist/manager.js +542 -66
- package/dist/manager.js.map +1 -1
- package/dist/remote-authority.d.ts +1 -1
- package/dist/remote-authority.d.ts.map +1 -1
- package/dist/remote-authority.js +2 -1
- package/dist/remote-authority.js.map +1 -1
- package/dist/resume.d.ts +5 -0
- package/dist/resume.d.ts.map +1 -1
- package/dist/resume.js +7 -0
- package/dist/resume.js.map +1 -1
- package/dist/run-hosting.d.ts +11 -3
- package/dist/run-hosting.d.ts.map +1 -1
- package/dist/run-hosting.js +35 -10
- package/dist/run-hosting.js.map +1 -1
- package/dist/static-lifecycle.d.ts.map +1 -1
- package/dist/static-lifecycle.js +2 -9
- package/dist/static-lifecycle.js.map +1 -1
- package/package.json +12 -12
package/dist/manager.js
CHANGED
|
@@ -2,10 +2,10 @@ import { createHash, randomUUID, randomBytes } from "node:crypto";
|
|
|
2
2
|
import { hostname } from "node:os";
|
|
3
3
|
import { credsAuthenticator } from "@nats-io/transport-node";
|
|
4
4
|
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs";
|
|
5
|
-
import { isAbsolute, join, dirname, resolve } from "node:path";
|
|
5
|
+
import { isAbsolute, join, dirname, resolve, sep } from "node:path";
|
|
6
6
|
import { parse as parseYaml } from "yaml";
|
|
7
7
|
import Headless from "@xterm/headless";
|
|
8
|
-
import { CotalEndpoint, canonicalJson, MANAGED_HANDOFF_KIND, accountFromCreds, credsClaims, DEFAULT_SERVER, DEV_OWNER, MANAGER_LEASE_RENEW_MS, MANAGER_LEASE_TTL_MS, awaitLeaseExpiry, leaseRemainingMs, STANDING_RENEWABLE_TTL_SEC, assertLifecycleToken, discardLaunchArtifacts, divergentSecretStoreNotice, injectedSecretStoreIdentity, parseDaemonStoreAnswer, parseSecretStoreIdentity, sameSecretStoreIdentity, agentFilePath, clearSpaceHistory, dialerFor, connectorServers, spawnEnvAllow, deprovisionAgent, fenceLifecycleAcl, DeprovisionError, addDeprovisionAccounting, isConcreteChannel, isChannelFamily, deprovisionTargetPrincipal, firstFreeName, spawnNameError, idFromCreds, identityFromCreds, inspectCredHealth, credsRenewalDelayMs, composeWirePersona, loadAgentFile, listPersonaCatalog, personaCatalogDescription, personaCatalogReadable, loadCotalConfig, modelPolicyRefusal, mintCreds, mintGeneration, mintAcceptedToken, bindIssuedStores, mintLifecycleUid, managedRetirementOpId, mkSecretDir, newIdentity, oneLine, actionContext, parsePrincipalKey, principalKey, probeConnect, provisionAgent, provisionAgentDurables, registry, resolveAuthProvider, resolveReadAcl, runAgentBearer, saveAgentFile, subjectMatches, AUTH_ENDPOINT, EP_CMD_RETIRE_LIFECYCLE, controlServiceSubject, eventChannelPrincipal, resolveService, invokeCommand, digestIdToken, renderOnceTask, } from "@cotal-ai/core";
|
|
8
|
+
import { CotalEndpoint, canonicalJson, MANAGED_HANDOFF_KIND, accountFromCreds, credsClaims, DEFAULT_SERVER, DEV_OWNER, MANAGER_LEASE_RENEW_MS, MANAGER_LEASE_TTL_MS, awaitLeaseExpiry, leaseRemainingMs, STANDING_RENEWABLE_TTL_SEC, assertLifecycleToken, discardLaunchArtifacts, divergentSecretStoreNotice, injectedSecretStoreIdentity, parseDaemonStoreAnswer, parseSecretStoreIdentity, sameSecretStoreIdentity, agentFilePath, assertValidChannel, clearSpaceHistory, dialerFor, connectorServers, spawnEnvAllow, deprovisionAgent, fenceLifecycleAcl, DeprovisionError, addDeprovisionAccounting, isConcreteChannel, isChannelFamily, deprovisionTargetPrincipal, firstFreeName, spawnNameError, idFromCreds, identityFromCreds, inspectCredHealth, credsRenewalDelayMs, composeWirePersona, loadAgentFile, listPersonaCatalog, personaCatalogDescription, personaCatalogReadable, loadCotalConfig, modelPolicyRefusal, mintCreds, mintGeneration, mintAcceptedToken, bindIssuedStores, mintLifecycleUid, managedRetirementOpId, mkSecretDir, newIdentity, oneLine, actionContext, parsePrincipalKey, principalKey, probeConnect, provisionAgent, provisionAgentDurables, registry, resolveAuthProvider, resolveReadAcl, runAgentBearer, runDriverCaller, saveAgentFile, subjectMatches, AUTH_ENDPOINT, EP_CMD_RETIRE_LIFECYCLE, controlServiceSubject, eventChannel, eventChannelPrincipal, resolveService, invokeCommand, digestIdToken, capabilityWithin, grantOverreach, grantParts, isIssuedCaller, issuedSpawnerStanding, withIssuerSession, renderOnceTask, } from "@cotal-ai/core";
|
|
9
9
|
import { loadLaunchPersonaFile, PersonaSourceError } from "@cotal-ai/workspace";
|
|
10
10
|
import { agentAuthState, agentCredsDir, agentLifecycleSecretFilePaths, agentSecretFilePaths, agentSecretKeyForFile, authDir, claimIdentityRecord, claimManagerSiblingIdentities, connectorInstallHint, DEFAULT_SEAT_CAP, DELIVERY_CREDS_KIND, extensionConnectors, findCotalRoot, getSpaceAuth, hasUserAuthState, loadExtensionsManifest, loadManagerInstanceIdentity, loadMeshes, localTrustOfSpace, manifestExtensionNames, materializeFromManifest, materializeSecretToFile, MEMBERSHIP_RW_CREDS_KIND, mergeLaunchOptions, probeLiveness, remintDaemonCreds, resolveAgentType, resolveOnPath, runtimeInstallHint, createManagerInstanceIdentity, SEAT_CAP_UNLIMITED, spaceKey, spaceMaterialKey, SYSTEM_CREDS_FILES, userAuthStateDir, workspaceSecretStore, writeRenewalRecord } from "@cotal-ai/workspace";
|
|
11
11
|
import { createRuntime, exitProof, isCustodialRuntime, pollProvedExit, requireRuntimeAdopt, requireRuntimeReap, RuntimeReapUnproven, watchProvedExit, } from "./runtime/index.js";
|
|
@@ -13,10 +13,10 @@ import { AttachEndpoint } from "./attach-endpoint.js";
|
|
|
13
13
|
import { DELIVERY_ADMIN_BOOT_WAIT_MS, isUnansweredDeliveryAdmin, makeManagerEndpointEvictionEvidence, makeManagerEndpointHolderEvictor, untilDeliveryAdminAnswers, withScopedEndpoint } from "./endpoint-evict.js";
|
|
14
14
|
import { makeManagerHolderLivenessProbe } from "./holder-liveness.js";
|
|
15
15
|
import { GateReconcileRefused, reconcileEndpointGate } from "./reconcile-gate.js";
|
|
16
|
-
import { launchSpecForRun, materializePersona, launchAgentToStartOpts, parseLaunchSpec, persistLaunchSpec, readContinuityAssignment, writeContinuityAssignment } from "./launch.js";
|
|
16
|
+
import { inlinePersonaDir, inlineSnapshotPath, launchSpecForRun, materializePersona, launchAgentToStartOpts, removeInlineSnapshot, writeInlineSnapshot, parseLaunchSpec, persistLaunchSpec, readContinuityAssignment, writeContinuityAssignment } from "./launch.js";
|
|
17
17
|
import { authorizeLaunch, authorizeNamedControl } from "./authorize.js";
|
|
18
18
|
import { controlShutdown } from "./control-shutdown.js";
|
|
19
|
-
import { RunHosting } from "./run-hosting.js";
|
|
19
|
+
import { RunHosting, servedRequestSubject } from "./run-hosting.js";
|
|
20
20
|
import { enrolledAcl } from "./remote-authority.js";
|
|
21
21
|
import { rejectionText } from "./rejection-text.js";
|
|
22
22
|
import { controlSession } from "./control-session.js";
|
|
@@ -307,6 +307,7 @@ const FREE_SLOT_CAUSE_TEXT = {
|
|
|
307
307
|
"carried-fork-refused": "this manager stopped it: it recorded no fork of the carried transcript",
|
|
308
308
|
"resume-custody-unrecorded": "this manager stopped it: its custody reference could not be recorded on resume",
|
|
309
309
|
"supervise-record-failed": "this manager stopped it: its supervise policy could not be recorded for a manager restart",
|
|
310
|
+
"inline-snapshot-changed": "this manager retired it instead of restarting it: its inline persona snapshot changed after launch",
|
|
310
311
|
};
|
|
311
312
|
/** The stop family's sentences — the same job {@link FREE_SLOT_CAUSE_TEXT} does for the string
|
|
312
313
|
* causes, beside it so both stay the single place a cause becomes a sentence. A requested stop's
|
|
@@ -335,6 +336,65 @@ function onceStopReason(cause) {
|
|
|
335
336
|
default: return cause;
|
|
336
337
|
}
|
|
337
338
|
}
|
|
339
|
+
/** Why a revoked run starts no helper, the same words wherever the marker is read. */
|
|
340
|
+
function revokedRunRefusal(runId, revoked) {
|
|
341
|
+
return `run ${runId} was revoked by ${revoked.by} (${revoked.reason}); a revoked run starts no helper (SPEC 14.8)`;
|
|
342
|
+
}
|
|
343
|
+
const INLINE_PERSONA_KEYS = ["prompt", "role", "model", "variant", "description", "subscribe", "allowSubscribe", "allowPublish", "capabilities", "save"];
|
|
344
|
+
/** Read an inline persona into the definition a catalog file would load as, refusing what a file
|
|
345
|
+
* load refuses plus any shape the closed record does not take. The body is literal text: nothing in
|
|
346
|
+
* it becomes a field, so a body that starts with `---` stays body. */
|
|
347
|
+
function inlinePersonaDef(name, raw) {
|
|
348
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw))
|
|
349
|
+
throw new Error("persona: expected an object");
|
|
350
|
+
const rec = raw;
|
|
351
|
+
const extra = Object.keys(rec).filter((k) => !INLINE_PERSONA_KEYS.includes(k));
|
|
352
|
+
if (extra.length)
|
|
353
|
+
throw new Error(`persona: unknown key ${extra[0]}; it takes ${INLINE_PERSONA_KEYS.join(", ")}`);
|
|
354
|
+
const text = (k) => {
|
|
355
|
+
const v = rec[k];
|
|
356
|
+
if (v === undefined)
|
|
357
|
+
return undefined;
|
|
358
|
+
if (typeof v !== "string" || !v.trim())
|
|
359
|
+
throw new Error(`persona.${k}: expected a non-blank string`);
|
|
360
|
+
return v;
|
|
361
|
+
};
|
|
362
|
+
const list = (k) => {
|
|
363
|
+
const v = rec[k];
|
|
364
|
+
if (v === undefined)
|
|
365
|
+
return undefined;
|
|
366
|
+
if (!Array.isArray(v) || v.some((x) => typeof x !== "string"))
|
|
367
|
+
throw new Error(`persona.${k}: expected an array of strings`);
|
|
368
|
+
return [...v];
|
|
369
|
+
};
|
|
370
|
+
const prompt = text("prompt");
|
|
371
|
+
if (prompt === undefined)
|
|
372
|
+
throw new Error("persona.prompt: required; it is the persona body");
|
|
373
|
+
if (rec.save !== undefined && typeof rec.save !== "boolean")
|
|
374
|
+
throw new Error("persona.save: expected a boolean");
|
|
375
|
+
const def = {
|
|
376
|
+
name,
|
|
377
|
+
persona: prompt.trim(),
|
|
378
|
+
role: text("role"),
|
|
379
|
+
model: text("model"),
|
|
380
|
+
variant: text("variant"),
|
|
381
|
+
description: text("description"),
|
|
382
|
+
subscribe: list("subscribe"),
|
|
383
|
+
allowSubscribe: list("allowSubscribe"),
|
|
384
|
+
allowPublish: list("allowPublish"),
|
|
385
|
+
capabilities: list("capabilities"),
|
|
386
|
+
};
|
|
387
|
+
// The checks a file load makes on these fields: channel names, and the read set within the read ACL.
|
|
388
|
+
try {
|
|
389
|
+
for (const ch of [...(def.subscribe ?? []), ...(def.allowSubscribe ?? []), ...(def.allowPublish ?? [])])
|
|
390
|
+
assertValidChannel(ch);
|
|
391
|
+
resolveReadAcl(def.subscribe ?? [], def.allowSubscribe);
|
|
392
|
+
}
|
|
393
|
+
catch (e) {
|
|
394
|
+
throw new Error(`persona: ${rejectionText(e)}`);
|
|
395
|
+
}
|
|
396
|
+
return { def, save: rec.save === true };
|
|
397
|
+
}
|
|
338
398
|
const TURN_PARTICIPATION_TTL_MS = 5 * 60_000;
|
|
339
399
|
function participantKey(owner, actor, uid) {
|
|
340
400
|
return `${owner}.${actor}.${uid}`;
|
|
@@ -2295,6 +2355,8 @@ export class Manager {
|
|
|
2295
2355
|
return `imperative launch options have no non-secret durable source (${entry.launch.unresolvedLaunchOptionKeys.join(", ")})`;
|
|
2296
2356
|
if (!entry.dependencies.some((path) => resolve(path) === resolve(entry.launch.source.configPath)))
|
|
2297
2357
|
return `launch config is not declared as a retained dependency: ${entry.launch.source.configPath}`;
|
|
2358
|
+
if (entry.launch.source.kind === "inline" && resolve(entry.launch.source.configPath) !== resolve(inlineSnapshotPath(this.workspaceRoot, entry.identity.lifecycleUid)))
|
|
2359
|
+
return `inline persona snapshot ${entry.launch.source.configPath} is not this lifecycle's snapshot path`;
|
|
2298
2360
|
if (entry.launch.source.kind === "manifest" && entry.launch.source.runId) {
|
|
2299
2361
|
const specPath = join(this.workspaceRoot, ".cotal", "run", `${entry.launch.source.runId}.json`);
|
|
2300
2362
|
if (!entry.dependencies.some((path) => resolve(path) === resolve(specPath)))
|
|
@@ -2473,7 +2535,11 @@ export class Manager {
|
|
|
2473
2535
|
}
|
|
2474
2536
|
}));
|
|
2475
2537
|
// Deprovision EVERY snapshot entry regardless of whether its stop failed (allSettled + a loud log).
|
|
2476
|
-
await Promise.allSettled(managed.filter((a) => !a.suppressCleanup).map((a) =>
|
|
2538
|
+
await Promise.allSettled(managed.filter((a) => !a.suppressCleanup).map((a) => {
|
|
2539
|
+
// Retired here on every mesh, the open mesh included, which runs no teardown of its own.
|
|
2540
|
+
const settled = a.launch.source.kind !== "inline" || this.dropInlineSnapshot(a.name, a.lifecycleUid);
|
|
2541
|
+
return this.deprovision({ ...this.teardownTarget(a), ...(settled ? { snapshotSettled: true } : {}) }).catch((e) => console.error(`deprovision ${a.name} (${a.id}) on shutdown: ${rejectionText(e)}`));
|
|
2542
|
+
}));
|
|
2477
2543
|
if (failures.length)
|
|
2478
2544
|
throw new Error(`manager shutdown could not prove every seat exited: ${failures.join("; ")}`);
|
|
2479
2545
|
}
|
|
@@ -3510,7 +3576,7 @@ export class Manager {
|
|
|
3510
3576
|
// follows owner-domain reach on a user mesh. Resolve the caller's admin tier once for
|
|
3511
3577
|
// the residual cross-owner case; opStart decides before connector resolution or grant
|
|
3512
3578
|
// mutation. Static meshes resolve true and nothing changes.
|
|
3513
|
-
spawn: (ctx) => this.loggedSpawnRefusal(ctx, () => this.serveGated(ctx, async () => this.serveSpawnGoal(ctx, async (h) => this.opStart(args(ctx), callerOf(ctx), await this.epAdminReach(ctx.subject.caller), h, ctx.subject.route, ctx.subject.caller)))),
|
|
3579
|
+
spawn: (ctx) => this.loggedSpawnRefusal(ctx, () => this.serveGated(ctx, async () => this.serveSpawnGoal(ctx, async (h) => this.opStart(args(ctx), callerOf(ctx), await this.epAdminReach(ctx.subject.caller), h, ctx.subject.route, ctx.subject.caller, servedRequestSubject(this.space, ctx))))),
|
|
3514
3580
|
despawn: (ctx) => this.serveGated(ctx, async () => {
|
|
3515
3581
|
const a = targetAgent(ctx);
|
|
3516
3582
|
const denied = await this.authorizeNamed(a, callerOf(ctx), await this.epAnyModeAdmin(ctx), ctx.subject.caller);
|
|
@@ -3575,7 +3641,7 @@ export class Manager {
|
|
|
3575
3641
|
// authenticated caller's own, so a run withdraws only the turns it submitted.
|
|
3576
3642
|
cancel: (ctx) => this.serveGated(ctx, () => this.serveTurnCancel(ctx)),
|
|
3577
3643
|
stopSelf: (ctx) => this.serveGated(ctx, () => unwrap(this.opStopSelf(callerOf(ctx), args(ctx)))),
|
|
3578
|
-
definePersona: (ctx) => this.serveGated(ctx, () => unwrap(this.opDefinePersona(args(ctx), callerOf(ctx), false))),
|
|
3644
|
+
definePersona: (ctx) => this.serveGated(ctx, async () => unwrap(await this.opDefinePersona(args(ctx), callerOf(ctx), false, ctx.subject.caller))),
|
|
3579
3645
|
removePersona: (ctx) => this.serveGated(ctx, () => {
|
|
3580
3646
|
const r = this.opRemovePersona(args(ctx), callerOf(ctx), false);
|
|
3581
3647
|
if (!r.ok)
|
|
@@ -3872,6 +3938,12 @@ export class Manager {
|
|
|
3872
3938
|
})
|
|
3873
3939
|
.finally(() => this.releaseLifecycle());
|
|
3874
3940
|
}
|
|
3941
|
+
/** The served spawn a hosted enrollment forwards. A run's spawn also names its run, so the host
|
|
3942
|
+
* bounds the helper by what the run's starter held when the run was admitted (SPEC 14.8). */
|
|
3943
|
+
servedSpawnRequest(served, caller) {
|
|
3944
|
+
const run = caller !== undefined ? this.runHosting?.runAdmission(caller) : undefined;
|
|
3945
|
+
return { served, ...(run !== undefined ? { runId: run.runId } : {}) };
|
|
3946
|
+
}
|
|
3875
3947
|
/** USER-MODE spawn provisioning (the gate-1 counterpart to the static mint block): resolve the
|
|
3876
3948
|
* OWNER (ctl caller's principal, or the manifest's stamped owner — never a payload field),
|
|
3877
3949
|
* pre-create the principal-keyed durables + ACL row on the ephemeral provisioner, author the
|
|
@@ -4044,6 +4116,7 @@ export class Manager {
|
|
|
4044
4116
|
allowSubscribe: opts.allowSubscribe,
|
|
4045
4117
|
...(opts.allowPublish !== undefined ? { allowPublish: opts.allowPublish } : {}),
|
|
4046
4118
|
},
|
|
4119
|
+
...(opts.spawnRequest !== undefined ? { spawn: opts.spawnRequest } : {}),
|
|
4047
4120
|
});
|
|
4048
4121
|
if (material.actor !== name)
|
|
4049
4122
|
throw new Error(`the host enrolled actor "${material.actor}", not the requested "${name}"`);
|
|
@@ -4279,8 +4352,28 @@ export class Manager {
|
|
|
4279
4352
|
// process is already gone, so this must never block the slot free or throw into the caller — it runs
|
|
4280
4353
|
// detached, and a failure is logged loudly (never swallowed), not retried. The `agents` guard above
|
|
4281
4354
|
// makes this fire exactly once per agent across every free path (despawn / self-stop / reap / exit).
|
|
4282
|
-
if (!a.suppressCleanup && (this.maintenanceState === "active" || acceptedBeforeFence))
|
|
4355
|
+
if (!a.suppressCleanup && (this.maintenanceState === "active" || acceptedBeforeFence)) {
|
|
4356
|
+
// An inline seat's snapshot goes with the seat on every mesh. An open mesh runs no teardown
|
|
4357
|
+
// below, and a preservation cut suppresses this, so a resumed seat still finds its snapshot.
|
|
4358
|
+
// Settled means the file is gone or the seat never had one. A removal that failed is left to
|
|
4359
|
+
// the teardown, which retries it and reports what it could not remove.
|
|
4360
|
+
if (a.launch.source.kind !== "inline" || this.dropInlineSnapshot(a.name, a.lifecycleUid))
|
|
4361
|
+
target.snapshotSettled = true;
|
|
4283
4362
|
this.trackDeprovision(target);
|
|
4363
|
+
}
|
|
4364
|
+
}
|
|
4365
|
+
/** Remove one lifecycle's inline snapshot by the path its uid derives, never by a recorded one, so a
|
|
4366
|
+
* resume inventory cannot point the removal at another file. Returns whether the file is gone. A
|
|
4367
|
+
* failure is logged, never thrown, because the callers must still schedule the teardown. */
|
|
4368
|
+
dropInlineSnapshot(name, lifecycleUid) {
|
|
4369
|
+
try {
|
|
4370
|
+
removeInlineSnapshot(this.workspaceRoot, lifecycleUid);
|
|
4371
|
+
return true;
|
|
4372
|
+
}
|
|
4373
|
+
catch (e) {
|
|
4374
|
+
console.error(`! ${name}: its inline persona snapshot could not be removed: ${rejectionText(e)}`);
|
|
4375
|
+
return false;
|
|
4376
|
+
}
|
|
4284
4377
|
}
|
|
4285
4378
|
/** A live agent's teardown target, built field by field so the rest of its record (handle, seed,
|
|
4286
4379
|
* restart state) never rides into the detached teardown or its single-flight map. */
|
|
@@ -4911,6 +5004,33 @@ export class Manager {
|
|
|
4911
5004
|
}
|
|
4912
5005
|
throw new Error(`replacement did not prove session ${expected} (${last})`);
|
|
4913
5006
|
}
|
|
5007
|
+
/** Why an inline seat's snapshot no longer matches its launch, or `undefined` when it does or the
|
|
5008
|
+
* seat is not inline. The recorded path and the relaunch's path must both be this lifecycle's
|
|
5009
|
+
* snapshot path, as the resume cut requires, and the file there must still be a private regular
|
|
5010
|
+
* file with the recorded digest. */
|
|
5011
|
+
inlineSnapshotChange(a) {
|
|
5012
|
+
const source = a.launch.source;
|
|
5013
|
+
if (source.kind !== "inline")
|
|
5014
|
+
return undefined;
|
|
5015
|
+
const path = resolve(inlineSnapshotPath(this.workspaceRoot, a.lifecycleUid));
|
|
5016
|
+
if (resolve(source.configPath) !== path)
|
|
5017
|
+
return `its recorded inline persona snapshot ${source.configPath} is not this lifecycle's snapshot path`;
|
|
5018
|
+
if (a.restart !== undefined && resolve(a.restart.opts.configPath ?? "") !== path)
|
|
5019
|
+
return `its relaunch persona ${a.restart.opts.configPath} is not this lifecycle's snapshot path`;
|
|
5020
|
+
try {
|
|
5021
|
+
const st = lstatSync(path);
|
|
5022
|
+
if (!st.isFile() || st.isSymbolicLink())
|
|
5023
|
+
return `its inline persona snapshot ${path} is not a regular file`;
|
|
5024
|
+
if (process.platform !== "win32" && (st.mode & 0o077) !== 0)
|
|
5025
|
+
return `its inline persona snapshot ${path} is readable by others`;
|
|
5026
|
+
if (this.fileDigest(path) !== source.configSha256)
|
|
5027
|
+
return `its inline persona snapshot ${path} changed after launch`;
|
|
5028
|
+
}
|
|
5029
|
+
catch (e) {
|
|
5030
|
+
return `its inline persona snapshot is unavailable: ${rejectionText(e)}`;
|
|
5031
|
+
}
|
|
5032
|
+
return undefined;
|
|
5033
|
+
}
|
|
4914
5034
|
/** Restart one managed process in place. Identity, lifecycle, credentials, durables, children,
|
|
4915
5035
|
* and the manager row remain owned; only the process handle/control endpoint change. Budget
|
|
4916
5036
|
* comes from `restart.policy` when spawn carried `supervise`; otherwise the Pi session-recovery
|
|
@@ -4938,6 +5058,19 @@ export class Manager {
|
|
|
4938
5058
|
release();
|
|
4939
5059
|
return;
|
|
4940
5060
|
}
|
|
5061
|
+
// An inline seat relaunches from its snapshot, so one that changed after launch, or a recorded
|
|
5062
|
+
// path that names another file, would start a session its spawn never accepted. Every in-place
|
|
5063
|
+
// restart passes here; the resume cut and a successor's restart make the same two checks in
|
|
5064
|
+
// inventoryReferenceError.
|
|
5065
|
+
const changed = this.inlineSnapshotChange(a);
|
|
5066
|
+
if (changed) {
|
|
5067
|
+
console.error(`! ${a.name}: ${changed} - retiring the managed seat instead of restarting it`);
|
|
5068
|
+
restart.armed = false;
|
|
5069
|
+
this.freeSlot(a, true, "inline-snapshot-changed");
|
|
5070
|
+
this.reapChildrenOf(this.managedPrincipal(a));
|
|
5071
|
+
release();
|
|
5072
|
+
return;
|
|
5073
|
+
}
|
|
4941
5074
|
restart.recovering = true;
|
|
4942
5075
|
void (async () => {
|
|
4943
5076
|
let replacement;
|
|
@@ -5134,7 +5267,7 @@ export class Manager {
|
|
|
5134
5267
|
return false;
|
|
5135
5268
|
}
|
|
5136
5269
|
/** Parse an untyped control-plane `start` request into {@link StartAgentOpts}. */
|
|
5137
|
-
opStart(args, caller, admin, hooks, route = "inst", authenticatedCaller) {
|
|
5270
|
+
opStart(args, caller, admin, hooks, route = "inst", authenticatedCaller, served) {
|
|
5138
5271
|
// Opaque launch options, when present, must be a mapping — a raw control message could send a
|
|
5139
5272
|
// scalar/array (the CLI never does). Core doesn't interpret the keys; the connector validates them.
|
|
5140
5273
|
if (args.launchOptions !== undefined && (typeof args.launchOptions !== "object" || args.launchOptions === null || Array.isArray(args.launchOptions)))
|
|
@@ -5241,6 +5374,9 @@ export class Manager {
|
|
|
5241
5374
|
role: args.role,
|
|
5242
5375
|
config: args.config,
|
|
5243
5376
|
identity: args.identity,
|
|
5377
|
+
// Passed as sent: spawnPersona validates the record, the one place every door reads it.
|
|
5378
|
+
persona: args.persona,
|
|
5379
|
+
...(typeof args.runId === "string" ? { runId: args.runId } : {}),
|
|
5244
5380
|
model: args.model,
|
|
5245
5381
|
variant: args.variant,
|
|
5246
5382
|
launchOptions: args.launchOptions,
|
|
@@ -5261,6 +5397,7 @@ export class Manager {
|
|
|
5261
5397
|
...(once !== undefined ? { once } : {}),
|
|
5262
5398
|
...(typeof args.runtime === "string" ? { runtime: args.runtime.trim() } : {}),
|
|
5263
5399
|
route,
|
|
5400
|
+
...(served !== undefined ? { served } : {}),
|
|
5264
5401
|
}, caller, hooks, authenticatedCaller);
|
|
5265
5402
|
}
|
|
5266
5403
|
/** The realpath + directory checks a placed spawn directory must pass on this manager's host,
|
|
@@ -5671,6 +5808,39 @@ export class Manager {
|
|
|
5671
5808
|
const ref = opts.name.trim();
|
|
5672
5809
|
if (!ref)
|
|
5673
5810
|
return { error: "name required" };
|
|
5811
|
+
// An inline persona is its own definition, read here like a file so every later check sees a
|
|
5812
|
+
// def. It has no file until its snapshot is written, once the seat's lifecycle uid is final.
|
|
5813
|
+
if (opts.persona !== undefined) {
|
|
5814
|
+
if (opts.resolved || opts.config !== undefined || opts.identity !== undefined)
|
|
5815
|
+
return { error: "persona: an inline persona names the helper itself, so it takes no config, identity or manifest launch", code: "bad-request" };
|
|
5816
|
+
const refErr = this.nameError(ref);
|
|
5817
|
+
if (refErr)
|
|
5818
|
+
return { error: refErr };
|
|
5819
|
+
let inline;
|
|
5820
|
+
try {
|
|
5821
|
+
inline = inlinePersonaDef(ref, opts.persona);
|
|
5822
|
+
}
|
|
5823
|
+
catch (e) {
|
|
5824
|
+
return { error: rejectionText(e), code: "bad-request" };
|
|
5825
|
+
}
|
|
5826
|
+
// A save under a name the catalog already holds is refused here, before anything is allocated,
|
|
5827
|
+
// so the refusal starts nothing. The create-only write after the grant settles a race.
|
|
5828
|
+
if (inline.save) {
|
|
5829
|
+
const target = agentFilePath(this.workspaceRoot, ref);
|
|
5830
|
+
let taken = true;
|
|
5831
|
+
try {
|
|
5832
|
+
lstatSync(target);
|
|
5833
|
+
}
|
|
5834
|
+
catch (e) {
|
|
5835
|
+
if (e.code !== "ENOENT")
|
|
5836
|
+
return { error: `persona.save: ${rejectionText(e)}` };
|
|
5837
|
+
taken = false;
|
|
5838
|
+
}
|
|
5839
|
+
if (taken)
|
|
5840
|
+
return { error: `persona.save: the catalog already holds a persona named "${ref}" (${target}). Spawn that persona by name, or pick another name to save this one under` };
|
|
5841
|
+
}
|
|
5842
|
+
return { ref, def: inline.def, agent: resolveAgentType({ flag: opts.agent, pin: undefined, callerDefault: opts.defaultAgent }), inline: { save: inline.save } };
|
|
5843
|
+
}
|
|
5674
5844
|
// A bare ref maps to `.cotal/agents/<ref>.md`, so it must be a safe token (no path traversal); a
|
|
5675
5845
|
// `--config` path is validated by existsSync below instead.
|
|
5676
5846
|
if (!opts.config) {
|
|
@@ -5696,6 +5866,11 @@ export class Manager {
|
|
|
5696
5866
|
if (!existsSync(configPath))
|
|
5697
5867
|
return { error: `no persona "${ref}" in the catalog of manager ${this.managerInstanceId} on host ${hostname()}: ${configPath} not found; create it there` };
|
|
5698
5868
|
}
|
|
5869
|
+
// An inline snapshot belongs to its own seat's lifecycle, which removes it at retirement and
|
|
5870
|
+
// refuses a restart once it changed, so no other launch may read it. Compared after symlinks
|
|
5871
|
+
// resolve, for a config path and a catalog entry alike.
|
|
5872
|
+
if (!opts.resolved && this.insideInlineDir(configPath))
|
|
5873
|
+
return { error: `${configPath} is an inline persona's snapshot, which belongs to the seat it was written for; spawn a saved persona by name, or pass the persona inline` };
|
|
5699
5874
|
// Load the persona ONCE, here, ahead of the connector choice. The file's `agent:` pin feeds the
|
|
5700
5875
|
// harness resolution below; the spawn path's identity/ACL profile block consumes the SAME def (no
|
|
5701
5876
|
// second load). A manifest launch (`resolved`) materializes its own transient persona and the
|
|
@@ -5723,6 +5898,155 @@ export class Manager {
|
|
|
5723
5898
|
}
|
|
5724
5899
|
return { ref, configPath, def, agent: resolveAgentType({ flag: opts.agent, pin: def?.agent, callerDefault: opts.defaultAgent }) };
|
|
5725
5900
|
}
|
|
5901
|
+
/** Whether a persona path resolves into the inline snapshot directory, the directory itself
|
|
5902
|
+
* included. A directory that does not exist holds nothing. */
|
|
5903
|
+
insideInlineDir(path) {
|
|
5904
|
+
const dir = inlinePersonaDir(this.workspaceRoot);
|
|
5905
|
+
if (!existsSync(dir))
|
|
5906
|
+
return false;
|
|
5907
|
+
const real = realpathSync(path);
|
|
5908
|
+
const realDir = realpathSync(dir);
|
|
5909
|
+
return real === realDir || real.startsWith(realDir + sep);
|
|
5910
|
+
}
|
|
5911
|
+
/** The run a spawn acts for (SPEC 14.8). The run this manager drives under `caller` answers from
|
|
5912
|
+
* memory. On a static mesh, a spawn from a run hosted elsewhere names its run, whether the program
|
|
5913
|
+
* placed it here or the class queue delivered it, and its caller must be that run's driver, whose
|
|
5914
|
+
* triple derives from the run id; the run's admission and revocation marker are then read
|
|
5915
|
+
* leader-served, so the helper is bounded as the hosting manager bounds it. Elsewhere the run id
|
|
5916
|
+
* is not read: a user-auth run's issuing host bounds the helper, and an open mesh bounds none.
|
|
5917
|
+
* `undefined` for a spawn that acts for no run. */
|
|
5918
|
+
async spawnRun(caller, runId) {
|
|
5919
|
+
const hosted = caller !== undefined ? this.runHosting?.runAdmission(caller) : undefined;
|
|
5920
|
+
if (hosted !== undefined) {
|
|
5921
|
+
if (runId !== undefined && runId !== hosted.runId)
|
|
5922
|
+
return { refused: `it names run ${runId}, but its caller drives run ${hosted.runId}` };
|
|
5923
|
+
return { admission: hosted, hosted: true };
|
|
5924
|
+
}
|
|
5925
|
+
if (runId === undefined || this.meshMode !== "static")
|
|
5926
|
+
return undefined;
|
|
5927
|
+
let driver;
|
|
5928
|
+
try {
|
|
5929
|
+
driver = caller !== undefined ? runDriverCaller(runId, caller.owner) : undefined;
|
|
5930
|
+
}
|
|
5931
|
+
catch (e) {
|
|
5932
|
+
return { refused: `runId ${JSON.stringify(runId)} is not a run id (${rejectionText(e)})` };
|
|
5933
|
+
}
|
|
5934
|
+
if (driver === undefined || driver.actor !== caller.actor || driver.uid !== caller.uid)
|
|
5935
|
+
return { refused: `it names run ${runId}, but its caller is not that run's driver` };
|
|
5936
|
+
if (this.runHosting === undefined)
|
|
5937
|
+
return { refused: `run ${runId} asked this manager for a helper, but this manager hosts no runs, so it cannot read the run's admission` };
|
|
5938
|
+
let view;
|
|
5939
|
+
try {
|
|
5940
|
+
view = await this.runHosting.admission(runId);
|
|
5941
|
+
}
|
|
5942
|
+
catch (e) {
|
|
5943
|
+
return { refused: `run ${runId}'s admission and revocation marker could not be read (${rejectionText(e)}), so it starts no helper (SPEC 14.8)` };
|
|
5944
|
+
}
|
|
5945
|
+
if (view.revoked !== undefined)
|
|
5946
|
+
return { refused: revokedRunRefusal(runId, view.revoked) };
|
|
5947
|
+
return { admission: view.admission, hosted: false };
|
|
5948
|
+
}
|
|
5949
|
+
/**
|
|
5950
|
+
* Resolve what bounds a spawn's grant on a STATIC mesh: the caller's issued evidence, read the way
|
|
5951
|
+
* keyed runs read it. A user-auth mesh bounds the mint on its ledger instead, and an open mesh
|
|
5952
|
+
* mints no grant, so both return `exempt`. So does a call with no authenticated caller, which is
|
|
5953
|
+
* the manager's own composition root (roster boot, a manifest `launch`, which a static mesh mints
|
|
5954
|
+
* only into operator instruments). A workflow run's spawn, whether this manager hosts the run or
|
|
5955
|
+
* its spawn reached this manager from another host ({@link spawnRun}), is bounded by the run's admission (SPEC 14.8):
|
|
5956
|
+
* the ceiling it recorded, at the starter's coordinate, unless the starter was exempt then. That
|
|
5957
|
+
* ceiling is the starter's evidence as resolved at admission, or for a local run the channels the
|
|
5958
|
+
* operator named. Any other caller with no issued generation, or whose evidence does not resolve,
|
|
5959
|
+
* hands on no grant. The evidence's own standing decides the rest.
|
|
5960
|
+
*/
|
|
5961
|
+
async staticSpawnBound(caller, run) {
|
|
5962
|
+
if (this.meshMode !== "static" || caller === undefined)
|
|
5963
|
+
return { kind: "exempt" };
|
|
5964
|
+
// The driver's own credential is not the run's authority, and the starter's evidence may have
|
|
5965
|
+
// expired or changed since: the admission recorded both the ceiling and the verdict.
|
|
5966
|
+
if (run !== undefined) {
|
|
5967
|
+
const admission = run.admission;
|
|
5968
|
+
// Read leader-served before each of the run's spawns, so a run revoked mid-step starts no
|
|
5969
|
+
// helper, and a marker this manager cannot read refuses the spawn too. The marker of a run
|
|
5970
|
+
// hosted elsewhere was read with its admission.
|
|
5971
|
+
let refusal;
|
|
5972
|
+
if (run.hosted)
|
|
5973
|
+
try {
|
|
5974
|
+
const revoked = (await this.runHosting.admission(admission.runId)).revoked;
|
|
5975
|
+
if (revoked !== undefined)
|
|
5976
|
+
refusal = revokedRunRefusal(admission.runId, revoked);
|
|
5977
|
+
}
|
|
5978
|
+
catch (e) {
|
|
5979
|
+
refusal = `run ${admission.runId}'s revocation marker could not be read (${rejectionText(e)}), so it starts no helper (SPEC 14.8)`;
|
|
5980
|
+
}
|
|
5981
|
+
if (refusal !== undefined)
|
|
5982
|
+
return { kind: "refused", reason: refusal };
|
|
5983
|
+
if (admission.spawnExempt)
|
|
5984
|
+
return { kind: "exempt" };
|
|
5985
|
+
// The helper is projected at the admitted coordinate, which needs a lifecycle token. A local
|
|
5986
|
+
// admission written before its uid was one records the CLI's own holder id, and bounds nothing.
|
|
5987
|
+
try {
|
|
5988
|
+
assertLifecycleToken(admission.caller.uid);
|
|
5989
|
+
}
|
|
5990
|
+
catch {
|
|
5991
|
+
return { kind: "refused", reason: `run ${admission.runId}'s admission records caller uid ${JSON.stringify(admission.caller.uid)}, which is not a lifecycle token, so its helpers cannot be measured and it starts none. Start the run again (SPEC 14.8)` };
|
|
5992
|
+
}
|
|
5993
|
+
return { kind: "bounded", caller: admission.caller, ceiling: admission.ceiling, run: admission.runId };
|
|
5994
|
+
}
|
|
5995
|
+
if (!isIssuedCaller(caller))
|
|
5996
|
+
return { kind: "none", reason: "its credential carries no issued generation (an unissued mint or a legacy rail)" };
|
|
5997
|
+
const ref = { space: this.space, owner: caller.owner, actor: caller.actor, uid: caller.uid, generation: caller.generation };
|
|
5998
|
+
try {
|
|
5999
|
+
const { evidence } = await withIssuerSession({ servers: this.servers ?? DEFAULT_SERVER, space: this.space, auth: this.auth, tls: false }, (s) => s.store.resolve(ref, s.sourceIsLive));
|
|
6000
|
+
return issuedSpawnerStanding(evidence) === "bounded" ? { kind: "bounded", caller, ceiling: evidence.permissions } : { kind: "exempt" };
|
|
6001
|
+
}
|
|
6002
|
+
catch (e) {
|
|
6003
|
+
return { kind: "none", reason: `its issued evidence does not resolve: ${rejectionText(e)}` };
|
|
6004
|
+
}
|
|
6005
|
+
}
|
|
6006
|
+
/** The refusal for a grant its static spawner does not hold, or `undefined` when it holds all of
|
|
6007
|
+
* it. Each part is named, so the caller can drop exactly what overreached. `armsEvents` is whether
|
|
6008
|
+
* the helper's own event plane will be minted: that row is keyed on a principal allocated later,
|
|
6009
|
+
* so it is measured as the spawner's own, the rule the ledger applies. `door` names the command
|
|
6010
|
+
* being bounded: a persona definition is bounded against its definer as a spawn is. */
|
|
6011
|
+
spawnBoundRefusal(bound, grant, armsEvents, door = "spawn") {
|
|
6012
|
+
if (bound.kind === "exempt")
|
|
6013
|
+
return undefined;
|
|
6014
|
+
if (bound.kind === "refused")
|
|
6015
|
+
return `${door}: ${bound.reason}`;
|
|
6016
|
+
let over;
|
|
6017
|
+
let eventsOver;
|
|
6018
|
+
try {
|
|
6019
|
+
over = bound.kind === "none" ? grantParts(grant) : grantOverreach(this.space, bound.caller, bound.ceiling, grant);
|
|
6020
|
+
eventsOver = armsEvents && (bound.kind === "none"
|
|
6021
|
+
|| grantOverreach(this.space, bound.caller, bound.ceiling, { allowPublish: [eventChannel({ owner: bound.caller.owner, actor: bound.caller.actor })] }).length > 0);
|
|
6022
|
+
}
|
|
6023
|
+
catch (e) {
|
|
6024
|
+
return `${door}: ${rejectionText(e)}`;
|
|
6025
|
+
}
|
|
6026
|
+
if (!over.length && !eventsOver)
|
|
6027
|
+
return undefined;
|
|
6028
|
+
const named = [
|
|
6029
|
+
// A role is handed on by a spawner whose credential binds that role's task queue: its own
|
|
6030
|
+
// role, or a `role:<r>` token in its capabilities.
|
|
6031
|
+
...over.map((o) => o.field === "role" ? `role ${o.value} (a spawner hands on a role it was given as its own or as the \`role:${o.value}\` capability)` : `${o.field} ${o.value}`),
|
|
6032
|
+
...(eventsOver ? ["events (the helper's own event plane; pass events: false to spawn without it)"] : []),
|
|
6033
|
+
].join(", ");
|
|
6034
|
+
if (door === "define-persona")
|
|
6035
|
+
return bound.kind === "none"
|
|
6036
|
+
? `define-persona: the definer holds no issued evidence (SPEC 13.15), so its persona can grant nothing: ${bound.reason}. This persona asks for ${named}. ` +
|
|
6037
|
+
`Define it without these, or start the definer under a manager (\`cotal spawn --detach\`), whose seats carry issued evidence.`
|
|
6038
|
+
: `define-persona: this persona grants more than the definer holds: ${named}. A definer hands on only what its own issued credential carries (SPEC 13.15). ` +
|
|
6039
|
+
`Drop these from the persona, or have the operator widen the definer's own persona.`;
|
|
6040
|
+
if (bound.kind === "bounded" && bound.run !== undefined)
|
|
6041
|
+
return `spawn: this spawn hands its helper more than run ${bound.run}'s starter held when the run was admitted: ${named}. ` +
|
|
6042
|
+
`A run hands on only what its admission recorded: its starter's issued credential, or for a \`--local\` run the channels named by \`--admit-read\` and \`--admit-publish\` (SPEC 14.8). ` +
|
|
6043
|
+
`Drop these from the persona or the spawn, or start the run from a caller that holds them.`;
|
|
6044
|
+
return bound.kind === "none"
|
|
6045
|
+
? `spawn: the spawner holds no issued evidence (SPEC 13.15), so it hands on no grant: ${bound.reason}. This spawn asks for ${named}. ` +
|
|
6046
|
+
`Spawn a persona that names none of these, or start the spawner under a manager (\`cotal spawn --detach\`), whose seats carry issued evidence.`
|
|
6047
|
+
: `spawn: this spawn hands its helper more than the spawner holds: ${named}. A spawner hands on only what its own issued credential carries (SPEC 13.15). ` +
|
|
6048
|
+
`Drop these from the persona or the spawn, or have the operator widen the spawner's own persona.`;
|
|
6049
|
+
}
|
|
5726
6050
|
async startAgentActive(opts, spawner, hooks, authenticatedCaller) {
|
|
5727
6051
|
// Before the first await, so the grant, the launch, the retained form and the restart slot all read
|
|
5728
6052
|
// the lists and the restart policy as admitted.
|
|
@@ -5759,7 +6083,11 @@ export class Manager {
|
|
|
5759
6083
|
const persona = await this.spawnPersona(opts, spawner, authenticatedCaller);
|
|
5760
6084
|
if ("error" in persona)
|
|
5761
6085
|
return { ok: false, error: persona.error, ...(persona.code ? { code: persona.code } : {}) };
|
|
5762
|
-
const { ref,
|
|
6086
|
+
const { ref, def, agent, inline } = persona;
|
|
6087
|
+
// An inline persona's file is its snapshot, written once the lifecycle uid is final; until then
|
|
6088
|
+
// a refusal names the persona instead of a path.
|
|
6089
|
+
let configPath = persona.configPath;
|
|
6090
|
+
const where = configPath ?? `inline persona "${ref}"`;
|
|
5763
6091
|
// Materialize the requested connector up front — the ONE async step in the spawn path (a lazy
|
|
5764
6092
|
// `cotal ext` manifest import on the published binary). It runs BEFORE the capacity/reserve span
|
|
5765
6093
|
// below so that span stays fully SYNCHRONOUS and atomic. On the manifest binary this also honors a
|
|
@@ -5777,6 +6105,11 @@ export class Manager {
|
|
|
5777
6105
|
throw new EpEnvelopeError("failed-precondition", error, undefined, "not-executed");
|
|
5778
6106
|
return { ok: false, error };
|
|
5779
6107
|
}
|
|
6108
|
+
// Read here, beside the connector, so the capacity check and the reserve below take no new await.
|
|
6109
|
+
const run = await this.spawnRun(authenticatedCaller, opts.runId);
|
|
6110
|
+
if (run !== undefined && "refused" in run)
|
|
6111
|
+
return { ok: false, error: `spawn: ${run.refused}` };
|
|
6112
|
+
const bound = await this.staticSpawnBound(authenticatedCaller, run);
|
|
5780
6113
|
const readinessTimeoutMs = connector.readinessTimeoutMs ?? this.readinessTimeoutMs;
|
|
5781
6114
|
if (!Number.isSafeInteger(readinessTimeoutMs) || readinessTimeoutMs <= 0)
|
|
5782
6115
|
return { ok: false, error: `connector ${connector.name} declares invalid readinessTimeoutMs ${JSON.stringify(connector.readinessTimeoutMs)}; expected a positive safe integer` };
|
|
@@ -5898,6 +6231,8 @@ export class Manager {
|
|
|
5898
6231
|
let variant = opts.variant;
|
|
5899
6232
|
let launchOptions = opts.launchOptions;
|
|
5900
6233
|
let prompt = opts.prompt;
|
|
6234
|
+
// The read set the persona and the overrides name, before `join` adds to it: what a saved copy keeps.
|
|
6235
|
+
let ownSubscribe;
|
|
5901
6236
|
if (opts.resolved) {
|
|
5902
6237
|
// A manifest launch is the access + identity authority: imperative overrides arriving
|
|
5903
6238
|
// alongside `resolved` are a caller contract error, not something to merge (no fallbacks).
|
|
@@ -5932,13 +6267,26 @@ export class Manager {
|
|
|
5932
6267
|
// when the door sent one). The spawner's own event channel is dropped from it: that plane is
|
|
5933
6268
|
// foreign to the child, and the guard below refuses any spawn that asks for one. The joined
|
|
5934
6269
|
// set is never copied from the spawner: a helper starts on what its persona lists plus what
|
|
5935
|
-
// the caller passes in `join`, and nothing else.
|
|
6270
|
+
// the caller passes in `join`, and nothing else. The ACL is the caller's own report, so on a
|
|
6271
|
+
// static mesh it is clamped to what the caller's evidence shows it holds, and a caller with
|
|
6272
|
+
// no evidence gets no default at all.
|
|
5936
6273
|
const personaNamesChannels = def.subscribe !== undefined || def.allowSubscribe !== undefined || def.allowPublish !== undefined;
|
|
5937
|
-
const
|
|
5938
|
-
|
|
5939
|
-
|
|
5940
|
-
}
|
|
6274
|
+
const heldChannels = (field, channels) => {
|
|
6275
|
+
const own = channels.filter((ch) => eventChannelPrincipal(ch) === null);
|
|
6276
|
+
return bound.kind === "bounded" ? own.filter((ch) => grantOverreach(this.space, bound.caller, bound.ceiling, { [field]: [ch] }).length === 0) : own;
|
|
6277
|
+
};
|
|
6278
|
+
let scoped;
|
|
6279
|
+
try {
|
|
6280
|
+
scoped = !personaNamesChannels && opts.callerAcl && bound.kind !== "none" ? {
|
|
6281
|
+
allowSubscribe: heldChannels("allowSubscribe", opts.callerAcl.allowSubscribe),
|
|
6282
|
+
allowPublish: heldChannels("allowPublish", opts.callerAcl.allowPublish),
|
|
6283
|
+
} : undefined;
|
|
6284
|
+
}
|
|
6285
|
+
catch (e) {
|
|
6286
|
+
return { ok: false, error: `callerAcl: ${rejectionText(e)}` };
|
|
6287
|
+
}
|
|
5941
6288
|
const base = opts.subscribe ?? def.subscribe;
|
|
6289
|
+
ownSubscribe = base;
|
|
5942
6290
|
subscribe = opts.join?.length ? [...new Set([...(base ?? []), ...opts.join])] : base;
|
|
5943
6291
|
allowSubscribe = opts.allowSubscribe ?? def.allowSubscribe ?? scoped?.allowSubscribe ?? [];
|
|
5944
6292
|
allowPublish = opts.allowPublish ?? def.allowPublish ?? scoped?.allowPublish;
|
|
@@ -5957,8 +6305,43 @@ export class Manager {
|
|
|
5957
6305
|
allowSubscribe = resolveReadAcl(subscribe ?? [], allowSubscribe);
|
|
5958
6306
|
}
|
|
5959
6307
|
catch (e) {
|
|
5960
|
-
return { ok: false, error: opts.resolved ? `launch agent: ${rejectionText(e)}` : `persona ${
|
|
6308
|
+
return { ok: false, error: opts.resolved ? `launch agent: ${rejectionText(e)}` : `persona ${where}: ${rejectionText(e)}` };
|
|
5961
6309
|
}
|
|
6310
|
+
// The AG-UI event plane is on unless the launch explicitly opted out. A connector that cannot
|
|
6311
|
+
// emit one is refused further down, before anything is minted.
|
|
6312
|
+
const events = this.eventsRequired || opts.events !== false;
|
|
6313
|
+
// THE DELEGATION BOUND, on the grant as it will be minted: every channel and capability the
|
|
6314
|
+
// helper gets, from the persona, the raw overrides and the `callerAcl` default alike, and its own
|
|
6315
|
+
// event plane, so no door reaches the mint around it. Before any reserve or mint, so a refusal
|
|
6316
|
+
// leaves nothing.
|
|
6317
|
+
const boundRefusal = this.spawnBoundRefusal(bound, {
|
|
6318
|
+
allowSubscribe,
|
|
6319
|
+
...(allowPublish !== undefined ? { allowPublish } : {}),
|
|
6320
|
+
...(role !== undefined ? { role } : {}),
|
|
6321
|
+
...(capabilities !== undefined ? { capabilities } : {}),
|
|
6322
|
+
}, events);
|
|
6323
|
+
if (boundRefusal)
|
|
6324
|
+
return { ok: false, error: boundRefusal };
|
|
6325
|
+
// A run saves for the caller it was admitted for, whose catalog reads then find the copy.
|
|
6326
|
+
const savedBy = authenticatedCaller === undefined ? undefined : this.runHosting?.admittedCaller(authenticatedCaller);
|
|
6327
|
+
// What a saved copy keeps: the grant just checked, without the helper's own event row, which each
|
|
6328
|
+
// later spawn derives for its own helper. The file names every channel field, so no later caller's
|
|
6329
|
+
// default fills one, and each later spawn of it is still bounded against that caller.
|
|
6330
|
+
const catalogCopy = inline?.save
|
|
6331
|
+
? {
|
|
6332
|
+
name: ref,
|
|
6333
|
+
owner: savedBy !== undefined ? this.personaOwner(principalKey(savedBy.owner, savedBy.actor).key) : spawner === undefined ? undefined : this.personaOwner(spawner),
|
|
6334
|
+
role,
|
|
6335
|
+
model,
|
|
6336
|
+
variant,
|
|
6337
|
+
description: def?.description,
|
|
6338
|
+
persona: def?.persona,
|
|
6339
|
+
subscribe: [...(ownSubscribe ?? [])],
|
|
6340
|
+
allowSubscribe: [...allowSubscribe],
|
|
6341
|
+
allowPublish: [...(allowPublish ?? [])],
|
|
6342
|
+
capabilities: capabilities?.length ? [...capabilities] : undefined,
|
|
6343
|
+
}
|
|
6344
|
+
: undefined;
|
|
5962
6345
|
// Checked after every path has resolved its selectors: a direct `startAgent` call skips the
|
|
5963
6346
|
// `start` op's checks, and no connector may receive a blank one (#2862).
|
|
5964
6347
|
if (model !== undefined && !model.trim())
|
|
@@ -5967,36 +6350,40 @@ export class Manager {
|
|
|
5967
6350
|
return { ok: false, error: "variant: must not be empty" };
|
|
5968
6351
|
const idErr = this.nameError(identityName);
|
|
5969
6352
|
if (idErr)
|
|
5970
|
-
return { ok: false, error: opts.resolved ? `launch agent: ${idErr}` : `persona ${
|
|
6353
|
+
return { ok: false, error: opts.resolved ? `launch agent: ${idErr}` : `persona ${where}: ${idErr}` };
|
|
5971
6354
|
// #966: a seat labelled `manager` that cannot spawn is a worker wearing a label, and the label
|
|
5972
6355
|
// is what other agents ROUTE on (role is the anycast address). The gap is silent today because
|
|
5973
|
-
// the mismatch is only discovered minutes in, when the seat first reaches for a worker.
|
|
5974
|
-
//
|
|
5975
|
-
//
|
|
5976
|
-
//
|
|
5977
|
-
//
|
|
5978
|
-
// defined the persona over the wire must ask one, because capabilities cannot be declared over
|
|
5979
|
-
// the wire by design. The check keys on the EFFECTIVE role (a spawn-time role: override wins
|
|
6356
|
+
// the mismatch is only discovered minutes in, when the seat first reaches for a worker. Refuse
|
|
6357
|
+
// at accept, before any reserve/mint, naming the remediation for each author: an operator edits
|
|
6358
|
+
// the file to add the grant, and the persona's owner may redefine it with the grant only if the
|
|
6359
|
+
// owner holds spawn, because define-persona bounds capabilities against the definer. The
|
|
6360
|
+
// check keys on the EFFECTIVE role (a spawn-time role: override wins
|
|
5980
6361
|
// over the file, mirroring the resolution above), not the file's, so the label other agents
|
|
5981
6362
|
// will actually see is the label that is held to account. `manager` is the label that MEANS
|
|
5982
6363
|
// spawn-holding: naming it literally keeps this the narrow guard for the observed harm, not a
|
|
5983
6364
|
// vocabulary any caller can trigger with a synonym (roles are free-form and mostly
|
|
5984
6365
|
// non-manager: this workspace's own catalog carries worker/reviewer/orchestrator seats with no
|
|
5985
6366
|
// spawn grant, all legitimate).
|
|
6367
|
+
if (role === "manager" && !capabilities?.includes("spawn") && inline)
|
|
6368
|
+
return {
|
|
6369
|
+
ok: false,
|
|
6370
|
+
error: `inline persona "${ref}" would join with role "manager" but no spawn capability: it presents as a manager ` +
|
|
6371
|
+
`while being unable to seat workers. Add \`capabilities: [spawn]\` to the inline persona (a spawner hands on only what it holds), or give it another role.`,
|
|
6372
|
+
};
|
|
5986
6373
|
if (role === "manager" && !capabilities?.includes("spawn"))
|
|
5987
6374
|
return {
|
|
5988
6375
|
ok: false,
|
|
5989
6376
|
error: `persona "${ref}" would join with role "manager" but no spawn capability: it presents as a manager ` +
|
|
5990
6377
|
`while being unable to seat workers, and it would discover that only on first use. ` +
|
|
5991
|
-
`An operator adds \`capabilities: [spawn]\` to ${configPath}
|
|
5992
|
-
`
|
|
6378
|
+
`An operator adds \`capabilities: [spawn]\` to ${configPath}, or the persona's owner redefines it ` +
|
|
6379
|
+
`with \`capabilities: [spawn]\` (cotal_persona) if the owner holds spawn.`,
|
|
5993
6380
|
};
|
|
5994
6381
|
// #581: the operator's per-role model allowlist, judged on the EFFECTIVE role and model resolved
|
|
5995
6382
|
// above, before any reserve or mint. This is where the launch is performed, so no caller can
|
|
5996
6383
|
// skip it, and a definition pinned to a stale model (or to none) is refused here instead of
|
|
5997
6384
|
// serving on it.
|
|
5998
6385
|
const policyRefusal = modelPolicyRefusal(cotalConfig, {
|
|
5999
|
-
persona: opts.resolved ? `manifest agent "${ref}"` : `persona "${ref}" (${configPath})
|
|
6386
|
+
persona: opts.resolved ? `manifest agent "${ref}"` : configPath ? `persona "${ref}" (${configPath})` : where,
|
|
6000
6387
|
role, model, variant,
|
|
6001
6388
|
// A manifest launch carries its own declared model/variant in these options
|
|
6002
6389
|
// (launchAgentToStartOpts), so there they are the definition's field, never a flag.
|
|
@@ -6044,9 +6431,9 @@ export class Manager {
|
|
|
6044
6431
|
const unportable = runtime.spawnDelegated ? Object.keys(launchOptions ?? {}).filter((k) => typeof launchOptions[k] !== "string") : [];
|
|
6045
6432
|
if (unportable.length)
|
|
6046
6433
|
return { ok: false, error: `runtime "${runtime.kind}" starts seats outside this host, so it cannot pass non-string launch options (${unportable.join(", ")})` };
|
|
6047
|
-
//
|
|
6048
|
-
//
|
|
6049
|
-
//
|
|
6434
|
+
// An event plane the connector cannot emit is refused HERE, before anything is minted: a
|
|
6435
|
+
// connector that cannot emit must fail before provisioning rather than after, as an
|
|
6436
|
+
// unsupported `resume` does.
|
|
6050
6437
|
// The GRANT itself cannot be derived yet. It is keyed on the agent's PRINCIPAL, and in user mode
|
|
6051
6438
|
// the principal's owner is resolved further down, so deriving it from anything in scope here
|
|
6052
6439
|
// would mean guessing at the identity the child will actually connect as. It is added at the
|
|
@@ -6054,7 +6441,6 @@ export class Manager {
|
|
|
6054
6441
|
const eventsRefused = this.eventsRefusal(connector, opts.events);
|
|
6055
6442
|
if (eventsRefused)
|
|
6056
6443
|
return { ok: false, error: eventsRefused };
|
|
6057
|
-
const events = this.eventsRequired || opts.events !== false;
|
|
6058
6444
|
// The roster the allocation consults must reflect the initial presence snapshot,
|
|
6059
6445
|
// or a spawn immediately after manager boot races an already-live unmanaged peer and re-opens the
|
|
6060
6446
|
// very collision black-hole this closes. Await the snapshot (bounded internally, fail-safe on an
|
|
@@ -6111,6 +6497,9 @@ export class Manager {
|
|
|
6111
6497
|
let provisioned;
|
|
6112
6498
|
// A carried resume's seat-private home, removed by the finally unless a live seat took it.
|
|
6113
6499
|
let seatHome;
|
|
6500
|
+
// The lifecycle whose inline snapshot this spawn wrote: removed by the finally unless a live seat
|
|
6501
|
+
// took it. A write that failed sets nothing, so the finally never removes a file it did not make.
|
|
6502
|
+
let snapshotUid;
|
|
6114
6503
|
// Taken right before the try so its finally is the only release; a refusal belongs above the
|
|
6115
6504
|
// allocation, where there is no name to give back.
|
|
6116
6505
|
hooks?.onEffect?.();
|
|
@@ -6228,6 +6617,7 @@ export class Manager {
|
|
|
6228
6617
|
label: ref,
|
|
6229
6618
|
lifecycleUid,
|
|
6230
6619
|
delegatedIntent: opts.delegatedIntent,
|
|
6620
|
+
...(opts.served !== undefined ? { spawnRequest: this.servedSpawnRequest(opts.served, authenticatedCaller) } : {}),
|
|
6231
6621
|
// Hold the name at the UID the host enrolled for the intent's user before any later local
|
|
6232
6622
|
// step runs, so no later failure, its refusal included, releases a lifecycle that only that
|
|
6233
6623
|
// user's retirement intent retires.
|
|
@@ -6323,6 +6713,18 @@ export class Manager {
|
|
|
6323
6713
|
// The realpath the admission check above already resolved (#385 item 2) — never re-resolved here,
|
|
6324
6714
|
// so the admission refusal and the launch cwd can never disagree on the same request.
|
|
6325
6715
|
const cwd = resolvedCwd ?? this.workspaceRoot;
|
|
6716
|
+
// An inline persona's snapshot, and its catalog copy when saved, are written here: after the
|
|
6717
|
+
// grant (the static issuance, the user-auth ledger, a hosted enrollment that may replace the
|
|
6718
|
+
// uid) and before launch, so a refused grant leaves neither file and the snapshot carries the
|
|
6719
|
+
// final uid. A copy that loses a create race throws, and the finally rolls the grant back.
|
|
6720
|
+
if (inline) {
|
|
6721
|
+
configPath = writeInlineSnapshot(this.workspaceRoot, lifecycleUid, { name, role, model, variant, description: def.description, body: def.persona });
|
|
6722
|
+
snapshotUid = lifecycleUid;
|
|
6723
|
+
if (catalogCopy)
|
|
6724
|
+
saveAgentFile(agentFilePath(this.workspaceRoot, ref), catalogCopy, { createOnly: true });
|
|
6725
|
+
}
|
|
6726
|
+
if (configPath === undefined)
|
|
6727
|
+
throw new Error("internal: the persona file is missing at launch");
|
|
6326
6728
|
const configSha256 = this.fileDigest(configPath);
|
|
6327
6729
|
const manifestPath = opts.launchRef
|
|
6328
6730
|
? join(this.workspaceRoot, ".cotal", "run", `${opts.launchRef.runId}.json`)
|
|
@@ -6425,7 +6827,9 @@ export class Manager {
|
|
|
6425
6827
|
configSha256,
|
|
6426
6828
|
manifestSha256,
|
|
6427
6829
|
}
|
|
6428
|
-
:
|
|
6830
|
+
: inline
|
|
6831
|
+
? { kind: "inline", ref, configPath, configSha256 }
|
|
6832
|
+
: { kind: "persona", ref: opts.config ? identityName : ref, configPath, configSha256 },
|
|
6429
6833
|
cwd,
|
|
6430
6834
|
model,
|
|
6431
6835
|
variant,
|
|
@@ -6486,6 +6890,7 @@ export class Manager {
|
|
|
6486
6890
|
// orphan-rollback in `finally` no longer applies to it.
|
|
6487
6891
|
provisioned = undefined;
|
|
6488
6892
|
seatHome = undefined;
|
|
6893
|
+
snapshotUid = undefined;
|
|
6489
6894
|
// #159 B1: reply on a REAL outcome, not a timer. Wait for the agent to actually join the mesh
|
|
6490
6895
|
// (presence) → started, the child to exit → failed (with its last output; already reaped), or
|
|
6491
6896
|
// neither in time → uncertain. `✓ started` therefore means "it joined", never just "a process
|
|
@@ -6651,10 +7056,15 @@ export class Manager {
|
|
|
6651
7056
|
this.reservedLive.delete(name);
|
|
6652
7057
|
if (seatHome)
|
|
6653
7058
|
rmSync(seatHome, { recursive: true, force: true });
|
|
7059
|
+
// A failed removal is logged, never thrown, so the grant's teardown below is still scheduled.
|
|
7060
|
+
const snapshotGone = snapshotUid === undefined || this.dropInlineSnapshot(name, snapshotUid);
|
|
6654
7061
|
// Minted but never handed to a live slot (buildLaunch / runtime.spawn threw after mint) → tear the
|
|
6655
7062
|
// orphan down (detached, fail-loud) so a failed spawn leaves no creds/durables behind (#159 B).
|
|
6656
7063
|
if (provisioned) {
|
|
6657
|
-
|
|
7064
|
+
// Settled when this spawn wrote no snapshot or removed the one it wrote. A file its write
|
|
7065
|
+
// refused is not this lifecycle's, so the terminal leaves the uid's path alone; a removal
|
|
7066
|
+
// that failed is the terminal's to retry and report.
|
|
7067
|
+
const orphan = { ...provisioned, ...(snapshotGone ? { snapshotSettled: true } : {}) };
|
|
6658
7068
|
// The host enrolled this UID for the intent's user, so only that user's retirement intent
|
|
6659
7069
|
// retires it, and its name stays held for retireDelegatedAgent.
|
|
6660
7070
|
const p = orphan.delegated ? parsePrincipalKey(orphan.id) : null;
|
|
@@ -10610,6 +11020,12 @@ export class Manager {
|
|
|
10610
11020
|
await attemptCleanup(failed, `secret ${creds}`, () => secrets.delete(agentSecretKeyForFile(creds, this.space)));
|
|
10611
11021
|
await attemptCleanup(failed, `file ${creds}`, () => rmSync(creds, { force: true }));
|
|
10612
11022
|
}
|
|
11023
|
+
// The lifecycle's inline snapshot, when no earlier step settled it. Keyed by the uid, so a boot
|
|
11024
|
+
// sweep or a reconcile that knows only the slot row removes it too.
|
|
11025
|
+
if (!a.snapshotSettled) {
|
|
11026
|
+
const snapshot = inlineSnapshotPath(this.workspaceRoot, a.lifecycleUid);
|
|
11027
|
+
await attemptCleanup(failed, `file ${snapshot}`, () => removeInlineSnapshot(this.workspaceRoot, a.lifecycleUid));
|
|
11028
|
+
}
|
|
10613
11029
|
await attemptCleanup(failed, "broker teardown", async () => {
|
|
10614
11030
|
try {
|
|
10615
11031
|
const accounting = await this.deprovisionBroker(a);
|
|
@@ -11475,15 +11891,17 @@ export class Manager {
|
|
|
11475
11891
|
/** Persist a peer-defined persona as config. After this, `start name` auto-discovers
|
|
11476
11892
|
* .cotal/agents/<name>.md and the connector applies its persona/model at spawn.
|
|
11477
11893
|
*
|
|
11478
|
-
* CONTENT vs POLICY: `
|
|
11479
|
-
*
|
|
11894
|
+
* CONTENT vs POLICY: `owner` has no slot, so a peer cannot claim ownership, and the
|
|
11895
|
+
* capabilities the written persona carries, a saved list a redefine keeps included, are bounded
|
|
11896
|
+
* against the definer before the write, so a peer can put only what it holds into a persona that
|
|
11897
|
+
* a wider spawner might later launch. Channel grants, role, and agent are content: they may
|
|
11480
11898
|
* arrive as explicit arguments or as a leading frontmatter block inside `persona`. A prompt
|
|
11481
11899
|
* that starts with `---` is parsed and merged (explicit args win) so the written file has one
|
|
11482
11900
|
* frontmatter block; a malformed block is refused by name rather than wrapped. A fresh name is
|
|
11483
11901
|
* created with the owner {@link personaOwner} records. Redefining an EXISTING file is allowed on
|
|
11484
11902
|
* the privileged tier only if `file.owner == caller`, else admin is required. Fail-closed: an
|
|
11485
11903
|
* ownerless file (legacy / operator-written) is admin-only. */
|
|
11486
|
-
opDefinePersona(args, caller, admin) {
|
|
11904
|
+
async opDefinePersona(args, caller, admin, authenticated) {
|
|
11487
11905
|
if (this.execution === "none")
|
|
11488
11906
|
throw new EpEnvelopeError("unimplemented", this.executionRefusal());
|
|
11489
11907
|
const name = String(args.name ?? "").trim();
|
|
@@ -11497,49 +11915,107 @@ export class Manager {
|
|
|
11497
11915
|
return { ok: false, error: "persona required" };
|
|
11498
11916
|
const model = args.model !== undefined && args.model !== null && String(args.model) !== "" ? String(args.model) : undefined;
|
|
11499
11917
|
const path = agentFilePath(this.workspaceRoot, name);
|
|
11500
|
-
let existing;
|
|
11501
|
-
if (existsSync(path)) {
|
|
11502
|
-
try {
|
|
11503
|
-
existing = loadAgentFile(path);
|
|
11504
|
-
}
|
|
11505
|
-
catch (e) {
|
|
11506
|
-
return { ok: false, error: rejectionText(e) };
|
|
11507
|
-
}
|
|
11508
|
-
if (!admin && existing.owner !== caller) {
|
|
11509
|
-
const owner = existing.owner ? `owned by ${existing.owner}` : "operator-owned (legacy file - no agent owner)";
|
|
11510
|
-
return { ok: false, error: `not authorized to redefine ${name}: ${owner}; only its owner or an operator can` };
|
|
11511
|
-
}
|
|
11512
|
-
}
|
|
11513
11918
|
const strList = (v) => {
|
|
11514
11919
|
if (v === undefined || v === null)
|
|
11515
11920
|
return undefined;
|
|
11516
11921
|
return (Array.isArray(v) ? v : [v]).map((x) => String(x));
|
|
11517
11922
|
};
|
|
11518
|
-
|
|
11519
|
-
|
|
11520
|
-
|
|
11521
|
-
|
|
11522
|
-
|
|
11523
|
-
|
|
11524
|
-
|
|
11525
|
-
|
|
11526
|
-
|
|
11527
|
-
|
|
11528
|
-
|
|
11529
|
-
|
|
11530
|
-
|
|
11531
|
-
|
|
11532
|
-
|
|
11533
|
-
|
|
11923
|
+
const capabilities = strList(args.capabilities);
|
|
11924
|
+
// Read, authorize and compose against the catalog as it is now. Nothing yields between this and
|
|
11925
|
+
// the write, so no other call in this manager can change the file in between. The handler does
|
|
11926
|
+
// not serialize with other processes; their writes are outside this single-manager guarantee.
|
|
11927
|
+
const compose = () => {
|
|
11928
|
+
let existing;
|
|
11929
|
+
if (existsSync(path)) {
|
|
11930
|
+
try {
|
|
11931
|
+
existing = loadAgentFile(path);
|
|
11932
|
+
}
|
|
11933
|
+
catch (e) {
|
|
11934
|
+
return { reply: { ok: false, error: rejectionText(e) } };
|
|
11935
|
+
}
|
|
11936
|
+
if (!admin && existing.owner !== caller) {
|
|
11937
|
+
const owner = existing.owner ? `owned by ${existing.owner}` : "operator-owned (legacy file - no agent owner)";
|
|
11938
|
+
return { reply: { ok: false, error: `not authorized to redefine ${name}: ${owner}; only its owner or an operator can` } };
|
|
11939
|
+
}
|
|
11940
|
+
}
|
|
11941
|
+
try {
|
|
11942
|
+
return {
|
|
11943
|
+
def: composeWirePersona({
|
|
11944
|
+
name,
|
|
11945
|
+
owner: this.personaOwner(caller),
|
|
11946
|
+
prompt: persona,
|
|
11947
|
+
model,
|
|
11948
|
+
role: args.role !== undefined && args.role !== null && String(args.role) !== "" ? String(args.role) : undefined,
|
|
11949
|
+
agent: args.agent !== undefined && args.agent !== null && String(args.agent) !== "" ? String(args.agent) : undefined,
|
|
11950
|
+
subscribe: strList(args.subscribe),
|
|
11951
|
+
allowSubscribe: strList(args.allowSubscribe),
|
|
11952
|
+
allowPublish: strList(args.allowPublish),
|
|
11953
|
+
capabilities,
|
|
11954
|
+
}, existing),
|
|
11955
|
+
};
|
|
11956
|
+
}
|
|
11957
|
+
catch (e) {
|
|
11958
|
+
return { reply: { ok: false, error: rejectionText(e) } };
|
|
11959
|
+
}
|
|
11960
|
+
};
|
|
11961
|
+
let composed = compose();
|
|
11962
|
+
if ("reply" in composed)
|
|
11963
|
+
return composed.reply;
|
|
11964
|
+
// The bound reads the persona as written, so a redefine that omits `capabilities` has the list it
|
|
11965
|
+
// keeps checked too. Resolving the definer's grant yields, so the catalog is read again after it.
|
|
11966
|
+
if (composed.def.capabilities?.length) {
|
|
11967
|
+
const refusal = await this.definerCapabilityRefusal(caller, authenticated);
|
|
11968
|
+
composed = compose();
|
|
11969
|
+
if ("reply" in composed)
|
|
11970
|
+
return composed.reply;
|
|
11971
|
+
const kept = composed.def.capabilities ?? [];
|
|
11972
|
+
const refused = kept.length ? refusal(kept) : undefined;
|
|
11973
|
+
if (refused)
|
|
11974
|
+
return {
|
|
11975
|
+
ok: false,
|
|
11976
|
+
code: "permission-denied",
|
|
11977
|
+
error: capabilities === undefined
|
|
11978
|
+
? `${refused} These come from the saved persona, which a redefine without \`capabilities\` keeps; pass the capabilities the definer holds, or [] to clear them.`
|
|
11979
|
+
: refused,
|
|
11980
|
+
};
|
|
11534
11981
|
}
|
|
11535
11982
|
try {
|
|
11536
|
-
saveAgentFile(path, def);
|
|
11983
|
+
saveAgentFile(path, composed.def);
|
|
11537
11984
|
}
|
|
11538
11985
|
catch (e) {
|
|
11539
11986
|
return { ok: false, error: rejectionText(e) };
|
|
11540
11987
|
}
|
|
11541
11988
|
return { ok: true, data: { name, path } };
|
|
11542
11989
|
}
|
|
11990
|
+
/** Resolve what a persona's definer may put into it, as a check that returns the refusal for
|
|
11991
|
+
* capabilities it does not hold, or `undefined` when it holds them all. The check itself does not
|
|
11992
|
+
* yield. An open mesh mints no credential, so nothing is bounded there. A static definer is
|
|
11993
|
+
* bounded by its issued evidence, as a spawner is; a user-auth definer by its own ledger row's
|
|
11994
|
+
* scope, under the ledger's token rule, with `admin` exempt. A hosted manager reads no ledger, so
|
|
11995
|
+
* it refuses. A later spawn of the persona is bounded again against its own spawner. */
|
|
11996
|
+
async definerCapabilityRefusal(caller, authenticated) {
|
|
11997
|
+
if (this.meshMode === "open")
|
|
11998
|
+
return () => undefined;
|
|
11999
|
+
if (this.meshMode === "static") {
|
|
12000
|
+
const run = await this.spawnRun(authenticated, undefined);
|
|
12001
|
+
const bound = await this.staticSpawnBound(authenticated, run !== undefined && "admission" in run ? run : undefined);
|
|
12002
|
+
return (capabilities) => this.spawnBoundRefusal(bound, { capabilities }, false, "define-persona");
|
|
12003
|
+
}
|
|
12004
|
+
if (this.remoteAuthority)
|
|
12005
|
+
return () => "define-persona: this manager's user authority is hosted, so it cannot read the definer's grant and its persona can grant no capabilities.";
|
|
12006
|
+
const p = parsePrincipalKey(caller);
|
|
12007
|
+
const scope = p ? await resolveAuthProvider().actorScope({ dir: userAuthStateDir(this.workspaceRoot, this.space), owner: p.owner, actor: p.actor }) : undefined;
|
|
12008
|
+
return (capabilities) => {
|
|
12009
|
+
if (scope?.includes("admin"))
|
|
12010
|
+
return undefined;
|
|
12011
|
+
const over = capabilities.filter((c) => !capabilityWithin(scope ?? [], c));
|
|
12012
|
+
if (!over.length)
|
|
12013
|
+
return undefined;
|
|
12014
|
+
return scope === undefined
|
|
12015
|
+
? `define-persona: the definer ${caller} has no grant in this space, so its persona can grant nothing. This persona asks for capabilities ${over.join(", ")}.`
|
|
12016
|
+
: `define-persona: this persona grants more than the definer holds: capabilities ${over.join(", ")} beyond [${scope.join(", ") || "none"}]. Drop these from the persona, or have the operator widen the definer's grant.`;
|
|
12017
|
+
};
|
|
12018
|
+
}
|
|
11543
12019
|
/** Remove one machine-local persona definition without touching any running lifecycle that was
|
|
11544
12020
|
* launched from it. Removal accepts the catalog's safe filename alphabet without applying the
|
|
11545
12021
|
* current spawn-name length or user-mode actor rules, so an obsolete invalid-name entry remains
|