@phnx-labs/agents-cli 1.22.9 → 1.22.10

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/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.22.10
4
+
5
+ - **Plugins package workflows (Phase 5 packaging slice).** A plugin’s `workflows/<name>/WORKFLOW.md` is discovered and resolved by `agents run <name>` with precedence project > user > plugin > extra > system — no separate install into `~/.agents/workflows/` required. Plugin inventory / resource groups list `workflows`. Source: `apps/cli/src/lib/workflows.ts`, `apps/cli/src/lib/plugins.ts`, `apps/cli/src/lib/resources/workflows.ts`.
6
+
7
+ - **`scripts/release.sh` routes the home-base publish hop via `agents ssh`.** Plain `ssh mac-mini` fails host-key checks on headless Linux workers; `agents ssh` uses the devices registry and brokered credentials. Falls back to plain ssh only when `agents` is not on PATH. Source: `apps/cli/scripts/release.sh`.
8
+
9
+ - **Touch ID is now raised in exactly one place — `agents secrets unlock`.** `agents secrets list`, `agents run <agent>`, `secrets get`/`export`/`view`, and every background read resolve from the secrets broker / durable session / no-ACL layer and never raise a biometric sheet; a locked keychain bundle fails with an actionable "run `agents secrets unlock <bundle>`" hint instead of prompting. The `AGENTS_SECRETS_NO_PROMPT` environment override and the "a human at a TTY, so prompting is fine" heuristic are deleted — the prompt decision is structural, not an ambient env var. The macOS keychain `list`/`list-synced` enumeration queries now pass `kSecUseAuthenticationUISkip` (enumeration itself was evaluating the biometry ACL, so `agents secrets list` prompted and silently dropped keychain bundles when the sheet was cancelled), and the one-time hash-rekey + metadata-ACL heal run only inside the single `unlock` sheet so nothing on the run/list path can storm. Source: `apps/cli/src/lib/secrets/keychain-helper.swift`, `apps/cli/src/lib/secrets/index.ts`, `apps/cli/src/lib/secrets/bundles.ts`, `apps/cli/src/lib/secrets/headless.ts`, `apps/cli/src/commands/secrets.ts`.
10
+ - **The Factory VS Code extension (`swarm-ext`) no longer decrypts secrets or shells raw `ssh`.** Device health, reachability, and sync route every remote command through `agents ssh <host>` (broker-owned credentials, no prompt); the extension's own secret-resolution path (`resolveSecret`/`discoverSecretsReadCmd`/`extractCredentials`) is removed, so rendering the devices list never raises Touch ID. Source: `apps/factory/src/vscode/deviceHealth.vscode.ts`, `apps/factory/src/vscode/settings.vscode.ts`, `apps/factory/src/vscode/extension.ts`.
11
+
3
12
  ## 1.22.9
4
13
 
5
14
  - **`agents ssh auto` and `agents teams add --device auto` no longer reject with "Unknown device 'auto'" (RUSH-2185).** The `auto` affinity sentinel was a `run`-only preprocessing step (`applyDeviceAutoToOptions` in `smart-launch.ts`, wired only from `agents run`'s exec path) — every other `--host`/`--device` caller went straight to the shared resolver, which had no idea what `auto` meant and reported it as an unregistered device. `matchHost` (the one core every `--host`/`--device` caller shares) now resolves `auto` directly via the same `resolveDeviceAffinity` engine `run` uses, so `agents ssh`, `agents teams add`, and anything else routed through `matchHost`/`resolveHost` (including the generic `--host`/`--device` passthrough) pick a device the same way. `agents teams add --device auto` landing on the local machine now just runs the teammate locally, matching `run`'s "null pick = local" outcome; `agents ssh auto` refuses a local pick with a clear message instead of self-SSHing, since `agents ssh` exists to dial OUT to a remote box. Source: `apps/cli/src/lib/hosts/registry.ts`, `apps/cli/src/lib/devices/resolve-target.ts`, `apps/cli/src/commands/ssh.ts`, `apps/cli/src/commands/teams.ts`, `apps/cli/docs/00-concepts.md`, `apps/cli/docs/hosts.md`, `apps/cli/docs/teams.md`.
@@ -2908,10 +2917,8 @@
2908
2917
  `agentOnly` guard; and `isHeadlessSecretsContext` recognized the `headless` and
2909
2918
  `teams` runtimes but not `terminal`, which is what an interactive run sets. Agent
2910
2919
  launches now resolve broker-only and a locked bundle fails fast naming
2911
- `agents secrets unlock <bundle>`; `AGENTS_SECRETS_NO_PROMPT=1` is no longer needed
2912
- as a workaround. `agents secrets get/export/exec` typed in a **plain shell** still
2913
- prompts — it carries no `AGENTS_RUNTIME`, so the guard does not apply. Run beneath
2914
- an agent it refuses, because there the agent is the caller. This narrows the
2920
+ `agents secrets unlock <bundle>`. Direct read commands use the same broker-only
2921
+ path even from a plain shell; only an explicit unlock may authenticate. This narrows the
2915
2922
  agent-triggered approval added in RUSH-2032, which is unreleased.
2916
2923
 
2917
2924
  - **`release.sh` now borrows the npm token from a primary device when the local box
package/dist/bin/agents CHANGED
Binary file
@@ -5,7 +5,7 @@ import { updateMeta } from '../lib/state.js';
5
5
  import { resolveActor } from '../lib/actor.js';
6
6
  import { loginsForProfile, profilesLoggedInto, serviceForUrl, loginsWithAccountsForProfile, accountsForProfile, credKeysForService, AUTH_SIGNATURES, } from '../lib/browser/login-detection.js';
7
7
  import { parseSecretRef } from '../lib/browser/secret-ref.js';
8
- import { readAndResolveBundleEnv, isHeadlessSecretsContext, bundleExists, readBundle, describeBundle } from '../lib/secrets/bundles.js';
8
+ import { readAndResolveBundleEnv, bundleExists, readBundle, describeBundle } from '../lib/secrets/bundles.js';
9
9
  import { findBrowserPath, getPortOccupant, isLauncherScript } from '../lib/browser/chrome.js';
10
10
  import { listProfileCacheDirs, removeProfileCache, listAllProfileSnapshots, } from '../lib/browser/runtime-state.js';
11
11
  import { DEFAULT_VIEWPORT } from '../lib/browser/devices.js';
@@ -1481,7 +1481,7 @@ function registerTaskCommands(browser) {
1481
1481
  process.exit(1);
1482
1482
  }
1483
1483
  try {
1484
- const { env } = readAndResolveBundleEnv(parsed.bundle, { caller: 'browser type', keys: [parsed.key], keyMode: 'storage', agentOnly: isHeadlessSecretsContext() });
1484
+ const { env } = readAndResolveBundleEnv(parsed.bundle, { caller: 'browser type', keys: [parsed.key], keyMode: 'storage', agentOnly: true });
1485
1485
  if (!(parsed.key in env)) {
1486
1486
  console.error(`Key "${parsed.key}" not in bundle "${parsed.bundle}".`);
1487
1487
  process.exit(1);
@@ -18,10 +18,10 @@ import { ensureDaemonStarted, isDaemonRunning } from '../lib/daemon.js';
18
18
  import { parseHostsOption, remoteResolveEnv, remoteSecretsRaw, remoteSecretsStream, resolveHostSshTarget, verifyRemoteKeychainPush, keychainWriteFailureMessage, } from '../lib/secrets/remote.js';
19
19
  import { remoteShellFor, buildWindowsStdinImportCommand } from '../lib/hosts/remote-cmd.js';
20
20
  import { resolveRemoteOsSync } from '../lib/hosts/remote-os.js';
21
- import { bundleBackend, bundleExists, bundleItemStore, bundlePolicy, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, migrateLegacyBundles, parseDotenv, readAndResolveBundleEnv, isHeadlessSecretsContext, readBundle, readBundleIfDecryptable, reAclBundleItems, renameBundle, rotateBundleSecret, sanitizeProcessEnv, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, writeBundleWithItems, SECRET_TYPES, } from '../lib/secrets/bundles.js';
21
+ import { bundleBackend, bundleExists, bundleItemStore, bundlePolicy, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, healKeychainBundleMetadataAclOnce, migrateLegacyBundles, parseDotenv, readAndResolveBundleEnv, readBundle, readBundleIfDecryptable, reAclBundleItems, renameBundle, rotateBundleSecret, sanitizeProcessEnv, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, writeBundleWithItems, SECRET_TYPES, } from '../lib/secrets/bundles.js';
22
22
  import { parseListFilters, bundleMatchesFilter, bundleExpiry, filterIsActive, describeFilter, parseSortField, sortBundles, SORT_FIELDS, REF_KINDS, DEFAULT_EXPIRING_DAYS, } from '../lib/secrets/list-filter.js';
23
23
  import { encryptForFallback, decryptForFallback } from '../lib/secrets/filestore.js';
24
- import { getKeychainToken, getKeychainTokens, hasKeychainToken, secretsKeychainItem, setKeychainToken, } from '../lib/secrets/index.js';
24
+ import { getKeychainToken, hasKeychainToken, secretsKeychainItem, setKeychainToken, maybeAutoRekey, } from '../lib/secrets/index.js';
25
25
  import { assertOpAvailable, createPasswordItem, deleteItemByTitle, extractSecrets, itemExistsByTitle, listItems, listVaults, } from '../lib/onepassword.js';
26
26
  import { GLOBAL_HARNESS } from '../lib/secrets/scope.js';
27
27
  import { secretsHoldMs, secretsAgentDurable, agentLoad, agentLock, agentPing, agentStatus, ensureAgentRunning, runAgentLoadFromStdin, runSecretsAgent, uninstallSecretsAgentService, } from '../lib/secrets/agent.js';
@@ -1251,15 +1251,15 @@ export function registerSecretsCommands(program) {
1251
1251
  }
1252
1252
  const revealed = new Map();
1253
1253
  if (reveal) {
1254
- const items = entries
1255
- .filter((e) => e.kind === 'keychain')
1256
- .map((e) => secretsKeychainItem(bundle.name, e.detail));
1257
- try {
1258
- for (const [item, value] of getKeychainTokens(items))
1259
- revealed.set(item, value);
1260
- }
1261
- catch {
1262
- /* cancelled / batch failure — fall through to masked (null) values */
1254
+ const { env } = readAndResolveBundleEnv(bundle.name, {
1255
+ caller: 'view --reveal --json',
1256
+ keyMode: 'storage',
1257
+ agentOnly: true,
1258
+ });
1259
+ for (const entry of entries) {
1260
+ if (entry.kind === 'keychain' && env[entry.key] !== undefined) {
1261
+ revealed.set(secretsKeychainItem(bundle.name, entry.detail), env[entry.key]);
1262
+ }
1263
1263
  }
1264
1264
  const exposed = revealed.size + entries.filter((e) => e.kind === 'literal').length;
1265
1265
  if (exposed > 0) {
@@ -1369,23 +1369,20 @@ export function registerSecretsCommands(program) {
1369
1369
  console.error(chalk.red('--reveal in a non-TTY requires --plaintext.'));
1370
1370
  process.exit(1);
1371
1371
  }
1372
- // Batch every backend read into one helper call where supported, so
1373
- // keychain --reveal pops Touch ID once and synced/file bundles use
1374
- // their declared storage.
1372
+ // Resolve through the broker / durable-session path. A locked keychain
1373
+ // bundle errors with the explicit unlock hint; viewing never raises a
1374
+ // Touch ID sheet itself.
1375
1375
  const revealedValues = new Map();
1376
1376
  if (reveal) {
1377
- const items = entries
1378
- .filter((e) => e.kind === 'keychain')
1379
- .map((e) => secretsKeychainItem(bundle.name, e.detail));
1380
- try {
1381
- const fetched = bundle.backend
1382
- ? new Map(items.map((item) => [item, bundleItemStore(bundle.backend).get(item)]))
1383
- : getKeychainTokens(items);
1384
- for (const [item, value] of fetched)
1385
- revealedValues.set(item, value);
1386
- }
1387
- catch {
1388
- // Fall through to masked output on cancellation / batch failure.
1377
+ const { env } = readAndResolveBundleEnv(bundle.name, {
1378
+ caller: 'view --reveal',
1379
+ keyMode: 'storage',
1380
+ agentOnly: true,
1381
+ });
1382
+ for (const entry of entries) {
1383
+ if (entry.kind === 'keychain' && env[entry.key] !== undefined) {
1384
+ revealedValues.set(secretsKeychainItem(bundle.name, entry.detail), env[entry.key]);
1385
+ }
1389
1386
  }
1390
1387
  // Revealing plaintext bypasses readAndResolveBundleEnv (the usual
1391
1388
  // audit chokepoint), so emit here — a `--reveal` exposes real values
@@ -1475,10 +1472,9 @@ export function registerSecretsCommands(program) {
1475
1472
  process.exit(1);
1476
1473
  }
1477
1474
  // `secrets get` is the scriptable automation primitive ($(agents secrets
1478
- // get bundle KEY)); when embedded in a headless routine/CI script — or run
1479
- // beneath any agent, which inherits AGENTS_RUNTIME — it must not pop an
1480
- // unwatched Touch ID prompt. Typed in a plain shell it still prompts.
1481
- const { env } = readAndResolveBundleEnv(item, { caller: 'secrets get', keys: [key], keyMode: 'storage', agentOnly: isHeadlessSecretsContext() });
1475
+ // get bundle KEY)). Every read is broker-only; a locked bundle points at
1476
+ // the explicit unlock command instead of raising Touch ID.
1477
+ const { env } = readAndResolveBundleEnv(item, { caller: 'secrets get', keys: [key], keyMode: 'storage', agentOnly: true });
1482
1478
  if (!(key in env)) {
1483
1479
  console.error(chalk.red(`Key '${key}' not in bundle '${item}'.`));
1484
1480
  process.exit(1);
@@ -2083,7 +2079,7 @@ Examples:
2083
2079
  throw new Error('--to-file needs AGENTS_SECRETS_PASSPHRASE set to encrypt the bundle. ' +
2084
2080
  'Set it for this command, then supply the same value when importing.');
2085
2081
  }
2086
- const { env } = readAndResolveBundleEnv(resolvedBundleName, { caller: 'export --to-file', keyMode: 'storage', agentOnly: isHeadlessSecretsContext() });
2082
+ const { env } = readAndResolveBundleEnv(resolvedBundleName, { caller: 'export --to-file', keyMode: 'storage', agentOnly: true });
2087
2083
  exportBundleToFile(env, opts.toFile, passphrase);
2088
2084
  emitSecretAudit({ event: 'secrets.export', bundle: resolvedBundleName, operation: 'export --to-file', source: 'file', status: 'success', keyCount: Object.keys(env).length });
2089
2085
  console.log(chalk.green(`Exported ${Object.keys(env).length} key(s) to ${opts.toFile}`));
@@ -2111,7 +2107,7 @@ Examples:
2111
2107
  'bundle at rest on the remote. Set it for this command, then unlock it the same way per run.');
2112
2108
  }
2113
2109
  }
2114
- const { env } = readAndResolveBundleEnv(resolvedBundleName, { caller: `ssh export`, keyMode: 'storage', agentOnly: isHeadlessSecretsContext() });
2110
+ const { env } = readAndResolveBundleEnv(resolvedBundleName, { caller: `ssh export`, keyMode: 'storage', agentOnly: true });
2115
2111
  const dotenv = bundleEnvToDotenv(env);
2116
2112
  const keyCount = Object.keys(env).length;
2117
2113
  // Drive the remote's own `agents secrets import --from -` so the values
@@ -2200,7 +2196,7 @@ Examples:
2200
2196
  if (opts.to1password) {
2201
2197
  assertOpAvailable();
2202
2198
  const vault = await resolveVault(opts.vault);
2203
- const { env } = readAndResolveBundleEnv(resolvedBundleName, { caller: `1Password vault ${vault}`, keyMode: 'storage', agentOnly: isHeadlessSecretsContext() });
2199
+ const { env } = readAndResolveBundleEnv(resolvedBundleName, { caller: `1Password vault ${vault}`, keyMode: 'storage', agentOnly: true });
2204
2200
  let created = 0;
2205
2201
  let overwritten = 0;
2206
2202
  let skipped = 0;
@@ -2240,14 +2236,12 @@ Examples:
2240
2236
  process.exit(1);
2241
2237
  }
2242
2238
  // `agents secrets export --plaintext` is what release/CI scripts eval.
2243
- // When it runs detached (both stdio non-TTY) or beneath ANY agent — which
2244
- // inherits AGENTS_RUNTIME — resolve broker-only so it can never pop a Touch
2245
- // ID sheet on the interactive user's screen. An `eval "$(...)"` typed in a
2246
- // plain shell carries no AGENTS_RUNTIME, so it is not headless and still prompts.
2239
+ // Every caller is broker-only, including a plain shell, so export never
2240
+ // raises Touch ID on the interactive user's screen.
2247
2241
  const { env } = readAndResolveBundleEnv(resolvedBundleName, {
2248
2242
  caller: `export to shell`,
2249
2243
  keyMode: 'process',
2250
- agentOnly: isHeadlessSecretsContext(),
2244
+ agentOnly: true,
2251
2245
  });
2252
2246
  if (opts.format === 'json') {
2253
2247
  // Machine-readable form consumed by `remoteResolveEnv` over SSH.
@@ -2309,7 +2303,7 @@ Examples:
2309
2303
  caller: `command ${cmd}`,
2310
2304
  keys: keysSubset,
2311
2305
  allowExpired: execOpts.allowExpired,
2312
- agentOnly: isHeadlessSecretsContext(),
2306
+ agentOnly: true,
2313
2307
  }).env;
2314
2308
  }
2315
2309
  const { spawn } = await import('child_process');
@@ -2575,6 +2569,11 @@ Examples:
2575
2569
  duration: humanRemaining(Date.now() + ttlMs),
2576
2570
  keyMode: 'storage',
2577
2571
  });
2572
+ // Migrations are authorized only by this explicit unlock. The bundle
2573
+ // metadata was included in the successful authenticated batch, so the
2574
+ // ACL heal can rewrite that already-read value without another read.
2575
+ maybeAutoRekey();
2576
+ healKeychainBundleMetadataAclOnce(new Map([[bundle.name, JSON.stringify(bundle)]]));
2578
2577
  if (await agentLoad(name, bundle, env, ttlMs, harness)) {
2579
2578
  loaded++;
2580
2579
  // Persist a durable session snapshot so the unlock survives a daemon
@@ -16,7 +16,7 @@ import * as path from 'path';
16
16
  import chalk from 'chalk';
17
17
  import ora from 'ora';
18
18
  import { getCliVersion } from '../lib/version.js';
19
- import { readAndResolveBundleEnv, isHeadlessSecretsContext } from '../lib/secrets/bundles.js';
19
+ import { readAndResolveBundleEnv } from '../lib/secrets/bundles.js';
20
20
  import { machineId } from '../lib/session/sync/config.js';
21
21
  import { isDeviceAuto, resolveDeviceAffinity } from '../lib/smart-launch.js';
22
22
  import { registerFleetCaptureCommand } from './fleet-capture.js';
@@ -31,7 +31,7 @@ import { resolveDeviceTarget, splitUserHost } from '../lib/devices/resolve-targe
31
31
  import { clearPendingSentinel } from '../lib/devices/pending.js';
32
32
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
33
33
  import { hostNameFor, renderSshConfig } from '../lib/devices/ssh-config.js';
34
- import { ASKPASS_BUNDLE_ENV, ASKPASS_KEY_ENV, ASKPASS_AGENT_ONLY_ENV, buildSshInvocation, fleetDialTarget, writeAskpassShim, } from '../lib/devices/connect.js';
34
+ import { ASKPASS_BUNDLE_ENV, ASKPASS_KEY_ENV, buildSshInvocation, fleetDialTarget, writeAskpassShim, } from '../lib/devices/connect.js';
35
35
  import { ensureManagedKnownHostsDir, isHostPinned } from '../lib/devices/known-hosts.js';
36
36
  import { shouldSyncTerminfo, syncTerminfoToDevice, terminfoHostKey } from '../lib/devices/terminfo.js';
37
37
  import { fanOutDevices, fleetHealthSkip, planFleetTargets, remoteFleetTargets, runFleet, skipLabel, upgradeCommand, } from '../lib/devices/fleet.js';
@@ -1514,9 +1514,8 @@ async function runAskpass() {
1514
1514
  }
1515
1515
  // A read-only stats probe sets ASKPASS_AGENT_ONLY_ENV to force a broker-only
1516
1516
  // resolve even under a TTY — so `agents devices` never pops Touch ID just to
1517
- // render load/mem for an uncached password-auth device (RUSH-1970). Otherwise
1518
- // fall back to the headless-context heuristic.
1519
- const agentOnly = process.env[ASKPASS_AGENT_ONLY_ENV] === '1' || isHeadlessSecretsContext();
1517
+ // render load/mem for an uncached password-auth device (RUSH-1970).
1518
+ const agentOnly = true;
1520
1519
  try {
1521
1520
  const { env } = readAndResolveBundleEnv(bundle, { caller: 'agents ssh', keys: [key], keyMode: 'storage', agentOnly });
1522
1521
  const value = env[key];
@@ -29,7 +29,7 @@ Structure:
29
29
  skills/ optional: knowledge packs scoped to this workflow
30
30
  plugins/ optional: plugin bundles scoped to this workflow
31
31
 
32
- Resolution: project (.agents/workflows/) > user (~/.agents/workflows/) > system.
32
+ Resolution: project > user > plugin (plugins/*/workflows/) > extra > system.
33
33
 
34
34
  Note: agents run defaults to --mode plan (read-only). For workflows that
35
35
  write files, post comments, or otherwise mutate state, pass --mode edit or
@@ -2,11 +2,12 @@
2
2
  * Plugin discovery, validation, and syncing.
3
3
  *
4
4
  * Plugins are bundles in ~/.agents/plugins/ that package skills, hooks,
5
- * commands, agents, bin scripts, MCP servers, and settings under a single
6
- * manifest (plugin.json). They are user-authored resources, sitting alongside
7
- * skills/, commands/, hooks/, etc. — git-tracked as source of truth. This
8
- * module discovers plugins, validates their manifests, and syncs their
9
- * contents into agent version homes.
5
+ * commands, agents, workflows, bin scripts, MCP servers, and settings under a
6
+ * single manifest (plugin.json). They are user-authored resources, sitting
7
+ * alongside skills/, commands/, hooks/, etc. — git-tracked as source of truth.
8
+ * This module discovers plugins, validates their manifests, and syncs their
9
+ * contents into agent version homes. Workflows under a plugin’s workflows/
10
+ * are resolved at run time by resolveWorkflowRef (Phase 5 packaging).
10
11
  */
11
12
  import type { AgentId, DiscoveredPlugin, PluginManifest, MarketplaceSpec } from './types.js';
12
13
  export interface PluginCapabilities {
@@ -95,6 +96,14 @@ export declare function discoverPluginHooks(pluginRoot: string): string[];
95
96
  export declare function discoverPluginCommands(pluginRoot: string): string[];
96
97
  /** Discover agent definition .md files inside a plugin's agents/ directory. */
97
98
  export declare function discoverPluginAgentDefs(pluginRoot: string): string[];
99
+ /**
100
+ * Discover workflow directories inside a plugin's `workflows/` folder.
101
+ * A valid workflow is a directory containing WORKFLOW.md (same contract as
102
+ * project/user/system workflows). Phase 5: plugins package workflows as
103
+ * entrypoints so `agents run <name>` can resolve them without a separate
104
+ * install into ~/.agents/workflows/.
105
+ */
106
+ export declare function discoverPluginWorkflows(pluginRoot: string): string[];
98
107
  /** Discover executable files in a plugin's bin/ directory. */
99
108
  export declare function discoverPluginBin(pluginRoot: string): string[];
100
109
  /** Discover MCP server names from .mcp.json at the plugin root. */
@@ -2,11 +2,12 @@
2
2
  * Plugin discovery, validation, and syncing.
3
3
  *
4
4
  * Plugins are bundles in ~/.agents/plugins/ that package skills, hooks,
5
- * commands, agents, bin scripts, MCP servers, and settings under a single
6
- * manifest (plugin.json). They are user-authored resources, sitting alongside
7
- * skills/, commands/, hooks/, etc. — git-tracked as source of truth. This
8
- * module discovers plugins, validates their manifests, and syncs their
9
- * contents into agent version homes.
5
+ * commands, agents, workflows, bin scripts, MCP servers, and settings under a
6
+ * single manifest (plugin.json). They are user-authored resources, sitting
7
+ * alongside skills/, commands/, hooks/, etc. — git-tracked as source of truth.
8
+ * This module discovers plugins, validates their manifests, and syncs their
9
+ * contents into agent version homes. Workflows under a plugin’s workflows/
10
+ * are resolved at run time by resolveWorkflowRef (Phase 5 packaging).
10
11
  */
11
12
  import * as fs from 'fs';
12
13
  import * as path from 'path';
@@ -107,6 +108,7 @@ export function buildDiscoveredPlugin(pluginRoot, manifest, spec = { kind: 'user
107
108
  scripts: discoverPluginScripts(pluginRoot),
108
109
  commands: discoverPluginCommands(pluginRoot),
109
110
  agentDefs: discoverPluginAgentDefs(pluginRoot),
111
+ workflows: discoverPluginWorkflows(pluginRoot),
110
112
  memory: discoverPluginMemory(pluginRoot),
111
113
  bin: discoverPluginBin(pluginRoot),
112
114
  mcpServers: discoverPluginMcpServers(pluginRoot),
@@ -131,6 +133,7 @@ export function pluginResourceGroups(plugin) {
131
133
  { label: 'skills', items: plugin.skills.map((s) => `/${plugin.name}:${s}`) },
132
134
  { label: 'commands', items: plugin.commands.map((c) => `/${plugin.name}:${c}`) },
133
135
  { label: 'subagents', items: plugin.agentDefs },
136
+ { label: 'workflows', items: plugin.workflows },
134
137
  { label: 'hooks', items: plugin.hooks },
135
138
  { label: 'memory', items: plugin.memory },
136
139
  { label: 'mcp', items: plugin.mcpServers },
@@ -334,6 +337,27 @@ export function discoverPluginAgentDefs(pluginRoot) {
334
337
  .filter(f => f.endsWith('.md') && !f.startsWith('.'))
335
338
  .map(f => f.slice(0, -3));
336
339
  }
340
+ /**
341
+ * Discover workflow directories inside a plugin's `workflows/` folder.
342
+ * A valid workflow is a directory containing WORKFLOW.md (same contract as
343
+ * project/user/system workflows). Phase 5: plugins package workflows as
344
+ * entrypoints so `agents run <name>` can resolve them without a separate
345
+ * install into ~/.agents/workflows/.
346
+ */
347
+ export function discoverPluginWorkflows(pluginRoot) {
348
+ const workflowsDir = path.join(pluginRoot, 'workflows');
349
+ if (!fs.existsSync(workflowsDir))
350
+ return [];
351
+ try {
352
+ return fs.readdirSync(workflowsDir, { withFileTypes: true })
353
+ .filter((e) => e.isDirectory() && !e.name.startsWith('.') &&
354
+ fs.existsSync(path.join(workflowsDir, e.name, 'WORKFLOW.md')))
355
+ .map((e) => e.name);
356
+ }
357
+ catch {
358
+ return [];
359
+ }
360
+ }
337
361
  /** Discover executable files in a plugin's bin/ directory. */
338
362
  export function discoverPluginBin(pluginRoot) {
339
363
  const binDir = path.join(pluginRoot, 'bin');
@@ -6,7 +6,8 @@
6
6
  * - Override on name conflict: Higher layer wins (project > user > system)
7
7
  */
8
8
  export type AgentId = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode' | 'openclaw' | 'copilot' | 'kiro' | 'goose' | 'antigravity' | 'grok' | 'kimi' | 'droid' | 'hermes' | 'pi';
9
- export type Layer = 'system' | 'user' | 'project';
9
+ /** Resource origin. Precedence (highest first): project > user > plugin > system. */
10
+ export type Layer = 'system' | 'user' | 'project' | 'plugin';
10
11
  export type ResourceKind = 'command' | 'hook' | 'skill' | 'rule' | 'mcp' | 'permission' | 'subagent' | 'workflow' | 'memory';
11
12
  /** A resolved resource with its origin layer. */
12
13
  export interface ResolvedItem<T> {
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Workflows are directory bundles with a WORKFLOW.md containing YAML frontmatter.
5
5
  * They optionally contain subagents/, skills/, and plugins/ subdirectories.
6
- * Resolution order: project > user > system.
6
+ * Resolution order (docs/07-entrypoints): project > user > plugin > extra > system.
7
7
  */
8
8
  import type { AgentId, ResolvedItem, ResourceHandler } from './types.js';
9
9
  export interface WorkflowItem {
@@ -3,12 +3,12 @@
3
3
  *
4
4
  * Workflows are directory bundles with a WORKFLOW.md containing YAML frontmatter.
5
5
  * They optionally contain subagents/, skills/, and plugins/ subdirectories.
6
- * Resolution order: project > user > system.
6
+ * Resolution order (docs/07-entrypoints): project > user > plugin > extra > system.
7
7
  */
8
8
  import * as fs from 'fs';
9
9
  import * as path from 'path';
10
10
  import { getProjectAgentsDir, getUserWorkflowsDir, getSystemWorkflowsDir, getEnabledExtraRepos, } from '../state.js';
11
- import { parseWorkflowFrontmatter, countWorkflowSubagents } from '../workflows.js';
11
+ import { parseWorkflowFrontmatter, countWorkflowSubagents, listPluginWorkflowDirs, isBareWorkflowName, } from '../workflows.js';
12
12
  function getLayerDirs(cwd) {
13
13
  const projectDir = getProjectAgentsDir(cwd);
14
14
  const extraRepos = getEnabledExtraRepos();
@@ -32,20 +32,27 @@ function listWorkflowsInDir(dir) {
32
32
  return [];
33
33
  }
34
34
  }
35
+ /** Precedence-ordered (dir, layer) pairs for name lookup / listing. */
36
+ function orderedWorkflowSearchDirs(cwd) {
37
+ const dirs = getLayerDirs(cwd);
38
+ const out = [];
39
+ if (dirs.project)
40
+ out.push({ dir: dirs.project, layer: 'project' });
41
+ out.push({ dir: dirs.user, layer: 'user' });
42
+ for (const pluginDir of listPluginWorkflowDirs(cwd ?? process.cwd())) {
43
+ out.push({ dir: pluginDir, layer: 'plugin' });
44
+ }
45
+ for (const extraDir of dirs.extra)
46
+ out.push({ dir: extraDir, layer: 'system' });
47
+ out.push({ dir: dirs.system, layer: 'system' });
48
+ return out;
49
+ }
35
50
  class WorkflowsHandlerImpl {
36
51
  kind = 'workflow';
37
52
  listAll(_agent, cwd) {
38
- const dirs = getLayerDirs(cwd);
39
53
  const seen = new Set();
40
54
  const results = [];
41
- const layerDirs = [];
42
- if (dirs.project)
43
- layerDirs.push({ dir: dirs.project, layer: 'project' });
44
- layerDirs.push({ dir: dirs.user, layer: 'user' });
45
- layerDirs.push({ dir: dirs.system, layer: 'system' });
46
- for (const extraDir of dirs.extra)
47
- layerDirs.push({ dir: extraDir, layer: 'system' });
48
- for (const { dir, layer } of layerDirs) {
55
+ for (const { dir, layer } of orderedWorkflowSearchDirs(cwd)) {
49
56
  for (const { name, path: workflowPath } of listWorkflowsInDir(dir)) {
50
57
  if (seen.has(name))
51
58
  continue;
@@ -69,15 +76,10 @@ class WorkflowsHandlerImpl {
69
76
  return results.sort((a, b) => a.name.localeCompare(b.name));
70
77
  }
71
78
  resolve(_agent, name, cwd) {
72
- const dirs = getLayerDirs(cwd);
73
- const searchDirs = [];
74
- if (dirs.project)
75
- searchDirs.push({ dir: dirs.project, layer: 'project' });
76
- searchDirs.push({ dir: dirs.user, layer: 'user' });
77
- searchDirs.push({ dir: dirs.system, layer: 'system' });
78
- for (const extraDir of dirs.extra)
79
- searchDirs.push({ dir: extraDir, layer: 'system' });
80
- for (const { dir, layer } of searchDirs) {
79
+ // Same bare-name gate as resolveWorkflowRef — never path-join traversal refs.
80
+ if (!isBareWorkflowName(name))
81
+ return null;
82
+ for (const { dir, layer } of orderedWorkflowSearchDirs(cwd)) {
81
83
  const workflowPath = path.join(dir, name);
82
84
  const fm = parseWorkflowFrontmatter(workflowPath);
83
85
  if (fm) {
@@ -23,6 +23,8 @@
23
23
  import { type BundleValue, type SecretRef } from './index.js';
24
24
  /** Which store carries a bundle's items. */
25
25
  export type SecretsBackend = 'keychain' | 'file' | 'vault';
26
+ /** Disable the broker-only guard for in-memory keychain tests. */
27
+ export declare function setKeychainAgentOnlyBypassForTest(bypass: boolean): void;
26
28
  /**
27
29
  * Discover a bundle's backend by location: a file-backed bundle's metadata
28
30
  * item exists in the encrypted-file store. This is a plain file-existence
@@ -184,6 +186,13 @@ export declare function deleteBundle(name: string): boolean;
184
186
  * extra keychain read is issued. Exported for tests. Returns the count healed.
185
187
  */
186
188
  export declare function healKeychainBundleMetadata(metaJsonByName: Map<string, string>): number;
189
+ /**
190
+ * One-time driver around healKeychainBundleMetadata (RUSH-1759). macOS + real
191
+ * keychain only — libsecret/CredMan have no biometry ACL to shed, and a test
192
+ * backend has no real keychain — and gated by a sentinel so it runs at most
193
+ * once. Best-effort: a heal failure never breaks bundle listing.
194
+ */
195
+ export declare function healKeychainBundleMetadataAclOnce(metaJsonByName: Map<string, string>): void;
187
196
  export declare function listBundles(): SecretsBundle[];
188
197
  export interface BundleEntryInfo {
189
198
  key: string;
@@ -86,6 +86,11 @@ const vaultStore = {
86
86
  delete: vaultDeleteItem,
87
87
  list: vaultListItems,
88
88
  };
89
+ let keychainAgentOnlyBypassForTest = false;
90
+ /** Disable the broker-only guard for in-memory keychain tests. */
91
+ export function setKeychainAgentOnlyBypassForTest(bypass) {
92
+ keychainAgentOnlyBypassForTest = bypass;
93
+ }
89
94
  function itemStore(backend) {
90
95
  if (backend === 'file')
91
96
  return fileItemStore;
@@ -606,7 +611,7 @@ export function healKeychainBundleMetadata(metaJsonByName) {
606
611
  * backend has no real keychain — and gated by a sentinel so it runs at most
607
612
  * once. Best-effort: a heal failure never breaks bundle listing.
608
613
  */
609
- function healKeychainBundleMetadataAclOnce(metaJsonByName) {
614
+ export function healKeychainBundleMetadataAclOnce(metaJsonByName) {
610
615
  if (metaJsonByName.size === 0)
611
616
  return;
612
617
  if (process.platform !== 'darwin')
@@ -684,7 +689,6 @@ export function listBundles() {
684
689
  // still prompt once; it heals on the next interactive scan.)
685
690
  const fetched = getKeychainTokens(keychainServices, { silentNoAcl: true });
686
691
  const keychainBundles = [];
687
- const metaJsonByName = new Map();
688
692
  for (const service of keychainServices) {
689
693
  const json = fetched.get(service);
690
694
  if (json === undefined)
@@ -695,7 +699,6 @@ export function listBundles() {
695
699
  const bundle = parseBundleMeta(nameHint, json, 'keychain');
696
700
  if (bundle) {
697
701
  keychainBundles.push(bundle);
698
- metaJsonByName.set(bundle.name, json);
699
702
  }
700
703
  }
701
704
  for (const bundle of keychainBundles)
@@ -707,13 +710,6 @@ export function listBundles() {
707
710
  if (useAgent && keychainBundles.length > 0) {
708
711
  agentAutoLoadMetaSync(nameSetHash, keychainBundles, secretsHoldMs());
709
712
  }
710
- // One-time RUSH-1759 heal: bundles written before the metadata-no-ACL
711
- // change carry an ACL'd metadata item, so this fresh read (a broker miss)
712
- // popped Touch ID just to enumerate them. Re-home each metadata item
713
- // no-ACL now — reusing the JSON we just read, so the heal adds no extra
714
- // prompt — and every later enumeration is silent. Runs at most once (a
715
- // sentinel under the regenerable helpers dir).
716
- healKeychainBundleMetadataAclOnce(metaJsonByName);
717
713
  }
718
714
  }
719
715
  }
@@ -1099,24 +1095,14 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1099
1095
  return filtered;
1100
1096
  }
1101
1097
  }
1102
- // Never/no-ACL bundles remain prompt-free regardless. No agent launch — harness,
1103
- // teammate, routine, or the always-on daemon — may raise the sheet itself.
1104
- // Explicit opt-in ONLY — a deliberate NARROWING of the agent-triggered approval
1105
- // added in RUSH-2032 (b99796f8 removed this throw so an agent could raise the
1106
- // sheet itself; 4eeada68 generalized the daemon rule into `!interactiveUnlock`).
1107
- // That default — true whenever an agent name was present — was the spec, not a
1108
- // bug. It is unwanted: each keychain read runs in its own helper process, so the
1109
- // biometric assertion never reuses and one agent launch meant one sheet per
1110
- // bundle. `agentOnly` decides alone now; a human in a plain shell carries no
1111
- // AGENTS_RUNTIME, so isHeadlessSecretsContext() is false, agentOnly is false, the
1112
- // guard never fires, and they still get their prompt. No caller passes this flag;
1113
- // it remains the seam for a future unlock path that wants the sheet on purpose.
1098
+ // Never/no-ACL bundles remain prompt-free regardless. Every ordinary caller
1099
+ // sets agentOnly; only the unlock handler opts into interactive authentication.
1114
1100
  const interactiveUnlock = opts.interactiveUnlock ?? false;
1115
1101
  // A `never`-policy bundle's items carry no biometry ACL, so once the policy
1116
1102
  // check below proves that, the batch read is silent even in a headless
1117
1103
  // context — attest it to the raw-read storm guard via `silentNoAcl`.
1118
1104
  let verifiedNoAclBundle = false;
1119
- if (opts.agentOnly && backend === 'keychain' && !interactiveUnlock) {
1105
+ if (opts.agentOnly && backend === 'keychain' && !interactiveUnlock && !keychainAgentOnlyBypassForTest) {
1120
1106
  try {
1121
1107
  verifiedNoAclBundle = bundlePolicy(readBundle(name)) === 'never';
1122
1108
  }
@@ -5,22 +5,13 @@
5
5
  * importing bundles.ts (which already imports index.ts).
6
6
  */
7
7
  /**
8
- * True when the current process is a background / non-interactive context that
9
- * must NEVER raise a Keychain biometry prompt on the interactive user's screen —
10
- * a prompt nobody is watching. Two signals, either sufficient:
8
+ * True when the current process was structurally launched by an agent runtime
9
+ * that must NEVER raise a Keychain biometry prompt on the user's screen:
11
10
  * - `AGENTS_RUNTIME` is `headless`, `teams`, or `terminal` — i.e. ANY agent
12
11
  * launch, interactive included, and inherited by everything spawned beneath
13
12
  * one (set on the child env by `agents run --headless`, scheduled routines,
14
13
  * teammates, and interactive runs — see exec.ts:430, runner.ts,
15
14
  * teams/agents.ts).
16
- * - neither stdin nor stdout is a TTY (a detached/backgrounded task whose
17
- * stdio is redirected to a log — e.g. a release script run in the
18
- * background as `( ... ) >log 2>&1 </dev/null`).
19
- * `AGENTS_SECRETS_NO_PROMPT=1` forces headless-safe; `=0` force-allows a prompt
20
- * even in a non-TTY context. An `eval "$(agents secrets export X)"` typed in a
21
- * PLAIN shell has no AGENTS_RUNTIME, so it is not classified headless and still
22
- * prompts. Run beneath an agent it inherits AGENTS_RUNTIME and resolves
23
- * broker-only — the agent, not the human, is the caller there.
24
15
  *
25
16
  * Only **macOS keychain** reads pop an interactive Touch ID sheet — the secrets
26
17
  * broker itself is a no-op off darwin (see agent.ts), and libsecret (Linux) /
@@ -33,7 +24,4 @@
33
24
  * fast with an actionable error instead of hijacking Touch ID. This generalizes
34
25
  * the per-caller broker-only pattern used across the headless secrets readers.
35
26
  */
36
- export declare function isHeadlessSecretsContext(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, tty?: {
37
- stdin?: boolean;
38
- stdout?: boolean;
39
- }): boolean;
27
+ export declare function isHeadlessSecretsContext(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): boolean;
@@ -5,22 +5,13 @@
5
5
  * importing bundles.ts (which already imports index.ts).
6
6
  */
7
7
  /**
8
- * True when the current process is a background / non-interactive context that
9
- * must NEVER raise a Keychain biometry prompt on the interactive user's screen —
10
- * a prompt nobody is watching. Two signals, either sufficient:
8
+ * True when the current process was structurally launched by an agent runtime
9
+ * that must NEVER raise a Keychain biometry prompt on the user's screen:
11
10
  * - `AGENTS_RUNTIME` is `headless`, `teams`, or `terminal` — i.e. ANY agent
12
11
  * launch, interactive included, and inherited by everything spawned beneath
13
12
  * one (set on the child env by `agents run --headless`, scheduled routines,
14
13
  * teammates, and interactive runs — see exec.ts:430, runner.ts,
15
14
  * teams/agents.ts).
16
- * - neither stdin nor stdout is a TTY (a detached/backgrounded task whose
17
- * stdio is redirected to a log — e.g. a release script run in the
18
- * background as `( ... ) >log 2>&1 </dev/null`).
19
- * `AGENTS_SECRETS_NO_PROMPT=1` forces headless-safe; `=0` force-allows a prompt
20
- * even in a non-TTY context. An `eval "$(agents secrets export X)"` typed in a
21
- * PLAIN shell has no AGENTS_RUNTIME, so it is not classified headless and still
22
- * prompts. Run beneath an agent it inherits AGENTS_RUNTIME and resolves
23
- * broker-only — the agent, not the human, is the caller there.
24
15
  *
25
16
  * Only **macOS keychain** reads pop an interactive Touch ID sheet — the secrets
26
17
  * broker itself is a no-op off darwin (see agent.ts), and libsecret (Linux) /
@@ -33,31 +24,12 @@
33
24
  * fast with an actionable error instead of hijacking Touch ID. This generalizes
34
25
  * the per-caller broker-only pattern used across the headless secrets readers.
35
26
  */
36
- export function isHeadlessSecretsContext(env = process.env, platform = process.platform,
37
- // Injected so the TTY branch below is testable: it is the branch that decides a
38
- // plain human shell still prompts, which is this guard's entire safety argument,
39
- // and reading process.* directly made it unreachable from a test.
40
- tty = { stdin: process.stdin.isTTY, stdout: process.stdout.isTTY }) {
27
+ export function isHeadlessSecretsContext(env = process.env, platform = process.platform) {
41
28
  if (platform !== 'darwin')
42
29
  return false; // no biometry prompt to suppress off-darwin
43
- const override = env.AGENTS_SECRETS_NO_PROMPT;
44
- if (override === '1')
45
- return true;
46
- if (override === '0')
47
- return false;
48
- // Every AGENT-LAUNCH runtime resolves broker-only, interactive included.
49
- // `terminal` was missing, which made an agent terminal the one launch path
50
- // still allowed to pop Touch ID: exec.ts sets AGENTS_RUNTIME='terminal' for an
51
- // interactive run (exec.ts:430), that fell through to the TTY check below, and
52
- // a TTY meant "a human is watching, so prompting is fine". It is not fine —
53
- // opening a terminal is not a request to authenticate, and a launch that needs
54
- // a locked bundle should say so and point at `agents secrets unlock`, not grab
55
- // the fingerprint sensor. AGENTS_RUNTIME is INHERITED by everything spawned under
56
- // an agent, so `agents secrets export` run beneath one resolves broker-only too —
57
- // correctly: there the agent, not the human, is the caller. A plain shell carries
58
- // no AGENTS_RUNTIME, so a person running it themselves still gets the sheet.
30
+ // Every agent-launch runtime resolves broker-only, interactive included.
59
31
  const runtime = env.AGENTS_RUNTIME;
60
32
  if (runtime === 'headless' || runtime === 'teams' || runtime === 'terminal')
61
33
  return true;
62
- return !tty.stdin && !tty.stdout;
34
+ return false;
63
35
  }
@@ -143,6 +143,14 @@ export declare function healHmacKeyNoAclOnce(rec: HmacKeyRecord): boolean;
143
143
  * see readAndResolveBundleEnv.
144
144
  */
145
145
  export declare function keychainServiceAlias(item: string): string;
146
+ /**
147
+ * One-shot per process: activate hashing on machines with nothing to move,
148
+ * finish a crash-interrupted delete phase (silent), and run the interactive
149
+ * one-time re-key when cleartext-named items exist and a human is present.
150
+ * Never throws — a failed attempt leaves the process on cleartext names
151
+ * (exact pre-#316 behavior) and the next process retries.
152
+ */
153
+ export declare function maybeAutoRekey(): void;
146
154
  /** One re-keyed (or failed) item, by its old cleartext service name. */
147
155
  export interface RekeyPlanItem {
148
156
  oldService: string;
@@ -247,30 +247,7 @@ export function readHmacKeyRecord() {
247
247
  catch {
248
248
  return null;
249
249
  }
250
- const record = parseHmacKeyRecord(raw);
251
- // Converge a stale-ACL'd hmackey to silent, on the HOT read path. An old helper
252
- // (pre the metadata/hmackey no-ACL migration fix) re-stamped this
253
- // contractually-no-ACL item with a biometry ACL, so the read just above pops the
254
- // generic "Agents CLI needs to authenticate" sheet on EVERY hashed lookup — the
255
- // `agents devices list` stats probe the SessionStart hook runs, and every other
256
- // background hashed read. `maybeAutoRekey`'s one-shot heal only fires on a
257
- // cleartext-bundle resolve and is bypassed for the hmackey/hashed-name path
258
- // (prepareServiceName returns early for HMAC_KEY_ITEM before maybeAutoRekey), so
259
- // it never converged exactly these reads and the machine prompted forever.
260
- // Re-store the record no-ACL once per machine here (the read that produced it has
261
- // already happened — and already prompted if the item was ACL'd); every
262
- // subsequent read, in this process and all future ones, is silent.
263
- if (record && !record.healedNoAcl) {
264
- try {
265
- healHmacKeyNoAclOnce(record);
266
- record.healedNoAcl = true;
267
- }
268
- catch {
269
- // A failed no-ACL re-store leaves the item still ACL'd (a still-prompting
270
- // read) rather than a silent wrong state; the next process retries.
271
- }
272
- }
273
- return record;
250
+ return parseHmacKeyRecord(raw);
274
251
  }
275
252
  function writeHmacKeyRecord(rec) {
276
253
  // JSON.stringify drops undefined fields (used to clear pendingDeletes).
@@ -325,15 +302,13 @@ function resolveHashState() {
325
302
  export function keychainServiceAlias(item) {
326
303
  return prepareServiceName(item);
327
304
  }
328
- function prepareServiceName(item, opts) {
305
+ function prepareServiceName(item) {
329
306
  if (rawScopeDepth > 0)
330
307
  return item;
331
308
  if (!isOurItem(item))
332
309
  return item;
333
310
  if (item === HMAC_KEY_ITEM || item.startsWith(HASHED_SERVICE_PREFIX))
334
311
  return item;
335
- if (opts?.autoRekey)
336
- maybeAutoRekey();
337
312
  const st = resolveHashState();
338
313
  if (!st.active || !st.key)
339
314
  return item;
@@ -354,7 +329,6 @@ function prepareListPrefix(prefix) {
354
329
  return { prefix };
355
330
  if (prefix.startsWith(HASHED_SERVICE_PREFIX))
356
331
  return { prefix };
357
- maybeAutoRekey();
358
332
  const st = resolveHashState();
359
333
  if (!st.active || !st.key)
360
334
  return { prefix };
@@ -429,7 +403,7 @@ function finishPendingDeletes(rec) {
429
403
  * Never throws — a failed attempt leaves the process on cleartext names
430
404
  * (exact pre-#316 behavior) and the next process retries.
431
405
  */
432
- function maybeAutoRekey() {
406
+ export function maybeAutoRekey() {
433
407
  if (autoRekeyAttempted || rekeyRunning || rawScopeDepth > 0)
434
408
  return;
435
409
  autoRekeyAttempted = true;
@@ -446,10 +420,14 @@ function maybeAutoRekey() {
446
420
  return;
447
421
  const st = resolveHashState();
448
422
  if (st.active) {
449
- // The stale-ACL'd-hmackey heal now runs on the hot read path
450
- // (readHmacKeyRecord), which the resolveHashState() above just went through —
451
- // so st.record is already healed here regardless of how this machine reached
452
- // "hashing active". Nothing to do but finish any pending deletes.
423
+ if (st.record && !st.record.healedNoAcl) {
424
+ try {
425
+ healHmacKeyNoAclOnce(st.record);
426
+ }
427
+ catch {
428
+ /* the next explicit unlock retries */
429
+ }
430
+ }
453
431
  if (st.record?.pendingDeletes?.length) {
454
432
  try {
455
433
  finishPendingDeletes(st.record);
@@ -820,7 +798,7 @@ export function getKeychainToken(item, context = {}) {
820
798
  // Errors keep the requested (human-readable) name; the storage name may be
821
799
  // an opaque hash.
822
800
  const requested = item;
823
- item = prepareServiceName(item, { autoRekey: true });
801
+ item = prepareServiceName(item);
824
802
  if (backend)
825
803
  return backend.get(item);
826
804
  assertRawKeychainReadAllowed(requested, context);
@@ -848,6 +826,7 @@ export function getKeychainToken(item, context = {}) {
848
826
  ...process.env,
849
827
  AGENTS_KEYCHAIN_PROMPT: keychainOperationPrompt(context),
850
828
  AGENTS_KEYCHAIN_PROMPT_BASE: keychainOperationPrompt({ ...context, duration: undefined }),
829
+ AGENTS_KEYCHAIN_SKIP_AUTH_UI: context.silentNoAcl ? '1' : '0',
851
830
  },
852
831
  stdio: ['ignore', 'pipe', 'pipe'],
853
832
  });
@@ -890,7 +869,7 @@ export function getKeychainTokens(items, context = {}) {
890
869
  // whether those were cleartext (hashed here) or already-hashed (enumerated).
891
870
  const requestedByStorage = new Map();
892
871
  const storageItems = items.map((item) => {
893
- const storage = prepareServiceName(item, { autoRekey: true });
872
+ const storage = prepareServiceName(item);
894
873
  if (!requestedByStorage.has(storage))
895
874
  requestedByStorage.set(storage, item);
896
875
  return storage;
@@ -939,6 +918,7 @@ export function getKeychainTokens(items, context = {}) {
939
918
  ...process.env,
940
919
  AGENTS_KEYCHAIN_PROMPT: keychainOperationPrompt(context),
941
920
  AGENTS_KEYCHAIN_PROMPT_BASE: keychainOperationPrompt({ ...context, duration: undefined }),
921
+ AGENTS_KEYCHAIN_SKIP_AUTH_UI: context.silentNoAcl ? '1' : '0',
942
922
  // The signed helper's own vocabulary is unchanged (it predates the rename
943
923
  // and ships as a separately-versioned binary), so map to its legacy token.
944
924
  AGENTS_KEYCHAIN_DEFAULT_POLICY: (context.defaultPolicy ?? 'hold') === 'hold' ? 'daily' : context.defaultPolicy,
@@ -1037,7 +1017,7 @@ export function setKeychainToken(item, value, opts) {
1037
1017
  if (/[\x00=\r\n]/.test(item))
1038
1018
  throw new Error('Secret item name contains invalid characters.');
1039
1019
  const requested = item;
1040
- item = prepareServiceName(item, { autoRekey: true });
1020
+ item = prepareServiceName(item);
1041
1021
  if (backend) {
1042
1022
  backend.set(item, value, opts);
1043
1023
  return;
@@ -9,7 +9,7 @@
9
9
  // `cloudflare` bundle.
10
10
  import { randomBytes } from 'node:crypto';
11
11
  import { readMeta, updateMeta } from '../state.js';
12
- import { bundleExists, bundleItemStore, bundlePolicy, isHeadlessSecretsContext, keychainRef, readAndResolveBundleEnv, readBundle, writeBundle, } from '../secrets/bundles.js';
12
+ import { bundleExists, bundleItemStore, bundlePolicy, keychainRef, readAndResolveBundleEnv, readBundle, writeBundle, } from '../secrets/bundles.js';
13
13
  import { secretsKeychainItem } from '../secrets/index.js';
14
14
  export const SHARE_BUNDLE = 'share';
15
15
  export const SHARE_TOKEN_KEY = 'WRITE_TOKEN';
@@ -76,10 +76,8 @@ export function storeWriteToken(token) {
76
76
  export function readWriteTokenFromBundle() {
77
77
  const { env } = readAndResolveBundleEnv(SHARE_BUNDLE, {
78
78
  caller: 'share',
79
- // Explicit `agents share` command (a human published a file): a headless agent
80
- // subprocess resolves broker-only, an interactive human may unlock. This is NOT
81
- // an agent LAUNCH read (that is exec.ts's --secrets injection, always agentOnly).
82
- agentOnly: isHeadlessSecretsContext(),
79
+ // Explicit share commands are reads, not authorization to authenticate.
80
+ agentOnly: true,
83
81
  });
84
82
  const token = env[SHARE_TOKEN_KEY];
85
83
  if (!token) {
@@ -136,8 +134,8 @@ export function readCloudflareCreds(bundle = DEFAULT_CF_BUNDLE, override) {
136
134
  }
137
135
  const { env } = readAndResolveBundleEnv(bundle, {
138
136
  caller: 'share',
139
- // Explicit `agents share setup` provisioning read — not an agent launch.
140
- agentOnly: isHeadlessSecretsContext(),
137
+ // Setup is still a read; only `agents secrets unlock` may authenticate.
138
+ agentOnly: true,
141
139
  });
142
140
  const find = (re) => {
143
141
  for (const [k, v] of Object.entries(env))
@@ -610,6 +610,12 @@ export interface DiscoveredPlugin {
610
610
  commands: string[];
611
611
  /** Subagent .md files in the plugin's agents/ directory (names without extension). */
612
612
  agentDefs: string[];
613
+ /**
614
+ * Workflow directory names under the plugin's `workflows/` (each must contain
615
+ * WORKFLOW.md). Phase 5 packaging: plugins may package workflows as entrypoints;
616
+ * `agents run <name>` resolves them via project > user > plugin > extra > system.
617
+ */
618
+ workflows: string[];
613
619
  /** Memory fact basenames from the plugin's memory/ directory (without .md). */
614
620
  memory: string[];
615
621
  /** Executable files in the plugin's bin/ directory. */
@@ -312,11 +312,25 @@ export declare const GROK_WORKFLOW_MARKER = "agents_workflow";
312
312
  export declare function transformWorkflowForGrok(workflowPath: string, name: string): string;
313
313
  /** Read the agents_workflow marker from a Grok `.rhai` file, if present. */
314
314
  export declare function grokWorkflowMarker(filePath: string): string | null;
315
+ /**
316
+ * Plugin `workflows/` directories in discovery order (project → user → system →
317
+ * extra). Used by name resolution and listing so a plugin-packaged workflow is
318
+ * runnable via `agents run <name>` without a separate install into
319
+ * ~/.agents/workflows/ (Phase 5 packaging). Within the plugin band, project
320
+ * plugins beat user/system plugins (same first-hit-wins as other layers).
321
+ */
322
+ export declare function listPluginWorkflowDirs(cwd?: string): string[];
323
+ /**
324
+ * True when `ref` is a single bare workflow name (no path separators, no `..`).
325
+ * Name lookup must not path-join multi-segment or traversal refs into search roots.
326
+ */
327
+ export declare function isBareWorkflowName(ref: string): boolean;
315
328
  /**
316
329
  * Resolve an `agents run <workflow>` reference.
317
330
  *
318
331
  * Directories are accepted anywhere on disk when they contain WORKFLOW.md.
319
- * Name lookup keeps the normal resource precedence: project > user > system > extras.
332
+ * Name lookup precedence (docs/07-entrypoints): project > user > plugin > extra > system.
333
+ * Bare name only; `name@plugin` disambiguation is a follow-up.
320
334
  */
321
335
  export declare function resolveWorkflowRef(ref: string, cwd?: string): string | null;
322
336
  /**
@@ -326,10 +340,11 @@ export declare function resolveWorkflowRef(ref: string, cwd?: string): string |
326
340
  */
327
341
  export declare function discoverWorkflowsFromRepo(repoPath: string): DiscoveredWorkflow[];
328
342
  /**
329
- * List all workflows in central storage.
330
- * User layer (~/.agents/workflows/) wins over system (~/.agents/.system/workflows/).
343
+ * List all workflows in central storage + plugin packages.
344
+ * Precedence: user > plugin > extra > system (first writer wins; project is
345
+ * cwd-scoped and handled by resolveWorkflowRef / the resource handler).
331
346
  */
332
- export declare function listInstalledWorkflows(): Map<string, InstalledWorkflow>;
347
+ export declare function listInstalledWorkflows(cwd?: string): Map<string, InstalledWorkflow>;
333
348
  /** Copy a workflow directory into user central storage (~/.agents/workflows/<name>/). */
334
349
  export declare function installWorkflowCentrally(sourcePath: string, name: string): {
335
350
  success: boolean;
@@ -10,7 +10,7 @@ import * as os from 'os';
10
10
  import * as path from 'path';
11
11
  import * as yaml from 'yaml';
12
12
  import { capableAgents, supports } from './capabilities.js';
13
- import { getProjectAgentsDir, getSystemWorkflowsDir, getUserWorkflowsDir, getTrashWorkflowsDir, getEnabledExtraRepos, } from './state.js';
13
+ import { getProjectAgentsDir, getSystemWorkflowsDir, getUserWorkflowsDir, getTrashWorkflowsDir, getEnabledExtraRepos, getPluginsDir, getSystemPluginsDir, getProjectPluginsDir, } from './state.js';
14
14
  import { listInstalledVersions, getVersionHomePath } from './versions.js';
15
15
  /**
16
16
  * Hard upper bound on items a single `for_each` expands, absent an explicit
@@ -522,22 +522,94 @@ function resolveWorkflowPath(ref, cwd) {
522
522
  const candidate = path.isAbsolute(expanded) ? expanded : path.resolve(cwd, expanded);
523
523
  return isWorkflowDir(candidate) ? candidate : null;
524
524
  }
525
+ /**
526
+ * Plugin `workflows/` directories in discovery order (project → user → system →
527
+ * extra). Used by name resolution and listing so a plugin-packaged workflow is
528
+ * runnable via `agents run <name>` without a separate install into
529
+ * ~/.agents/workflows/ (Phase 5 packaging). Within the plugin band, project
530
+ * plugins beat user/system plugins (same first-hit-wins as other layers).
531
+ */
532
+ export function listPluginWorkflowDirs(cwd = process.cwd()) {
533
+ const pluginRoots = [];
534
+ const pluginsDirs = [];
535
+ const projectPlugins = getProjectPluginsDir(cwd);
536
+ if (projectPlugins)
537
+ pluginsDirs.push(projectPlugins);
538
+ pluginsDirs.push(getPluginsDir(), getSystemPluginsDir());
539
+ for (const extra of getEnabledExtraRepos()) {
540
+ pluginsDirs.push(path.join(extra.dir, 'plugins'));
541
+ }
542
+ for (const pluginsDir of pluginsDirs) {
543
+ if (!fs.existsSync(pluginsDir))
544
+ continue;
545
+ let entries;
546
+ try {
547
+ entries = fs.readdirSync(pluginsDir, { withFileTypes: true });
548
+ }
549
+ catch {
550
+ continue;
551
+ }
552
+ for (const entry of entries) {
553
+ if (entry.name.startsWith('.'))
554
+ continue;
555
+ // Directories and symlinks-to-directories (plugin marketplaces often symlink).
556
+ const pluginRoot = path.join(pluginsDir, entry.name);
557
+ let isDir = entry.isDirectory();
558
+ if (!isDir && entry.isSymbolicLink()) {
559
+ try {
560
+ isDir = fs.statSync(pluginRoot).isDirectory();
561
+ }
562
+ catch {
563
+ isDir = false;
564
+ }
565
+ }
566
+ if (!isDir)
567
+ continue;
568
+ const workflowsDir = path.join(pluginRoot, 'workflows');
569
+ if (fs.existsSync(workflowsDir))
570
+ pluginRoots.push(workflowsDir);
571
+ }
572
+ }
573
+ return pluginRoots;
574
+ }
575
+ /**
576
+ * True when `ref` is a single bare workflow name (no path separators, no `..`).
577
+ * Name lookup must not path-join multi-segment or traversal refs into search roots.
578
+ */
579
+ export function isBareWorkflowName(ref) {
580
+ if (!ref || ref === '.' || ref === '..')
581
+ return false;
582
+ if (ref.includes('/') || ref.includes('\\'))
583
+ return false;
584
+ if (ref.includes('..'))
585
+ return false;
586
+ // Reject absolute paths (posix or Windows).
587
+ if (path.isAbsolute(ref))
588
+ return false;
589
+ return path.basename(ref) === ref;
590
+ }
525
591
  /**
526
592
  * Resolve an `agents run <workflow>` reference.
527
593
  *
528
594
  * Directories are accepted anywhere on disk when they contain WORKFLOW.md.
529
- * Name lookup keeps the normal resource precedence: project > user > system > extras.
595
+ * Name lookup precedence (docs/07-entrypoints): project > user > plugin > extra > system.
596
+ * Bare name only; `name@plugin` disambiguation is a follow-up.
530
597
  */
531
598
  export function resolveWorkflowRef(ref, cwd = process.cwd()) {
532
599
  const direct = resolveWorkflowPath(ref, cwd);
533
600
  if (direct)
534
601
  return direct;
602
+ // Name lookup only — reject traversal / multi-segment so path.join(dir, ref)
603
+ // cannot escape a workflows root (absolute paths already handled above).
604
+ if (!isBareWorkflowName(ref))
605
+ return null;
535
606
  const projectAgentsDir = getProjectAgentsDir(cwd);
536
607
  const searchDirs = [
537
608
  ...(projectAgentsDir ? [path.join(projectAgentsDir, 'workflows')] : []),
538
609
  getUserWorkflowsDir(),
539
- getSystemWorkflowsDir(),
610
+ ...listPluginWorkflowDirs(cwd),
540
611
  ...getEnabledExtraRepos().map(r => path.join(r.dir, 'workflows')),
612
+ getSystemWorkflowsDir(),
541
613
  ];
542
614
  for (const dir of searchDirs) {
543
615
  const workflowPath = path.join(dir, ref);
@@ -592,16 +664,18 @@ export function discoverWorkflowsFromRepo(repoPath) {
592
664
  return results;
593
665
  }
594
666
  /**
595
- * List all workflows in central storage.
596
- * User layer (~/.agents/workflows/) wins over system (~/.agents/.system/workflows/).
667
+ * List all workflows in central storage + plugin packages.
668
+ * Precedence: user > plugin > extra > system (first writer wins; project is
669
+ * cwd-scoped and handled by resolveWorkflowRef / the resource handler).
597
670
  */
598
- export function listInstalledWorkflows() {
671
+ export function listInstalledWorkflows(cwd = process.cwd()) {
599
672
  const result = new Map();
600
673
  const extraRepos = getEnabledExtraRepos();
601
674
  const searchDirs = [
602
675
  getUserWorkflowsDir(),
603
- getSystemWorkflowsDir(),
676
+ ...listPluginWorkflowDirs(cwd),
604
677
  ...extraRepos.map(r => path.join(r.dir, 'workflows')),
678
+ getSystemWorkflowsDir(),
605
679
  ];
606
680
  for (const dir of searchDirs) {
607
681
  if (!fs.existsSync(dir))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.22.9",
3
+ "version": "1.22.10",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",