@zq-silk/yui 0.15.5 → 0.15.7

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.
@@ -985,7 +985,7 @@ export const ROOT_COMMAND = buildNode({
985
985
  ] },
986
986
  { id: "workflow", title: "Workflow", entries: ["operator", "project", "task"] },
987
987
  { id: "configuration", title: "Configuration", entries: ["config"] },
988
- { id: "operations", title: "Operations", entries: ["web", "controller", "session", "execution", "job", "jobs", "telemetry", "release"] },
988
+ { id: "operations", title: "Operations", entries: ["web", "controller", "session", "execution", "capability", "job", "jobs", "telemetry", "release"] },
989
989
  { id: "resources", title: "Resources", entries: ["resources"] },
990
990
  { id: "internal", title: "Internal", entries: ["internal"] }
991
991
  ],
@@ -1386,6 +1386,16 @@ export const ROOT_COMMAND = buildNode({
1386
1386
  ],
1387
1387
  children: taskChildren
1388
1388
  },
1389
+ {
1390
+ name: "capability",
1391
+ summary: "Discover and call authorized capabilities through the Controller.",
1392
+ sections: [{ id: "entry", title: "Commands", entries: ["search", "describe", "call"] }],
1393
+ children: [
1394
+ { name: "search", summary: "Search the current authorized directory.", usage: "yui capability search [query] --task <id>" },
1395
+ { name: "describe", summary: "Describe one contract or report Provider ambiguity.", usage: "yui capability describe <name> --task <id> [--provider <id>] [--version <version>]" },
1396
+ { name: "call", summary: "Invoke one implementation under the current managed identity.", usage: "yui capability call <name> --task <id> --input <json> [--provider <id>] [--version <version>] [--request-id <id>]" }
1397
+ ]
1398
+ },
1389
1399
  {
1390
1400
  name: "job",
1391
1401
  summary: "Start, inspect, cancel, or acknowledge a Controller-managed DurableJob.",
package/dist/cli.js CHANGED
@@ -24,6 +24,7 @@ import { nativeAgentEnvironmentNames } from "./agent/launchEnvironment.js";
24
24
  import { runAgentCommand } from "./commands/agentCommands.js";
25
25
  import { runGlobalRoleCommand } from "./commands/globalRoleCommands.js";
26
26
  import { runConfigCommand } from "./commands/configCommands.js";
27
+ import { runCapabilityCommand } from "./commands/capabilityCommands.js";
27
28
  import { CONFIG_DOMAINS } from "./config/configCatalog.js";
28
29
  import { runConfigOverview } from "./commands/configOverview.js";
29
30
  import { parseControllerCleanupOptions, parseControllerStatusOptions, parseControllerRuntimeSnapshot, renderControllerResourceStatus, renderRuntimeIdentitySection, summarizeDurablePhysicalMismatch, runInteractiveControllerCleanup } from "./commands/controllerCommands.js";
@@ -249,6 +250,15 @@ export async function main() {
249
250
  emit(renderCommandHelp(target, VERSION), false, describeCommandTree(target));
250
251
  return;
251
252
  }
253
+ if (args[0] === "capability") {
254
+ const result = await runCapabilityCommand(args.slice(1), home, process.env);
255
+ emit(JSON.stringify(result, null, 2), false, result);
256
+ const kind = result?.kind;
257
+ if (typeof result === "object" && result !== null && !Array.isArray(result)
258
+ && typeof kind === "string" && !["value", "operation"].includes(kind))
259
+ process.exitCode = 5;
260
+ return;
261
+ }
252
262
  if (args[0] === "setup") {
253
263
  if (jsonOutput)
254
264
  throw usageError("Setup does not support --json.");
@@ -0,0 +1,54 @@
1
+ import { usageError } from "../errors/cliError.js";
2
+ import { resolveJobCaller } from "./taskActor.js";
3
+ import { callFileTaskController } from "../controller/clientRuntime.js";
4
+ export async function runCapabilityCommand(args, home, environment) {
5
+ const [action, ...rest] = args;
6
+ const usage = "yui capability search [query] --task <id> | describe <name> --task <id> [--provider <id>] [--version <version>] | call <name> --task <id> --input <json> [--provider <id>] [--version <version>] [--request-id <id>]";
7
+ if (!["search", "describe", "call"].includes(action))
8
+ throw usageError(usage);
9
+ const flags = new Map();
10
+ const positionals = [];
11
+ const allowed = new Set(["--task", "--provider", "--version", "--input", "--request-id"]);
12
+ for (let index = 0; index < rest.length; index += 1) {
13
+ const arg = rest[index];
14
+ if (!arg.startsWith("--")) {
15
+ positionals.push(arg);
16
+ continue;
17
+ }
18
+ const next = rest[++index];
19
+ if (!allowed.has(arg) || flags.has(arg) || next === undefined || next.startsWith("--")) {
20
+ throw usageError(`Invalid capability option: ${arg}.`, usage);
21
+ }
22
+ flags.set(arg, next);
23
+ }
24
+ const taskId = flags.get("--task") ?? environment.YUI_TASK_ID;
25
+ if (!taskId || positionals.length > 1 || (action !== "search" && !positionals.length))
26
+ throw usageError(usage);
27
+ if (action === "search" && [...flags.keys()].some((key) => key !== "--task"))
28
+ throw usageError(usage);
29
+ if (action === "describe" && (flags.has("--input") || flags.has("--request-id")))
30
+ throw usageError(usage);
31
+ let input;
32
+ if (action === "call") {
33
+ if (!flags.has("--input"))
34
+ throw usageError("Capability call requires --input <json>.", usage);
35
+ try {
36
+ input = JSON.parse(flags.get("--input"));
37
+ }
38
+ catch {
39
+ throw usageError("Invalid capability JSON input.");
40
+ }
41
+ }
42
+ return callFileTaskController(home, `capability.${action}`, {
43
+ taskId, caller: resolveJobCaller(environment, taskId),
44
+ ...(action === "search" ? { query: positionals[0] ?? "" } : {
45
+ request: {
46
+ name: positionals[0],
47
+ ...(input === undefined ? {} : { input }),
48
+ ...(flags.has("--provider") ? { providerId: flags.get("--provider") } : {}),
49
+ ...(flags.has("--version") ? { contractVersion: flags.get("--version") } : {}),
50
+ ...(flags.has("--request-id") ? { requestId: flags.get("--request-id") } : {})
51
+ }
52
+ })
53
+ }, { environment });
54
+ }
@@ -471,30 +471,38 @@ function updateTaskCommand(args, store, options) {
471
471
  const tags = parsed.options.has("--tags")
472
472
  ? parseTaskTags(requiredOption(parsed.options, "--tags"))
473
473
  : undefined;
474
+ const result = updateTaskMetadataCommand(store, parsed.positionals[0], {
475
+ ...(parsed.options.has("--title") ? { title: requiredOption(parsed.options, "--title") } : {}),
476
+ ...(parsed.options.has("--type")
477
+ ? { type: requiredOption(parsed.options, "--type") }
478
+ : parsed.options.has("--clear-type") ? { type: null } : {}),
479
+ ...(parsed.options.has("--description")
480
+ ? { description: requiredOption(parsed.options, "--description") }
481
+ : parsed.options.has("--clear-description") ? { description: null } : {}),
482
+ ...(priority === undefined
483
+ ? parsed.options.has("--clear-priority") ? { priority: null } : {}
484
+ : { priority }),
485
+ ...(tags === undefined
486
+ ? parsed.options.has("--clear-tags") ? { tags: null } : {}
487
+ : { tags }),
488
+ ...(dueAt === undefined
489
+ ? parsed.options.has("--clear-due-at") ? { dueAt: null } : {}
490
+ : { dueAt })
491
+ }, options);
492
+ return `Updated task ${result.id}\n`;
493
+ }
494
+ /** Shared domain transaction for structured capabilities and the legacy CLI.
495
+ * Parsing text flags must not become an alternate business write path. */
496
+ export function updateTaskMetadataCommand(store, taskId, patch, options = {}) {
497
+ if (Object.keys(patch).length === 0)
498
+ throw usageError("At least one Task metadata field is required.");
474
499
  const now = clock(options);
475
500
  const result = store.transaction((tx) => {
476
- const current = requireTask(tx, parsed.positionals[0]);
501
+ const current = requireTask(tx, taskId);
477
502
  if (current.status === "archived")
478
503
  throw usageError(`Task is archived: ${current.id}.`);
479
504
  taskActor(tx, options, current.id);
480
- const updated = updateTaskMetadata(current, {
481
- ...(parsed.options.has("--title") ? { title: requiredOption(parsed.options, "--title") } : {}),
482
- ...(parsed.options.has("--type")
483
- ? { type: requiredOption(parsed.options, "--type") }
484
- : parsed.options.has("--clear-type") ? { type: null } : {}),
485
- ...(parsed.options.has("--description")
486
- ? { description: requiredOption(parsed.options, "--description") }
487
- : parsed.options.has("--clear-description") ? { description: null } : {}),
488
- ...(priority === undefined
489
- ? parsed.options.has("--clear-priority") ? { priority: null } : {}
490
- : { priority }),
491
- ...(tags === undefined
492
- ? parsed.options.has("--clear-tags") ? { tags: null } : {}
493
- : { tags }),
494
- ...(dueAt === undefined
495
- ? parsed.options.has("--clear-due-at") ? { dueAt: null } : {}
496
- : { dueAt })
497
- }, now);
505
+ const updated = updateTaskMetadata(current, patch, now);
498
506
  tx.saveTask(updated);
499
507
  recordTaskEvent(tx, updated.id, "task.updated", {
500
508
  status: updated.status,
@@ -504,7 +512,7 @@ function updateTaskCommand(args, store, options) {
504
512
  return updated;
505
513
  });
506
514
  notifyMailbox(options.runtime, taskMailbox(result.id), result.id);
507
- return `Updated task ${result.id}\n`;
515
+ return result;
508
516
  }
509
517
  export function submitOperatorMessage(body, taskId, store, options = {}) {
510
518
  const now = clock(options);
@@ -0,0 +1,66 @@
1
+ import { capabilitySchemaError } from "../kernel/capabilitySchema.js";
2
+ /** The same transport method is usable by CLI, Agent and an authenticated
3
+ * Surface adapter. Credentials are an ingress envelope, never capability input. */
4
+ export function createCapabilityDispatcher(capabilities) {
5
+ return async (method, value) => {
6
+ const error = capabilitySchemaError({
7
+ type: "object", required: ["taskId", "caller"],
8
+ additionalProperties: false,
9
+ properties: {
10
+ taskId: { type: "string", minLength: 1 },
11
+ caller: {
12
+ type: "object", required: ["scope"], additionalProperties: false,
13
+ properties: {
14
+ scope: { enum: ["user", "global", "task"] }, taskId: { type: "string" },
15
+ role: { type: "string" }, agentId: { type: "string" }, adapterId: { type: "string" },
16
+ runtimeGenerationId: { type: "string" }, nativeSessionId: { type: "string" },
17
+ turnId: { type: "string" }, callerKey: { type: "string" }
18
+ }
19
+ },
20
+ query: { type: "string" },
21
+ request: {
22
+ type: "object", required: ["name"], additionalProperties: false,
23
+ properties: {
24
+ name: { type: "string", minLength: 1 }, input: {},
25
+ contractVersion: { type: "string", minLength: 1 },
26
+ providerId: { type: "string", minLength: 1 },
27
+ requestId: { type: "string", minLength: 1 }
28
+ }
29
+ }
30
+ }
31
+ }, value);
32
+ if (error)
33
+ throw invalidParams(`Invalid capability envelope: ${error}`);
34
+ const params = value;
35
+ const context = capabilities.authenticate(params.caller, params.taskId);
36
+ let result;
37
+ if (method === "capability.search") {
38
+ if (params.request !== undefined)
39
+ throw invalidParams("search accepts query, not request.");
40
+ result = { capabilities: capabilities.registry.search(context, params.query) };
41
+ }
42
+ else {
43
+ if (params.query !== undefined || params.request === undefined)
44
+ throw invalidParams("describe/call requires request.");
45
+ if (method === "capability.describe")
46
+ result = capabilities.registry.describe(context, params.request);
47
+ else if (method === "capability.call") {
48
+ if (!Object.hasOwn(params.request, "input"))
49
+ throw invalidParams("call requires input.");
50
+ // Target is bound before acquisition, not inferred from plugin actor data.
51
+ const input = params.request.input;
52
+ if (typeof input === "object" && input !== null && "taskId" in input
53
+ && input.taskId !== params.taskId)
54
+ throw invalidParams("Capability target is outside the authenticated Task.");
55
+ result = await capabilities.registry.call(context, params.request);
56
+ }
57
+ else
58
+ throw invalidParams("Unknown capability method.");
59
+ }
60
+ return JSON.parse(JSON.stringify(result));
61
+ };
62
+ }
63
+ /** Expected ingress rejections use the Controller's existing safe error shape. */
64
+ function invalidParams(message) {
65
+ return Object.assign(new Error(message), { name: "CoreApplicationError", code: "INVALID_PARAMS" });
66
+ }
@@ -1667,6 +1667,19 @@ export async function startFileTaskController(home, store, delivery, dispatcher,
1667
1667
  const intervalMs = runtime.reloadReconciliationInterval();
1668
1668
  return { configured: true, reconciliationIntervalMs: intervalMs };
1669
1669
  }
1670
+ if (method === "capability.search" || method === "capability.describe" || method === "capability.call") {
1671
+ if (options.capabilityDispatcher === undefined) {
1672
+ throw controllerApplicationError("METHOD_NOT_FOUND", "Capability ingress is unavailable.");
1673
+ }
1674
+ const request = Promise.resolve(options.capabilityDispatcher(method, params));
1675
+ lifecycleRequests.add(request);
1676
+ try {
1677
+ return await request;
1678
+ }
1679
+ finally {
1680
+ lifecycleRequests.delete(request);
1681
+ }
1682
+ }
1670
1683
  if (method === "job.start" || method === "job.get" || method === "job.cancel" || method === "job.acknowledge") {
1671
1684
  const control = options.jobControl;
1672
1685
  if (control === undefined) {
@@ -23,6 +23,7 @@ import { openSchedulerTelemetry } from "../telemetry/telemetryWiring.js";
23
23
  import { createFileArtifactPort, createLinuxProcessPort, DurableJobSupervisor } from "./jobSupervisor.js";
24
24
  import { authorizeJobStart } from "./jobControl.js";
25
25
  import { createKernelPorts } from "../kernel/kernelPorts.js";
26
+ import { createCapabilityDispatcher } from "./capabilityBridge.js";
26
27
  import { FileRuntimeEventInbox } from "./runtimeEventInbox.js";
27
28
  import { AgentRuntimeObserver } from "./agentRuntimeObserver.js";
28
29
  import { AsyncRuntimeEventProcessor, FileRuntimeEventProcessor, createAsyncRuntimeObserver, } from "./runtimeEventProcessor.js";
@@ -356,7 +357,9 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
356
357
  // supervisor enqueues a durable-job-terminal event; the processor drains it
357
358
  // on the next pass, waking the Controller immediately instead of waiting for
358
359
  // the poll interval.
359
- const kernel = createKernelPorts(store, createLinuxProcessPort());
360
+ const kernel = createKernelPorts(store, createLinuxProcessPort(), (taskId) => {
361
+ runningRuntime?.signal(`task:${taskId}`);
362
+ });
360
363
  const jobSupervisor = new DurableJobSupervisor({
361
364
  store: schedulerStore,
362
365
  process: kernel.runner,
@@ -419,6 +422,7 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
419
422
  lifecycleHost,
420
423
  jobSupervisor,
421
424
  jobControl,
425
+ capabilityDispatcher: createCapabilityDispatcher(kernel.capabilities),
422
426
  ...(continuationReconciler === undefined ? {} : { continuationReconciler }),
423
427
  ...(resourceReaper === undefined ? {} : { resourceReaper }),
424
428
  resourceAutoGc,
@@ -76,8 +76,9 @@ export class RuntimeLaunchCoordinator {
76
76
  if (request.owner.scope === "global" && request.managedWorkspace !== undefined) {
77
77
  throw new Error("A global runtime cannot use a Task ManagedWorkspace.");
78
78
  }
79
+ let currentRequest = request;
79
80
  const assertLaunchCurrent = () => {
80
- this.#assertCurrent?.(request);
81
+ this.#assertCurrent?.(currentRequest);
81
82
  assertCurrent?.();
82
83
  };
83
84
  const proposedGenerationId = requireText(this.#createGenerationId(), "Launch generation id");
@@ -155,8 +156,20 @@ export class RuntimeLaunchCoordinator {
155
156
  throw new Error("Runtime host reported its pre-start launch fence more than once.");
156
157
  }
157
158
  validateRuntimeLaunchPreflight(preflight, request, runtimeGenerationId);
159
+ this.reservations.confirmRuntimeLaunchReservation({
160
+ owner: request.owner,
161
+ runtimeGenerationId
162
+ }, assertLaunchCurrent);
158
163
  preflightObserved = true;
159
164
  beforeHostStart?.(preflight);
165
+ // The pre-start persistence callback hands the fixed native Session to
166
+ // this reserved activation. From here on, fence against that exact new
167
+ // identity, not the historical Host we originally set out to restore.
168
+ // Do not accept both identities: a later change back is stale as well.
169
+ if (request.mode === "resume" && request.hostActivationId !== undefined) {
170
+ currentRequest = { ...request, hostActivationId: runtimeGenerationId };
171
+ }
172
+ assertLaunchCurrent();
160
173
  };
161
174
  let binding;
162
175
  try {
@@ -257,7 +270,7 @@ export class RuntimeLaunchCoordinator {
257
270
  throw error;
258
271
  }
259
272
  if (reusedConfirmedRunningHost && binding.hostCreated === true) {
260
- await this.#compensateStartedHost(request.owner, binding, runtimeGenerationId, runtimeIsolation, new Error(`Runtime host was recreated while recovering an existing generation: ${request.owner.roleName}.`));
273
+ await this.#compensateStartedHost(request.owner, binding, runtimeGenerationId, runtimeIsolation, reusedConfirmedRunningHost, new Error(`Runtime host was recreated while recovering an existing generation: ${request.owner.roleName}.`));
261
274
  }
262
275
  try {
263
276
  assertLaunchCurrent();
@@ -282,7 +295,7 @@ export class RuntimeLaunchCoordinator {
282
295
  }
283
296
  }
284
297
  catch (error) {
285
- await this.#compensateStartedHost(request.owner, binding, runtimeGenerationId, runtimeIsolation, error);
298
+ await this.#compensateStartedHost(request.owner, binding, runtimeGenerationId, runtimeIsolation, reusedConfirmedRunningHost, error);
286
299
  }
287
300
  return binding;
288
301
  }
@@ -317,7 +330,14 @@ export class RuntimeLaunchCoordinator {
317
330
  return;
318
331
  this.#requireCleanup(request.owner);
319
332
  }
320
- async #compensateStartedHost(owner, binding, runtimeGenerationId, runtimeIsolation, cause) {
333
+ async #compensateStartedHost(owner, binding, runtimeGenerationId, runtimeIsolation, reusedConfirmedRunningHost, cause) {
334
+ if (reusedConfirmedRunningHost && binding.hostCreated !== true) {
335
+ // Reattaching an existing activation gives this launch no ownership of
336
+ // it. A fresh activation inside a persistent Host is different: this
337
+ // launch still owns its startup and isolation cleanup even when the
338
+ // physical Host did not need to be created.
339
+ throw new RuntimeLaunchStateChangedError(`Runtime launch state changed while reusing a Host: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
340
+ }
321
341
  try {
322
342
  await this.host.stop(binding);
323
343
  }
@@ -0,0 +1,188 @@
1
+ import { updateTaskMetadataCommand } from "../commands/taskCommands.js";
2
+ import { runConfigCommand } from "../commands/configCommands.js";
3
+ import { CONFIG_DOMAINS } from "../config/configCatalog.js";
4
+ import { createJobCallAuthority, parseDurableJobStartParams } from "../controller/jobControl.js";
5
+ import { inspectJobOperation } from "./kernelPorts.js";
6
+ import { CapabilityRegistry, } from "./capabilityRegistry.js";
7
+ const text = { type: "string", minLength: 1 };
8
+ const strings = { type: "object", additionalProperties: { type: "string" } };
9
+ const object = (properties, required = Object.keys(properties)) => ({
10
+ type: "object", properties, required, additionalProperties: false
11
+ });
12
+ const taskInput = object({ taskId: text });
13
+ const taskOutput = { type: "object", required: ["id", "status", "title"], properties: {
14
+ id: text, status: text, title: text
15
+ } };
16
+ const provider = Object.freeze({ id: "yui:builtin-capabilities", generation: "1" });
17
+ /** Source locators identify the existing semantic owner, not a new Store. */
18
+ const definitions = [
19
+ {
20
+ name: "task.read", summary: "Read the current Task record.", effect: "query",
21
+ inputSchema: taskInput, outputSchema: taskOutput, requiredPermissions: ["task:read"],
22
+ source: "TaskStore.getTask (task show)"
23
+ },
24
+ {
25
+ name: "task.update", summary: "Update Task metadata through the existing command transaction.",
26
+ effect: "local-mutation", requiredPermissions: ["task:manage"],
27
+ source: "updateTaskMetadataCommand (task update)",
28
+ inputSchema: object({
29
+ taskId: text,
30
+ patch: object({
31
+ title: text, description: { type: "string" },
32
+ priority: { enum: ["low", "medium", "high", "urgent"] },
33
+ tags: { type: "array", items: text }
34
+ }, [])
35
+ }), outputSchema: taskOutput
36
+ },
37
+ {
38
+ name: "config.read", summary: "Read effective global configuration (Operator only).",
39
+ effect: "query", requiredPermissions: ["config:read"],
40
+ source: "runConfigCommand (config <domain> show)",
41
+ inputSchema: object({ domain: { enum: CONFIG_DOMAINS } }),
42
+ outputSchema: { type: "object" }
43
+ },
44
+ {
45
+ name: "resource.workspaces", summary: "Read the Task's managed workspace resources.",
46
+ effect: "query", requiredPermissions: ["task:read"],
47
+ source: "TaskStore.listManagedWorkspaces (task workspace list)",
48
+ inputSchema: taskInput, outputSchema: { type: "array", items: { type: "object", required: ["owner", "root", "entries"] } }
49
+ },
50
+ {
51
+ name: "job.get", summary: "Read the original Job and its operation evidence.",
52
+ effect: "query", requiredPermissions: ["task:read"],
53
+ source: "DurableJobControlPort.getJob (job.get RPC)",
54
+ inputSchema: object({ taskId: text, jobId: text }),
55
+ outputSchema: { type: "object", required: ["job", "operation"] }
56
+ },
57
+ {
58
+ name: "job.start", summary: "Request an idempotent Job through the existing Controller owner.",
59
+ effect: "external-operation", requiredPermissions: ["job:start"],
60
+ source: "DurableJobControlPort.startJob (job.start RPC)",
61
+ inputSchema: object({
62
+ taskId: text, projectId: text, head: text, workspace: text,
63
+ owner: { anyOf: [
64
+ object({ kind: { const: "task" } }),
65
+ object({ kind: { const: "work-item" }, workItemId: text }),
66
+ object({ kind: { const: "integration-attempt" }, integrationAttemptId: text })
67
+ ] },
68
+ env: strings,
69
+ steps: { type: "array", minItems: 1, items: object({
70
+ name: text, command: text, timeoutMs: { type: "integer" }
71
+ }, ["name", "command"]) },
72
+ retryOf: text
73
+ }, ["taskId", "projectId", "head", "workspace", "owner", "env", "steps"]),
74
+ outputSchema: { type: "object", required: ["job", "created", "operation"], properties: { created: { type: "boolean" } } }
75
+ },
76
+ ...["create", "validate", "activate", "disable"].map((action) => ({
77
+ name: `plugin.${action}`, summary: "Plugin SDK management is not implemented.",
78
+ effect: "local-mutation", requiredPermissions: ["plugin:manage"],
79
+ source: "T09 (not implemented)", inputSchema: object({}), outputSchema: object({}),
80
+ unavailable: "The executable plugin SDK is not implemented. Existing Agent Drivers and Project Skills retain their typed management interfaces."
81
+ }))
82
+ ];
83
+ export const BUILTIN_CAPABILITIES = definitions.map((entry) => ({
84
+ ...entry, contractVersion: "1", provider, scope: { kind: "global" }
85
+ }));
86
+ /** Authenticated managed bridge. Socket possession/scope JSON is not a grant.
87
+ * A future user/Web ingress must provide its own verified identity adapter;
88
+ * this one deliberately accepts only the existing managed credentials. */
89
+ export function createBuiltinCapabilities(host, store, jobs, signal = () => undefined) {
90
+ const authority = createJobCallAuthority(store);
91
+ const callers = new WeakMap();
92
+ const current = (context) => {
93
+ authority.authorize(context, context.targetId);
94
+ const caller = callers.get(context);
95
+ if (!caller)
96
+ throw new Error("Untrusted capability ingress.");
97
+ return caller;
98
+ };
99
+ const implementation = {
100
+ invoke(name, input, invocation) {
101
+ const caller = current(invocation.context);
102
+ const params = input;
103
+ if (params.taskId !== undefined && params.taskId !== invocation.context.targetId) {
104
+ throw new Error("Capability target is outside the authenticated Task.");
105
+ }
106
+ const taskId = invocation.context.targetId;
107
+ if (name === "task.read")
108
+ return requireTask(store, taskId);
109
+ if (name === "resource.workspaces")
110
+ return store.listManagedWorkspaces(taskId);
111
+ if (name === "config.read")
112
+ return runConfigCommand(params.domain, ["show"], store).data;
113
+ if (name === "task.update") {
114
+ const patch = params.patch;
115
+ return updateTaskMetadataCommand(store, taskId, {
116
+ ...patch,
117
+ ...(patch.description === "" ? { description: null } : {}),
118
+ ...(patch.tags?.length === 0 ? { tags: null } : {})
119
+ }, {
120
+ environment: callerEnvironment(caller),
121
+ runtime: { notifyStateChanged: signal, reconcileTask: signal }
122
+ });
123
+ }
124
+ if (name === "job.get") {
125
+ const job = jobs.getJob(taskId, params.jobId);
126
+ if (!job)
127
+ throw new Error(`Job not found: ${taskId}/${params.jobId}.`);
128
+ const operation = inspectJobOperation(job);
129
+ invocation.observe(operation);
130
+ return { job, operation };
131
+ }
132
+ if (name === "job.start") {
133
+ const parsed = parseDurableJobStartParams({
134
+ ...params, caller, requestId: invocation.requestId
135
+ });
136
+ const { job, created } = jobs.startJob(parsed, new Date());
137
+ const operation = inspectJobOperation(job);
138
+ invocation.observe(operation);
139
+ if (created)
140
+ signal(taskId);
141
+ return { job, created, operation };
142
+ }
143
+ throw new Error(`Builtin capability unavailable: ${name}.`);
144
+ }
145
+ };
146
+ host.attach(provider, implementation);
147
+ const registry = new CapabilityRegistry(host, (context, descriptor, input) => {
148
+ const caller = current(context);
149
+ const task = requireTask(store, context.targetId);
150
+ if (typeof input === "object" && input !== null && "taskId" in input && input.taskId !== task.id) {
151
+ throw new Error("Capability target is outside the authenticated Task.");
152
+ }
153
+ for (const permission of descriptor?.requiredPermissions ?? []) {
154
+ if (permission === "task:read" || permission === "job:start")
155
+ continue;
156
+ if (permission === "task:manage" && ((caller.scope === "task" && caller.role === "leader")
157
+ || (caller.scope === "global" && caller.role === "operator")))
158
+ continue;
159
+ if ((permission === "config:read" || permission === "plugin:manage")
160
+ && caller.scope === "global" && caller.role === "operator")
161
+ continue;
162
+ throw new Error(`Permission unavailable: ${permission}.`);
163
+ }
164
+ return { taskIds: [task.id], projectIds: task.projectBindings.map((binding) => binding.projectId) };
165
+ }, BUILTIN_CAPABILITIES);
166
+ return {
167
+ registry,
168
+ authenticate(caller, taskId) {
169
+ const credential = Object.freeze({ ...caller });
170
+ const context = authority.authenticate(credential, taskId);
171
+ callers.set(context, credential);
172
+ return context;
173
+ }
174
+ };
175
+ }
176
+ function requireTask(store, taskId) {
177
+ const task = store.getTask(taskId);
178
+ if (!task)
179
+ throw new Error(`Task not found: ${taskId}.`);
180
+ return task;
181
+ }
182
+ function callerEnvironment(caller) {
183
+ return {
184
+ YUI_SESSION_SCOPE: caller.scope, YUI_TASK_ID: caller.taskId,
185
+ YUI_ROLE: caller.role, YUI_AGENT_ID: caller.agentId,
186
+ YUI_ADAPTER_ID: caller.adapterId, YUI_JOB_CALLER_KEY: caller.callerKey
187
+ };
188
+ }
@@ -0,0 +1,268 @@
1
+ import { capabilitySchemaError, checkCapabilitySchema } from "./capabilitySchema.js";
2
+ const effectRank = { query: 0, "local-mutation": 1, "external-operation": 2 };
3
+ const reserved = new Set(["yui", "task", "config", "job", "resource", "plugin", "runtime", "grant", "capability"]);
4
+ /** One rebuildable descriptor view over the composition root's existing Host.
5
+ * Only the trusted root owns this object. Extensions receive a bound invocation
6
+ * port, never the registry/Host or an actor-selecting call interface. */
7
+ export class CapabilityRegistry {
8
+ host;
9
+ authorize;
10
+ #descriptors = [];
11
+ #coreProviders = new Set();
12
+ constructor(host, authorize, builtins = []) {
13
+ this.host = host;
14
+ this.authorize = authorize;
15
+ this.#publish(builtins, true);
16
+ builtins.forEach((entry) => this.#coreProviders.add(entry.provider.id));
17
+ }
18
+ /** Publish a complete provider generation atomically after owner initialization.
19
+ * Failure leaves all existing descriptors intact. Host lifecycle remains owned
20
+ * by the root: publish replacement, then detach old generation when appropriate. */
21
+ register(descriptors) {
22
+ this.#publish(descriptors, false);
23
+ }
24
+ disable(providerId) {
25
+ this.#descriptors = this.#descriptors.filter((entry) => entry.provider.id !== providerId);
26
+ }
27
+ search(context, query = "") {
28
+ const visibility = this.authorize(context);
29
+ const authorized = this.#descriptors.filter((entry) => visible(entry.scope, visibility))
30
+ .filter((entry) => {
31
+ try {
32
+ this.authorize(context, entry);
33
+ return true;
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ });
39
+ return authorized.filter((entry) => `${entry.name} ${entry.summary}`.toLowerCase().includes(query.toLowerCase()))
40
+ .map((entry) => {
41
+ const unavailable = this.#unavailable(entry, authorized, new Set());
42
+ return unavailable === undefined ? entry : Object.freeze({ ...entry, unavailable });
43
+ });
44
+ }
45
+ describe(context, request) {
46
+ try {
47
+ const candidates = this.search(context).filter((entry) => entry.name === request.name
48
+ && (request.contractVersion === undefined || request.contractVersion === entry.contractVersion)
49
+ && (request.providerId === undefined || request.providerId === entry.provider.id));
50
+ if (!candidates.length)
51
+ return result("unavailable", "No authorized compatible Provider is available.");
52
+ const available = candidates.filter((entry) => entry.unavailable === undefined);
53
+ if (!available.length)
54
+ return {
55
+ ...result("unavailable", candidates.map((entry) => `${entry.provider.id}: ${entry.unavailable}`).join("; ")),
56
+ candidates
57
+ };
58
+ if (available.length > 1)
59
+ return { ...result("ambiguous", "Select an explicit Provider and contract version."), candidates };
60
+ const selected = available[0];
61
+ return {
62
+ ...result("value"), value: selected, candidates, provider: selected.provider,
63
+ selection: request.providerId === undefined ? "unique" : "explicit"
64
+ };
65
+ }
66
+ catch {
67
+ return result("denied", "Current call authority is unavailable.");
68
+ }
69
+ }
70
+ call(context, request) {
71
+ return this.#call(context, request, "external-operation");
72
+ }
73
+ async #call(context, request, ceiling) {
74
+ // Freeze one input snapshot across validation/acquisition and async nesting.
75
+ try {
76
+ request = deepFreeze(structuredClone(request));
77
+ }
78
+ catch {
79
+ return result("invalid", "Capability request must be cloneable data.");
80
+ }
81
+ const resolved = this.describe(context, request);
82
+ if (resolved.kind !== "value")
83
+ return resolved;
84
+ const descriptor = resolved.value;
85
+ const origin = { provider: descriptor.provider, selection: resolved.selection };
86
+ if (effectRank[descriptor.effect] > effectRank[ceiling]) {
87
+ return { ...result("denied", "Nested call exceeds the parent's effect boundary."), ...origin };
88
+ }
89
+ const inputError = capabilitySchemaError(descriptor.inputSchema, request.input);
90
+ if (inputError)
91
+ return { ...result("invalid", inputError), ...origin };
92
+ if (descriptor.effect !== "query" && (typeof request.requestId !== "string" || !request.requestId.trim())) {
93
+ return { ...result("invalid", "Effectful calls require requestId."), ...origin };
94
+ }
95
+ try {
96
+ this.authorize(context, descriptor, request.input);
97
+ }
98
+ catch {
99
+ return { ...result("denied", "Current call authority was revoked."), ...origin };
100
+ }
101
+ const operations = [];
102
+ let effect = "none";
103
+ const observe = (operation) => {
104
+ operations.push(structuredClone(operation));
105
+ effect = accumulatedEffect(effect, operation.effect);
106
+ };
107
+ let entered = false;
108
+ try {
109
+ const value = await this.host.use(descriptor.provider, async (implementation) => {
110
+ entered = true;
111
+ let open = true;
112
+ const children = [];
113
+ try {
114
+ return await implementation.invoke(descriptor.name, request.input, Object.freeze({
115
+ context, requestId: request.requestId,
116
+ observe: (operation) => {
117
+ if (!open)
118
+ throw new Error("Capability invocation has ended.");
119
+ observe(operation);
120
+ },
121
+ call: (nested) => {
122
+ if (!open)
123
+ return Promise.resolve(result("denied", "Capability invocation has ended."));
124
+ const child = this.#call(context, nested, descriptor.effect).then((outcome) => {
125
+ outcome.operations.forEach(observe);
126
+ effect = accumulatedEffect(effect, outcome.effect);
127
+ return outcome;
128
+ });
129
+ children.push(child);
130
+ return child;
131
+ }
132
+ }));
133
+ }
134
+ finally {
135
+ open = false;
136
+ // A wrapper throwing early must not detach its already-issued child
137
+ // operations from the returned evidence or release the parent handle.
138
+ await Promise.all(children);
139
+ }
140
+ });
141
+ if (descriptor.effect === "local-mutation")
142
+ effect = accumulatedEffect(effect, "confirmed");
143
+ // An external implementation without an owner's operation reference is not
144
+ // proof of success. Preserve uncertainty rather than return a value success.
145
+ if (descriptor.effect === "external-operation" && !operations.length) {
146
+ return { kind: "failed", detail: "External implementation returned no operation evidence.", effect: "possible", operations, ...origin };
147
+ }
148
+ let output;
149
+ try {
150
+ output = JSON.parse(JSON.stringify(value, (_key, item) => {
151
+ if ((typeof item === "number" && !Number.isFinite(item))
152
+ || typeof item === "bigint" || typeof item === "function" || typeof item === "symbol") {
153
+ throw new Error("Output contains a non-JSON value.");
154
+ }
155
+ return item;
156
+ }));
157
+ }
158
+ catch {
159
+ if (descriptor.effect !== "query")
160
+ effect = accumulatedEffect(effect, "possible");
161
+ return { kind: "invalid", detail: "Output is not JSON serializable.", effect, operations, ...origin };
162
+ }
163
+ const outputError = capabilitySchemaError(descriptor.outputSchema, output);
164
+ if (outputError) {
165
+ if (descriptor.effect !== "query")
166
+ effect = accumulatedEffect(effect, "possible");
167
+ return { kind: "invalid", detail: `Output ${outputError}`, effect, operations, ...origin };
168
+ }
169
+ return { kind: operations.length ? "operation" : "value", value: output, effect, operations, ...origin };
170
+ }
171
+ catch (error) {
172
+ // An unrelated observation (including a historical queued Job with none)
173
+ // cannot prove that a failing effectful wrapper performed no other action.
174
+ // Preserve the owner's precise facts while reporting wrapper uncertainty.
175
+ if (entered && descriptor.effect !== "query")
176
+ effect = accumulatedEffect(effect, "possible");
177
+ return {
178
+ kind: entered ? "failed" : "unavailable",
179
+ detail: error instanceof Error ? error.message : "Capability invocation failed.",
180
+ effect, operations, ...origin
181
+ };
182
+ }
183
+ }
184
+ #unavailable(entry, authorized, visiting) {
185
+ if (entry.unavailable !== undefined)
186
+ return entry.unavailable;
187
+ if (!this.host.isAvailable(entry.provider))
188
+ return "Provider implementation is not available.";
189
+ if (visiting.has(entry))
190
+ return "Required capability dependency cycle.";
191
+ const path = new Set([...visiting, entry]);
192
+ // Only declared exact name/version edges are checked. No installation,
193
+ // priority policy, version solver or persisted dependency state is involved.
194
+ const missing = entry.required?.filter((dependency) => !authorized.some((candidate) => (candidate.name === dependency.name && candidate.contractVersion === dependency.contractVersion
195
+ && this.#unavailable(candidate, authorized, path) === undefined)));
196
+ if (missing?.length)
197
+ return `Missing available required capabilities: ${missing.map((dependency) => `${dependency.name}@${dependency.contractVersion}`).join(", ")}.`;
198
+ return undefined;
199
+ }
200
+ #publish(descriptors, core) {
201
+ if (!descriptors.length)
202
+ return;
203
+ const prepared = structuredClone(descriptors);
204
+ const provider = prepared[0].provider;
205
+ if (!core && this.#coreProviders.has(provider.id))
206
+ throw new Error("Core Provider identity is reserved.");
207
+ const names = new Set();
208
+ for (const entry of prepared) {
209
+ if (!/^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$/u.test(entry.name)
210
+ || !entry.contractVersion?.trim() || !entry.source?.trim()
211
+ || !Object.hasOwn(effectRank, entry.effect)
212
+ || !Array.isArray(entry.requiredPermissions)
213
+ || entry.requiredPermissions.some((permission) => typeof permission !== "string" || !permission.trim())) {
214
+ throw new Error("Invalid capability descriptor.");
215
+ }
216
+ if (!core && reserved.has(entry.name.split(".")[0]))
217
+ throw new Error("Core capability namespace is reserved.");
218
+ if (!["global", "project", "task"].includes(entry.scope.kind)
219
+ || (entry.scope.kind !== "global" && !entry.scope.id?.trim()))
220
+ throw new Error("Invalid capability scope.");
221
+ if (entry.provider.id !== provider.id || entry.provider.generation !== provider.generation) {
222
+ throw new Error("Publish one complete Provider generation at a time.");
223
+ }
224
+ const key = `${entry.name}@${entry.contractVersion}`;
225
+ if (names.has(key))
226
+ throw new Error(`Duplicate capability: ${key}.`);
227
+ names.add(key);
228
+ checkCapabilitySchema(entry.inputSchema);
229
+ checkCapabilitySchema(entry.outputSchema);
230
+ for (const dependency of entry.required ?? []) {
231
+ if (!dependency.name?.trim() || !dependency.contractVersion?.trim())
232
+ throw new Error("Invalid required capability.");
233
+ }
234
+ }
235
+ // Validate acquisition before publishing; no implementation is invoked.
236
+ if (prepared.some((entry) => !entry.unavailable)) {
237
+ const handle = this.host.acquire(provider);
238
+ try {
239
+ if (typeof handle.value?.invoke !== "function")
240
+ throw new Error("Provider has no capability implementation.");
241
+ }
242
+ finally {
243
+ void handle.release();
244
+ }
245
+ }
246
+ this.#descriptors = [
247
+ ...this.#descriptors.filter((entry) => entry.provider.id !== provider.id),
248
+ ...prepared.map((entry) => deepFreeze(entry))
249
+ ];
250
+ }
251
+ }
252
+ function visible(scope, visibility) {
253
+ return scope.kind === "global" || (scope.kind === "task"
254
+ ? visibility.taskIds.includes(scope.id) : visibility.projectIds.includes(scope.id));
255
+ }
256
+ function result(kind, detail) {
257
+ return { kind, effect: "none", operations: [], ...(detail === undefined ? {} : { detail }) };
258
+ }
259
+ function accumulatedEffect(a, b) {
260
+ return a === "confirmed" || b === "confirmed" ? "confirmed" : a === "possible" || b === "possible" ? "possible" : "none";
261
+ }
262
+ function deepFreeze(value) {
263
+ if (value !== null && typeof value === "object") {
264
+ Object.values(value).forEach(deepFreeze);
265
+ Object.freeze(value);
266
+ }
267
+ return value;
268
+ }
@@ -0,0 +1,91 @@
1
+ import { isDeepStrictEqual } from "node:util";
2
+ export function checkCapabilitySchema(schema) {
3
+ if (typeof schema !== "object" || schema === null || Array.isArray(schema)) {
4
+ throw new Error("Capability schema must be an object.");
5
+ }
6
+ const keywords = new Set([
7
+ "type", "properties", "required", "additionalProperties", "items",
8
+ "enum", "const", "anyOf", "minLength", "minItems"
9
+ ]);
10
+ for (const key of Object.keys(schema)) {
11
+ if (!keywords.has(key))
12
+ throw new Error(`Unsupported schema keyword: ${key}.`);
13
+ }
14
+ if (schema.type !== undefined
15
+ && !["object", "array", "string", "number", "integer", "boolean", "null"].includes(schema.type)) {
16
+ throw new Error("Unsupported schema type.");
17
+ }
18
+ if (schema.required !== undefined && (!Array.isArray(schema.required)
19
+ || schema.required.some((key) => typeof key !== "string")))
20
+ throw new Error("Invalid schema required.");
21
+ for (const bound of [schema.minLength, schema.minItems]) {
22
+ if (bound !== undefined && (!Number.isSafeInteger(bound) || bound < 0))
23
+ throw new Error("Invalid schema bound.");
24
+ }
25
+ if (schema.enum !== undefined && (!Array.isArray(schema.enum) || schema.enum.length === 0)) {
26
+ throw new Error("Invalid schema enum.");
27
+ }
28
+ if (schema.anyOf !== undefined) {
29
+ if (!Array.isArray(schema.anyOf) || !schema.anyOf.length)
30
+ throw new Error("Invalid schema anyOf.");
31
+ schema.anyOf.forEach(checkCapabilitySchema);
32
+ }
33
+ if (schema.properties !== undefined) {
34
+ if (typeof schema.properties !== "object" || schema.properties === null || Array.isArray(schema.properties)) {
35
+ throw new Error("Invalid schema properties.");
36
+ }
37
+ Object.values(schema.properties).forEach(checkCapabilitySchema);
38
+ }
39
+ if (schema.items !== undefined)
40
+ checkCapabilitySchema(schema.items);
41
+ if (schema.additionalProperties !== undefined && typeof schema.additionalProperties !== "boolean") {
42
+ checkCapabilitySchema(schema.additionalProperties);
43
+ }
44
+ }
45
+ export function capabilitySchemaError(schema, value, path = "$") {
46
+ if ("const" in schema && !isDeepStrictEqual(value, schema.const))
47
+ return `${path}: unexpected value.`;
48
+ if (schema.enum && !schema.enum.some((item) => isDeepStrictEqual(item, value)))
49
+ return `${path}: not in enum.`;
50
+ if (schema.anyOf && !schema.anyOf.some((branch) => capabilitySchemaError(branch, value) === undefined)) {
51
+ return `${path}: no matching schema branch.`;
52
+ }
53
+ const type = value === null ? "null" : Array.isArray(value) ? "array" : typeof value;
54
+ if (schema.type !== undefined && (schema.type === "integer"
55
+ ? typeof value !== "number" || !Number.isSafeInteger(value)
56
+ : type !== schema.type))
57
+ return `${path}: expected ${schema.type}.`;
58
+ if (typeof value === "number" && !Number.isFinite(value))
59
+ return `${path}: non-finite number.`;
60
+ if (typeof value === "string" && schema.minLength !== undefined && value.length < schema.minLength) {
61
+ return `${path}: string too short.`;
62
+ }
63
+ if (Array.isArray(value)) {
64
+ if (schema.minItems !== undefined && value.length < schema.minItems)
65
+ return `${path}: array too short.`;
66
+ if (schema.items) {
67
+ for (const [index, item] of value.entries()) {
68
+ const error = capabilitySchemaError(schema.items, item, `${path}[${index}]`);
69
+ if (error)
70
+ return error;
71
+ }
72
+ }
73
+ }
74
+ else if (type === "object") {
75
+ const record = value;
76
+ for (const key of schema.required ?? []) {
77
+ if (!Object.hasOwn(record, key))
78
+ return `${path}.${key}: required.`;
79
+ }
80
+ for (const [key, item] of Object.entries(record)) {
81
+ const property = Object.hasOwn(schema.properties ?? {}, key) ? schema.properties[key] : undefined;
82
+ if (!property && schema.additionalProperties === false)
83
+ return `${path}.${key}: unexpected property.`;
84
+ const child = property ?? (typeof schema.additionalProperties === "object" ? schema.additionalProperties : undefined);
85
+ const error = child && capabilitySchemaError(child, item, `${path}.${key}`);
86
+ if (error)
87
+ return error;
88
+ }
89
+ }
90
+ return undefined;
91
+ }
@@ -19,6 +19,12 @@ export class InstanceHost {
19
19
  });
20
20
  return ref;
21
21
  }
22
+ /** Read-only availability for the rebuildable capability directory. This is
23
+ * not an acquisition or a grant; acquire still checks immediately before use. */
24
+ isAvailable(implementation) {
25
+ const instance = this.#instances.get(implementationKey(implementation));
26
+ return !this.#closed && instance !== undefined && !instance.detached;
27
+ }
22
28
  acquire(implementation) {
23
29
  const instance = this.#instances.get(implementationKey(implementation));
24
30
  if (this.#closed || instance === undefined || instance.detached) {
@@ -1,19 +1,22 @@
1
1
  import { createDurableJobControl } from "../controller/jobControl.js";
2
2
  import { JOB_RUNNER_IMPLEMENTATION } from "../job/durableJob.js";
3
3
  import { InstanceHost } from "./instanceHost.js";
4
+ import { createBuiltinCapabilities } from "./builtinCapabilities.js";
4
5
  /** Called once by the existing Controller root. Does not open a Store, start
5
6
  * another Controller, or provide arbitrary persistence to plugin code.
6
7
  * T02 registers contributions on this Host and wraps this same Job control.
7
8
  */
8
- export function createKernelPorts(store, runner) {
9
+ export function createKernelPorts(store, runner, signal = () => undefined) {
9
10
  const host = new InstanceHost();
10
11
  const runnerImplementation = host.attach(JOB_RUNNER_IMPLEMENTATION, runner);
11
12
  const runnerHandle = host.acquire(runnerImplementation);
12
13
  const jobImplementation = host.attach({ id: "yui:job-control", generation: "1" }, createDurableJobControl(store));
13
14
  // The Controller is a long-lived consumer of this exact implementation.
14
15
  const jobHandle = host.acquire(jobImplementation);
16
+ const capabilities = createBuiltinCapabilities(host, store, jobHandle.value, signal);
15
17
  return {
16
18
  host,
19
+ capabilities,
17
20
  jobImplementation,
18
21
  runnerImplementation,
19
22
  runner: runnerHandle.value,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zq-silk/yui",
3
- "version": "0.15.5",
3
+ "version": "0.15.7",
4
4
  "description": "Local control plane for long-running native agent CLI sessions backed by tmux.",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -39,12 +39,12 @@
39
39
  ],
40
40
  "repository": {
41
41
  "type": "git",
42
- "url": "git+https://github.com/zhangqian-silk/Yui.git"
42
+ "url": "git+https://github.com/zhangqian-silk/yui.git"
43
43
  },
44
44
  "bugs": {
45
- "url": "https://github.com/zhangqian-silk/Yui/issues"
45
+ "url": "https://github.com/zhangqian-silk/yui/issues"
46
46
  },
47
- "homepage": "https://github.com/zhangqian-silk/Yui#readme",
47
+ "homepage": "https://github.com/zhangqian-silk/yui#readme",
48
48
  "dependencies": {
49
49
  "@xterm/addon-fit": "^0.11.0",
50
50
  "@xterm/xterm": "^6.0.0",