agent-relay 4.0.2 → 4.0.4

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.
Files changed (175) hide show
  1. package/bin/agent-relay-broker-darwin-arm64 +0 -0
  2. package/bin/agent-relay-broker-darwin-x64 +0 -0
  3. package/bin/agent-relay-broker-linux-arm64 +0 -0
  4. package/bin/agent-relay-broker-linux-x64 +0 -0
  5. package/dist/index.cjs +7906 -2084
  6. package/dist/packages/sdk/src/provisioner/seeder.d.ts +17 -0
  7. package/dist/packages/sdk/src/provisioner/seeder.d.ts.map +1 -0
  8. package/dist/packages/sdk/src/provisioner/seeder.js +419 -0
  9. package/dist/packages/sdk/src/provisioner/seeder.js.map +1 -0
  10. package/dist/packages/sdk/src/provisioner/token.d.ts +38 -0
  11. package/dist/packages/sdk/src/provisioner/token.d.ts.map +1 -0
  12. package/dist/packages/sdk/src/provisioner/token.js +74 -0
  13. package/dist/packages/sdk/src/provisioner/token.js.map +1 -0
  14. package/dist/src/cli/commands/core.d.ts.map +1 -1
  15. package/dist/src/cli/commands/core.js +7 -3
  16. package/dist/src/cli/commands/core.js.map +1 -1
  17. package/dist/src/cli/commands/on/provision.d.ts.map +1 -1
  18. package/dist/src/cli/commands/on/provision.js +8 -3
  19. package/dist/src/cli/commands/on/provision.js.map +1 -1
  20. package/dist/src/cli/commands/on/start.d.ts +5 -0
  21. package/dist/src/cli/commands/on/start.d.ts.map +1 -1
  22. package/dist/src/cli/commands/on/start.js +126 -88
  23. package/dist/src/cli/commands/on/start.js.map +1 -1
  24. package/dist/src/cli/commands/on/symlink-mount.d.ts +12 -0
  25. package/dist/src/cli/commands/on/symlink-mount.d.ts.map +1 -0
  26. package/dist/src/cli/commands/on/symlink-mount.js +304 -0
  27. package/dist/src/cli/commands/on/symlink-mount.js.map +1 -0
  28. package/dist/src/cli/commands/on.d.ts.map +1 -1
  29. package/dist/src/cli/commands/on.js +3 -0
  30. package/dist/src/cli/commands/on.js.map +1 -1
  31. package/install.sh +4 -0
  32. package/package.json +9 -9
  33. package/packages/acp-bridge/package.json +2 -2
  34. package/packages/brand/package.json +1 -1
  35. package/packages/cloud/package.json +2 -2
  36. package/packages/config/package.json +1 -1
  37. package/packages/hooks/package.json +4 -4
  38. package/packages/memory/package.json +2 -2
  39. package/packages/openclaw/package.json +2 -2
  40. package/packages/policy/package.json +2 -2
  41. package/packages/sdk/dist/client.d.ts +3 -10
  42. package/packages/sdk/dist/client.d.ts.map +1 -1
  43. package/packages/sdk/dist/client.js +2 -0
  44. package/packages/sdk/dist/client.js.map +1 -1
  45. package/packages/sdk/dist/provisioner/__tests__/audit.test.d.ts +2 -0
  46. package/packages/sdk/dist/provisioner/__tests__/audit.test.d.ts.map +1 -0
  47. package/packages/sdk/dist/provisioner/__tests__/audit.test.js +45 -0
  48. package/packages/sdk/dist/provisioner/__tests__/audit.test.js.map +1 -0
  49. package/packages/sdk/dist/provisioner/__tests__/compiler.test.d.ts +2 -0
  50. package/packages/sdk/dist/provisioner/__tests__/compiler.test.d.ts.map +1 -0
  51. package/packages/sdk/dist/provisioner/__tests__/compiler.test.js +345 -0
  52. package/packages/sdk/dist/provisioner/__tests__/compiler.test.js.map +1 -0
  53. package/packages/sdk/dist/provisioner/__tests__/presets.test.d.ts +2 -0
  54. package/packages/sdk/dist/provisioner/__tests__/presets.test.d.ts.map +1 -0
  55. package/packages/sdk/dist/provisioner/__tests__/presets.test.js +23 -0
  56. package/packages/sdk/dist/provisioner/__tests__/presets.test.js.map +1 -0
  57. package/packages/sdk/dist/provisioner/__tests__/seeder.test.d.ts +2 -0
  58. package/packages/sdk/dist/provisioner/__tests__/seeder.test.d.ts.map +1 -0
  59. package/packages/sdk/dist/provisioner/__tests__/seeder.test.js +224 -0
  60. package/packages/sdk/dist/provisioner/__tests__/seeder.test.js.map +1 -0
  61. package/packages/sdk/dist/provisioner/__tests__/tar-seeder.test.d.ts +2 -0
  62. package/packages/sdk/dist/provisioner/__tests__/tar-seeder.test.d.ts.map +1 -0
  63. package/packages/sdk/dist/provisioner/__tests__/tar-seeder.test.js +191 -0
  64. package/packages/sdk/dist/provisioner/__tests__/tar-seeder.test.js.map +1 -0
  65. package/packages/sdk/dist/provisioner/__tests__/token-factory.test.d.ts +2 -0
  66. package/packages/sdk/dist/provisioner/__tests__/token-factory.test.d.ts.map +1 -0
  67. package/packages/sdk/dist/provisioner/__tests__/token-factory.test.js +127 -0
  68. package/packages/sdk/dist/provisioner/__tests__/token-factory.test.js.map +1 -0
  69. package/packages/sdk/dist/provisioner/__tests__/token.test.d.ts +2 -0
  70. package/packages/sdk/dist/provisioner/__tests__/token.test.d.ts.map +1 -0
  71. package/packages/sdk/dist/provisioner/__tests__/token.test.js +44 -0
  72. package/packages/sdk/dist/provisioner/__tests__/token.test.js.map +1 -0
  73. package/packages/sdk/dist/provisioner/audit.d.ts +19 -0
  74. package/packages/sdk/dist/provisioner/audit.d.ts.map +1 -0
  75. package/packages/sdk/dist/provisioner/audit.js +74 -0
  76. package/packages/sdk/dist/provisioner/audit.js.map +1 -0
  77. package/packages/sdk/dist/provisioner/compiler.d.ts +23 -0
  78. package/packages/sdk/dist/provisioner/compiler.d.ts.map +1 -0
  79. package/packages/sdk/dist/provisioner/compiler.js +355 -0
  80. package/packages/sdk/dist/provisioner/compiler.js.map +1 -0
  81. package/packages/sdk/dist/provisioner/index.d.ts +9 -0
  82. package/packages/sdk/dist/provisioner/index.d.ts.map +1 -0
  83. package/packages/sdk/dist/provisioner/index.js +266 -0
  84. package/packages/sdk/dist/provisioner/index.js.map +1 -0
  85. package/packages/sdk/dist/provisioner/mount.d.ts +14 -0
  86. package/packages/sdk/dist/provisioner/mount.d.ts.map +1 -0
  87. package/packages/sdk/dist/provisioner/mount.js +329 -0
  88. package/packages/sdk/dist/provisioner/mount.js.map +1 -0
  89. package/packages/sdk/dist/provisioner/seeder.d.ts +17 -0
  90. package/packages/sdk/dist/provisioner/seeder.d.ts.map +1 -0
  91. package/packages/sdk/dist/provisioner/seeder.js +419 -0
  92. package/packages/sdk/dist/provisioner/seeder.js.map +1 -0
  93. package/packages/sdk/dist/provisioner/token.d.ts +38 -0
  94. package/packages/sdk/dist/provisioner/token.d.ts.map +1 -0
  95. package/packages/sdk/dist/provisioner/token.js +74 -0
  96. package/packages/sdk/dist/provisioner/token.js.map +1 -0
  97. package/packages/sdk/dist/provisioner/types.d.ts +133 -0
  98. package/packages/sdk/dist/provisioner/types.d.ts.map +1 -0
  99. package/packages/sdk/dist/provisioner/types.js +2 -0
  100. package/packages/sdk/dist/provisioner/types.js.map +1 -0
  101. package/packages/sdk/dist/relay.d.ts +6 -0
  102. package/packages/sdk/dist/relay.d.ts.map +1 -1
  103. package/packages/sdk/dist/relay.js +17 -5
  104. package/packages/sdk/dist/relay.js.map +1 -1
  105. package/packages/sdk/dist/types.d.ts +9 -0
  106. package/packages/sdk/dist/types.d.ts.map +1 -1
  107. package/packages/sdk/dist/workflows/__tests__/e2e-permissions.test.d.ts +2 -0
  108. package/packages/sdk/dist/workflows/__tests__/e2e-permissions.test.d.ts.map +1 -0
  109. package/packages/sdk/dist/workflows/__tests__/e2e-permissions.test.js +331 -0
  110. package/packages/sdk/dist/workflows/__tests__/e2e-permissions.test.js.map +1 -0
  111. package/packages/sdk/dist/workflows/__tests__/permission-types.test.d.ts +2 -0
  112. package/packages/sdk/dist/workflows/__tests__/permission-types.test.d.ts.map +1 -0
  113. package/packages/sdk/dist/workflows/__tests__/permission-types.test.js +124 -0
  114. package/packages/sdk/dist/workflows/__tests__/permission-types.test.js.map +1 -0
  115. package/packages/sdk/dist/workflows/__tests__/permissions-integration.test.d.ts +2 -0
  116. package/packages/sdk/dist/workflows/__tests__/permissions-integration.test.d.ts.map +1 -0
  117. package/packages/sdk/dist/workflows/__tests__/permissions-integration.test.js +526 -0
  118. package/packages/sdk/dist/workflows/__tests__/permissions-integration.test.js.map +1 -0
  119. package/packages/sdk/dist/workflows/dry-run-format.d.ts.map +1 -1
  120. package/packages/sdk/dist/workflows/dry-run-format.js +8 -0
  121. package/packages/sdk/dist/workflows/dry-run-format.js.map +1 -1
  122. package/packages/sdk/dist/workflows/runner.d.ts +14 -0
  123. package/packages/sdk/dist/workflows/runner.d.ts.map +1 -1
  124. package/packages/sdk/dist/workflows/runner.js +455 -6
  125. package/packages/sdk/dist/workflows/runner.js.map +1 -1
  126. package/packages/sdk/dist/workflows/types.d.ts +190 -0
  127. package/packages/sdk/dist/workflows/types.d.ts.map +1 -1
  128. package/packages/sdk/dist/workflows/types.js +29 -0
  129. package/packages/sdk/dist/workflows/types.js.map +1 -1
  130. package/packages/sdk/package.json +6 -2
  131. package/packages/sdk/src/__tests__/orchestration-upgrades.test.ts +123 -1
  132. package/packages/sdk/src/__tests__/provisioner-mount.test.ts +126 -0
  133. package/packages/sdk/src/__tests__/spawn-token.test.ts +41 -0
  134. package/packages/sdk/src/__tests__/workflow-runner.test.ts +77 -45
  135. package/packages/sdk/src/client.ts +4 -8
  136. package/packages/sdk/src/provisioner/__tests__/audit.test.ts +62 -0
  137. package/packages/sdk/src/provisioner/__tests__/compiler.test.ts +369 -0
  138. package/packages/sdk/src/provisioner/__tests__/presets.test.ts +25 -0
  139. package/packages/sdk/src/provisioner/__tests__/seeder.test.ts +284 -0
  140. package/packages/sdk/src/provisioner/__tests__/tar-seeder.test.ts +249 -0
  141. package/packages/sdk/src/provisioner/__tests__/token-factory.test.ts +172 -0
  142. package/packages/sdk/src/provisioner/__tests__/token.test.ts +53 -0
  143. package/packages/sdk/src/provisioner/audit.ts +104 -0
  144. package/packages/sdk/src/provisioner/compiler.ts +498 -0
  145. package/packages/sdk/src/provisioner/index.ts +332 -0
  146. package/packages/sdk/src/provisioner/mount.ts +419 -0
  147. package/packages/sdk/src/provisioner/seeder.ts +571 -0
  148. package/packages/sdk/src/provisioner/token.ts +112 -0
  149. package/packages/sdk/src/provisioner/types.ts +188 -0
  150. package/packages/sdk/src/relay.ts +31 -9
  151. package/packages/sdk/src/types.ts +9 -0
  152. package/packages/sdk/src/workflows/__tests__/e2e-permissions.test.ts +407 -0
  153. package/packages/sdk/src/workflows/__tests__/fixtures/.agentignore +2 -0
  154. package/packages/sdk/src/workflows/__tests__/fixtures/.reader.agentreadonly +2 -0
  155. package/packages/sdk/src/workflows/__tests__/fixtures/permission-test.yaml +42 -0
  156. package/packages/sdk/src/workflows/__tests__/permission-types.test.ts +154 -0
  157. package/packages/sdk/src/workflows/__tests__/permissions-integration.test.ts +649 -0
  158. package/packages/sdk/src/workflows/builtin-templates/bug-fix.yaml +13 -9
  159. package/packages/sdk/src/workflows/builtin-templates/code-review.yaml +12 -8
  160. package/packages/sdk/src/workflows/builtin-templates/competitive.yaml +11 -7
  161. package/packages/sdk/src/workflows/builtin-templates/documentation.yaml +16 -8
  162. package/packages/sdk/src/workflows/builtin-templates/feature-dev.yaml +13 -9
  163. package/packages/sdk/src/workflows/builtin-templates/refactor.yaml +13 -9
  164. package/packages/sdk/src/workflows/builtin-templates/review-loop.yaml +14 -10
  165. package/packages/sdk/src/workflows/builtin-templates/security-audit.yaml +19 -9
  166. package/packages/sdk/src/workflows/dry-run-format.ts +14 -1
  167. package/packages/sdk/src/workflows/runner.ts +559 -6
  168. package/packages/sdk/src/workflows/schema.json +204 -114
  169. package/packages/sdk/src/workflows/types.ts +266 -1
  170. package/packages/sdk/vitest.config.ts +5 -1
  171. package/packages/sdk-py/pyproject.toml +1 -1
  172. package/packages/telemetry/package.json +1 -1
  173. package/packages/trajectory/package.json +2 -2
  174. package/packages/user-directory/package.json +2 -2
  175. package/packages/utils/package.json +2 -2
@@ -22,6 +22,7 @@ import { readFile, writeFile, mkdir, unlink } from 'node:fs/promises';
22
22
  import { tmpdir } from 'node:os';
23
23
  import path from 'node:path';
24
24
  import chalk from 'chalk';
25
+ import ignore from 'ignore';
25
26
 
26
27
  import { parse as parseYaml } from 'yaml';
27
28
  import { stripAnsi as stripAnsiFn } from '../pty.js';
@@ -37,6 +38,8 @@ import {
37
38
  CustomStepsParseError,
38
39
  CustomStepResolutionError,
39
40
  } from './custom-steps.js';
41
+ import { provisionWorkflowAgents, resolveAgentPermissions } from '../provisioner/index.js';
42
+ import type { MountHandle } from '../provisioner/mount.js';
40
43
  import { collectCliSession, type CliSessionReport } from './cli-session-collector.js';
41
44
  import { executeApiStep } from './api-executor.js';
42
45
  import { ChannelMessenger } from './channel-messenger.js';
@@ -55,8 +58,10 @@ import {
55
58
  type VariableContext,
56
59
  } from './template-resolver.js';
57
60
  import type {
61
+ AccessPreset,
58
62
  AgentCli,
59
63
  AgentDefinition,
64
+ AgentPermissions,
60
65
  AgentPreset,
61
66
  CompletionEvidenceChannelOrigin,
62
67
  CompletionEvidenceChannelPost,
@@ -69,6 +74,7 @@ import type {
69
74
  ErrorHandlingConfig,
70
75
  IdleNudgeConfig,
71
76
  PathDefinition,
77
+ PermissionProfileDefinition,
72
78
  PreflightCheck,
73
79
  RelayYamlConfig,
74
80
  StepCompletionDecision,
@@ -391,6 +397,10 @@ export class WorkflowRunner {
391
397
  private currentRunId?: string;
392
398
  /** Live Agent handles keyed by name, for hub-mediated nudging. */
393
399
  private readonly activeAgentHandles = new Map<string, Agent>();
400
+ /** Per-agent workflow tokens for relay/relayfile auth across spawn modes. */
401
+ private readonly agentTokens = new Map<string, string>();
402
+ /** Per-agent relayfile mounts keyed by logical agent definition name. */
403
+ private readonly agentMounts = new Map<string, MountHandle>();
394
404
 
395
405
  // PTY-based output capture: accumulate terminal output per-agent
396
406
  private readonly ptyOutputBuffers = new Map<string, string[]>();
@@ -492,6 +502,355 @@ export class WorkflowRunner {
492
502
  return { resolved, errors, warnings };
493
503
  }
494
504
 
505
+ private validatePermissions(
506
+ agents: AgentDefinition[] | undefined,
507
+ permissionProfiles: RelayYamlConfig['permission_profiles'],
508
+ source = '<config>'
509
+ ): { errors: string[]; warnings: string[] } {
510
+ const errors: string[] = [];
511
+ const warnings: string[] = [];
512
+ const accessPresets = new Set<AccessPreset>(['readonly', 'readwrite', 'restricted', 'full']);
513
+ const profiles = permissionProfiles ?? {};
514
+ const profileNames = new Set(Object.keys(profiles));
515
+
516
+ const validateStringArray = (value: unknown, label: string): string[] | undefined => {
517
+ if (value === undefined) {
518
+ return undefined;
519
+ }
520
+ if (!Array.isArray(value)) {
521
+ errors.push(`${label} must be an array of strings`);
522
+ return undefined;
523
+ }
524
+
525
+ const normalized: string[] = [];
526
+ for (const entry of value) {
527
+ if (typeof entry !== 'string') {
528
+ errors.push(`${label} must be an array of strings`);
529
+ continue;
530
+ }
531
+ normalized.push(entry);
532
+ }
533
+ return normalized;
534
+ };
535
+
536
+ const validateGlobPattern = (pattern: string, label: string): void => {
537
+ const trimmed = pattern.trim();
538
+ if (trimmed === '') {
539
+ errors.push(`${label} must not contain empty glob patterns`);
540
+ return;
541
+ }
542
+ if (trimmed.includes('\0')) {
543
+ errors.push(`${label} contains an invalid glob pattern "${pattern}" (NUL byte)`);
544
+ return;
545
+ }
546
+
547
+ let escaped = false;
548
+ let bracketDepth = 0;
549
+ let braceDepth = 0;
550
+
551
+ for (const ch of trimmed) {
552
+ if (escaped) {
553
+ escaped = false;
554
+ continue;
555
+ }
556
+ if (ch === '\\') {
557
+ escaped = true;
558
+ continue;
559
+ }
560
+ if (ch === '[') {
561
+ bracketDepth += 1;
562
+ continue;
563
+ }
564
+ if (ch === ']' && bracketDepth > 0) {
565
+ bracketDepth -= 1;
566
+ continue;
567
+ }
568
+ if (ch === '{') {
569
+ braceDepth += 1;
570
+ continue;
571
+ }
572
+ if (ch === '}' && braceDepth > 0) {
573
+ braceDepth -= 1;
574
+ }
575
+ }
576
+
577
+ if (escaped) {
578
+ errors.push(`${label} contains an invalid glob pattern "${pattern}" (dangling escape)`);
579
+ return;
580
+ }
581
+ if (bracketDepth > 0) {
582
+ errors.push(`${label} contains an invalid glob pattern "${pattern}" (unclosed character class)`);
583
+ return;
584
+ }
585
+ if (braceDepth > 0) {
586
+ errors.push(`${label} contains an invalid glob pattern "${pattern}" (unclosed brace expansion)`);
587
+ return;
588
+ }
589
+
590
+ try {
591
+ ignore().add([trimmed]);
592
+ } catch (err) {
593
+ errors.push(
594
+ `${label} contains an invalid glob pattern "${pattern}" (${err instanceof Error ? err.message : String(err)})`
595
+ );
596
+ }
597
+ };
598
+
599
+ const validatePermissionObject = (
600
+ permissions: unknown,
601
+ label: string,
602
+ options: { allowProfileReference: boolean }
603
+ ): void => {
604
+ if (typeof permissions === 'string') {
605
+ const shorthand = permissions.trim();
606
+ if (shorthand === '') {
607
+ errors.push(`${label} must not be empty`);
608
+ return;
609
+ }
610
+
611
+ if (accessPresets.has(shorthand as AccessPreset)) {
612
+ return;
613
+ }
614
+
615
+ if (options.allowProfileReference) {
616
+ if (!profileNames.has(shorthand)) {
617
+ errors.push(`${label} references unknown permission profile "${shorthand}"`);
618
+ }
619
+ return;
620
+ }
621
+
622
+ errors.push(`${label} must be an object when provided`);
623
+ return;
624
+ }
625
+
626
+ if (typeof permissions !== 'object' || permissions === null) {
627
+ errors.push(`${label} must be an object when provided`);
628
+ return;
629
+ }
630
+
631
+ const permissionRecord = permissions as Record<string, unknown>;
632
+
633
+ if (permissionRecord.description !== undefined && typeof permissionRecord.description !== 'string') {
634
+ errors.push(`${label}.description must be a string when provided`);
635
+ }
636
+
637
+ if (permissionRecord.profile !== undefined) {
638
+ if (!options.allowProfileReference) {
639
+ errors.push(`${label}.profile is only supported on agent permissions`);
640
+ } else if (typeof permissionRecord.profile !== 'string') {
641
+ errors.push(`${label}.profile must be a string when provided`);
642
+ } else if (permissionRecord.profile.trim() === '') {
643
+ errors.push(`${label}.profile must not be empty`);
644
+ } else if (!profileNames.has(permissionRecord.profile)) {
645
+ errors.push(`${label}.profile references unknown permission profile "${permissionRecord.profile}"`);
646
+ }
647
+ }
648
+
649
+ if (permissionRecord.why !== undefined && typeof permissionRecord.why !== 'string') {
650
+ errors.push(`${label}.why must be a string when provided`);
651
+ }
652
+
653
+ if (
654
+ permissionRecord.access !== undefined &&
655
+ !accessPresets.has(permissionRecord.access as AccessPreset)
656
+ ) {
657
+ errors.push(`${label}.access must be one of readonly, readwrite, restricted, full`);
658
+ }
659
+
660
+ if (permissionRecord.inherit !== undefined && typeof permissionRecord.inherit !== 'boolean') {
661
+ errors.push(`${label}.inherit must be a boolean when provided`);
662
+ }
663
+
664
+ if (permissionRecord.network !== undefined) {
665
+ if (typeof permissionRecord.network === 'boolean') {
666
+ // valid: boolean form
667
+ } else if (
668
+ typeof permissionRecord.network === 'object' &&
669
+ permissionRecord.network !== null &&
670
+ !Array.isArray(permissionRecord.network)
671
+ ) {
672
+ const net = permissionRecord.network as Record<string, unknown>;
673
+ validateStringArray(net.allow, `${label}.network.allow`);
674
+ validateStringArray(net.deny, `${label}.network.deny`);
675
+ } else {
676
+ errors.push(`${label}.network must be a boolean or an object with allow/deny arrays`);
677
+ }
678
+ }
679
+
680
+ if (permissionRecord.files !== undefined) {
681
+ if (
682
+ typeof permissionRecord.files !== 'object' ||
683
+ permissionRecord.files === null ||
684
+ Array.isArray(permissionRecord.files)
685
+ ) {
686
+ errors.push(`${label}.files must be an object when provided`);
687
+ } else {
688
+ const files = permissionRecord.files as Record<string, unknown>;
689
+ const read = validateStringArray(files.read, `${label}.files.read`);
690
+ const write = validateStringArray(files.write, `${label}.files.write`);
691
+ const deny = validateStringArray(files.deny, `${label}.files.deny`);
692
+
693
+ for (const pattern of read ?? []) {
694
+ validateGlobPattern(pattern, `${label}.files.read`);
695
+ }
696
+ for (const pattern of write ?? []) {
697
+ validateGlobPattern(pattern, `${label}.files.write`);
698
+ }
699
+ for (const pattern of deny ?? []) {
700
+ validateGlobPattern(pattern, `${label}.files.deny`);
701
+ }
702
+
703
+ if (permissionRecord.access === 'readonly' && (write?.length ?? 0) > 0) {
704
+ warnings.push(`${label} sets access to "readonly" but also defines files.write entries`);
705
+ }
706
+ }
707
+ }
708
+
709
+ const scopes = validateStringArray(permissionRecord.scopes, `${label}.scopes`);
710
+ for (const scope of scopes ?? []) {
711
+ if (scope.trim() === '') {
712
+ errors.push(`${label}.scopes must not contain empty strings`);
713
+ continue;
714
+ }
715
+ if (!/^[^:\s]+:[^:\s]+:[^:\s]+:.+$/u.test(scope)) {
716
+ errors.push(`${label}.scopes entry "${scope}" must follow plane:resource:action:path format`);
717
+ }
718
+ }
719
+
720
+ const exec = validateStringArray(permissionRecord.exec, `${label}.exec`);
721
+ for (const entry of exec ?? []) {
722
+ if (entry.trim() === '') {
723
+ errors.push(`${label}.exec must not contain empty strings`);
724
+ }
725
+ }
726
+ };
727
+
728
+ if (permissionProfiles !== undefined) {
729
+ if (
730
+ typeof permissionProfiles !== 'object' ||
731
+ permissionProfiles === null ||
732
+ Array.isArray(permissionProfiles)
733
+ ) {
734
+ errors.push(`${source}: permission_profiles must be an object when provided`);
735
+ } else {
736
+ for (const [profileName, profile] of Object.entries(permissionProfiles)) {
737
+ if (profileName.trim() === '') {
738
+ errors.push(`${source}: permission_profiles keys must not be empty`);
739
+ continue;
740
+ }
741
+ validatePermissionObject(profile, `${source}: permission_profiles.${profileName}`, {
742
+ allowProfileReference: false,
743
+ });
744
+ }
745
+ }
746
+ }
747
+
748
+ if (!agents || agents.length === 0) {
749
+ return { errors, warnings };
750
+ }
751
+
752
+ for (const agent of agents) {
753
+ if (agent.permissions === undefined) {
754
+ continue;
755
+ }
756
+ validatePermissionObject(agent.permissions, `${source}: agent "${agent.name}" permissions`, {
757
+ allowProfileReference: true,
758
+ });
759
+ }
760
+
761
+ return { errors, warnings };
762
+ }
763
+
764
+ private mergePermissionLists(
765
+ base: readonly string[] | undefined,
766
+ override: readonly string[] | undefined
767
+ ): string[] | undefined {
768
+ const merged = [
769
+ ...new Set([...(base ?? []), ...(override ?? [])].map((value) => value.trim()).filter(Boolean)),
770
+ ];
771
+ return merged.length > 0 ? merged : undefined;
772
+ }
773
+
774
+ private mergePermissionFiles(
775
+ base: AgentPermissions['files'],
776
+ override: AgentPermissions['files']
777
+ ): AgentPermissions['files'] {
778
+ const merged = {
779
+ read: this.mergePermissionLists(base?.read, override?.read),
780
+ write: this.mergePermissionLists(base?.write, override?.write),
781
+ deny: this.mergePermissionLists(base?.deny, override?.deny),
782
+ };
783
+
784
+ return merged.read || merged.write || merged.deny ? merged : undefined;
785
+ }
786
+
787
+ private mergePermissionProfile(
788
+ profile: PermissionProfileDefinition,
789
+ permissions: AgentPermissions
790
+ ): AgentPermissions {
791
+ const merged: AgentPermissions = {
792
+ description: permissions.description ?? profile.description,
793
+ profile: permissions.profile,
794
+ why: permissions.why ?? profile.why,
795
+ access: permissions.access ?? profile.access,
796
+ inherit: permissions.inherit ?? profile.inherit,
797
+ files: this.mergePermissionFiles(profile.files, permissions.files),
798
+ scopes: this.mergePermissionLists(profile.scopes, permissions.scopes),
799
+ network: permissions.network ?? profile.network,
800
+ exec: this.mergePermissionLists(profile.exec, permissions.exec),
801
+ };
802
+
803
+ return Object.fromEntries(
804
+ Object.entries(merged).filter(([, value]) => value !== undefined)
805
+ ) as AgentPermissions;
806
+ }
807
+
808
+ private applyPermissionProfiles(config: RelayYamlConfig): RelayYamlConfig {
809
+ if (!config.permission_profiles || Object.keys(config.permission_profiles).length === 0) {
810
+ return config;
811
+ }
812
+
813
+ return {
814
+ ...config,
815
+ agents: config.agents.map((agent) => {
816
+ const rawPermissions = agent.permissions;
817
+ if (!rawPermissions) {
818
+ return agent;
819
+ }
820
+
821
+ const normalizedPermissions =
822
+ typeof rawPermissions === 'string'
823
+ ? ({
824
+ ...(config.permission_profiles?.[rawPermissions]
825
+ ? { profile: rawPermissions }
826
+ : { access: rawPermissions as AccessPreset }),
827
+ } as AgentPermissions)
828
+ : rawPermissions;
829
+
830
+ const profileName = normalizedPermissions.profile;
831
+ if (!profileName) {
832
+ return {
833
+ ...agent,
834
+ permissions: normalizedPermissions,
835
+ };
836
+ }
837
+
838
+ const profile = config.permission_profiles?.[profileName];
839
+ if (!profile) {
840
+ return {
841
+ ...agent,
842
+ permissions: normalizedPermissions,
843
+ };
844
+ }
845
+
846
+ return {
847
+ ...agent,
848
+ permissions: this.mergePermissionProfile(profile, normalizedPermissions),
849
+ };
850
+ }),
851
+ };
852
+ }
853
+
495
854
  /**
496
855
  * Resolve an agent's effective working directory, considering `workdir` (named path reference)
497
856
  * and `cwd` (explicit path). `workdir` takes precedence when both are set.
@@ -534,6 +893,36 @@ export class WorkflowRunner {
534
893
  return this.resolveStepWorkdir(step) ?? (agentDef ? this.resolveAgentCwd(agentDef) : this.cwd);
535
894
  }
536
895
 
896
+ private resolveMountedCwd(agentName: string, cwd: string): string {
897
+ const mount = this.agentMounts.get(agentName);
898
+ if (!mount) {
899
+ return cwd;
900
+ }
901
+
902
+ const relative = path.relative(this.cwd, cwd);
903
+ if (relative === '') {
904
+ return mount.mountPoint;
905
+ }
906
+ if (relative === '..' || relative.startsWith(`..${path.sep}`)) {
907
+ return cwd;
908
+ }
909
+ return path.resolve(mount.mountPoint, relative);
910
+ }
911
+
912
+ private resolveExecutionCwd(step: WorkflowStep, agentDef?: AgentDefinition): string {
913
+ const cwd = this.resolveEffectiveCwd(step, agentDef);
914
+ if (!agentDef) {
915
+ return cwd;
916
+ }
917
+ return this.resolveMountedCwd(agentDef.name, cwd);
918
+ }
919
+
920
+ private async stopProvisionedMounts(): Promise<void> {
921
+ const handles = [...this.agentMounts.values()];
922
+ this.agentMounts.clear();
923
+ await Promise.all(handles.map((handle) => handle.stop().catch(() => undefined)));
924
+ }
925
+
537
926
  private static readonly EVIDENCE_IGNORED_DIRS = new Set([
538
927
  '.git',
539
928
  '.agent-relay',
@@ -1138,6 +1527,48 @@ export class WorkflowRunner {
1138
1527
  };
1139
1528
  }
1140
1529
 
1530
+ private async provisionAgents(config: RelayYamlConfig): Promise<void> {
1531
+ this.agentTokens.clear();
1532
+ await this.stopProvisionedMounts();
1533
+
1534
+ const agentsToProvision: Record<string, NonNullable<AgentDefinition['permissions']>> = {};
1535
+ for (const agent of config.agents) {
1536
+ if (agent.permissions) {
1537
+ agentsToProvision[agent.name] = agent.permissions;
1538
+ }
1539
+ }
1540
+
1541
+ const agentNames = Object.keys(agentsToProvision);
1542
+ if (agentNames.length === 0) {
1543
+ return;
1544
+ }
1545
+
1546
+ const relayEnv = {
1547
+ ...process.env,
1548
+ ...(this.getRelayEnv() ?? {}),
1549
+ };
1550
+ const result = await provisionWorkflowAgents({
1551
+ secret:
1552
+ this.envSecrets?.RELAY_AUTH_SECRET ?? relayEnv.RELAY_AUTH_SECRET ?? randomBytes(32).toString('hex'),
1553
+ workspace: this.workspaceId,
1554
+ projectDir: this.cwd,
1555
+ relayfileBaseUrl: relayEnv.RELAYFILE_BASE_URL ?? 'http://127.0.0.1:8080',
1556
+ agents: agentsToProvision,
1557
+ tokenTtlSeconds: 3600,
1558
+ });
1559
+
1560
+ for (const [agentName, token] of result.tokens) {
1561
+ this.agentTokens.set(agentName, token);
1562
+ }
1563
+ for (const [agentName, mount] of result.mounts) {
1564
+ this.agentMounts.set(agentName, mount);
1565
+ }
1566
+
1567
+ this.log(
1568
+ `Provisioned workflow tokens for ${result.tokens.size} agent${result.tokens.size === 1 ? '' : 's'}`
1569
+ );
1570
+ }
1571
+
1141
1572
  private getRelaycastBaseUrl(): string {
1142
1573
  return (
1143
1574
  this.relayOptions.env?.RELAYCAST_BASE_URL ??
@@ -1248,11 +1679,34 @@ export class WorkflowRunner {
1248
1679
  parseYamlString(raw: string, source = '<string>'): RelayYamlConfig {
1249
1680
  const parsed = parseYaml(raw);
1250
1681
  this.validateConfig(parsed, source);
1251
- const config = parsed as RelayYamlConfig;
1682
+ const config = this.normalizeLegacyPermissionConfig(parsed as RelayYamlConfig);
1252
1683
  config.agents ??= [];
1253
1684
  return config;
1254
1685
  }
1255
1686
 
1687
+ private normalizeLegacyPermissionConfig(config: RelayYamlConfig): RelayYamlConfig {
1688
+ const legacyPermissions = (
1689
+ config as RelayYamlConfig & {
1690
+ permissions?: { profiles?: RelayYamlConfig['permission_profiles'] };
1691
+ }
1692
+ ).permissions;
1693
+
1694
+ if (
1695
+ config.permission_profiles === undefined &&
1696
+ legacyPermissions &&
1697
+ typeof legacyPermissions === 'object' &&
1698
+ legacyPermissions.profiles &&
1699
+ typeof legacyPermissions.profiles === 'object'
1700
+ ) {
1701
+ return {
1702
+ ...config,
1703
+ permission_profiles: legacyPermissions.profiles,
1704
+ };
1705
+ }
1706
+
1707
+ return config;
1708
+ }
1709
+
1256
1710
  /** Validate a config object against the RelayYamlConfig shape. */
1257
1711
  validateConfig(config: unknown, source = '<config>'): asserts config is RelayYamlConfig {
1258
1712
  if (typeof config !== 'object' || config === null) {
@@ -1277,6 +1731,37 @@ export class WorkflowRunner {
1277
1731
  if (c.agents !== undefined && !Array.isArray(c.agents)) {
1278
1732
  throw new Error(`${source}: "agents" must be an array when provided`);
1279
1733
  }
1734
+ const legacyPermissions = c.permissions;
1735
+ if (
1736
+ legacyPermissions !== undefined &&
1737
+ (typeof legacyPermissions !== 'object' ||
1738
+ legacyPermissions === null ||
1739
+ Array.isArray(legacyPermissions))
1740
+ ) {
1741
+ throw new Error(`${source}: "permissions" must be an object when provided`);
1742
+ }
1743
+ if (
1744
+ c.permission_profiles !== undefined &&
1745
+ (typeof c.permission_profiles !== 'object' ||
1746
+ c.permission_profiles === null ||
1747
+ Array.isArray(c.permission_profiles))
1748
+ ) {
1749
+ throw new Error(`${source}: "permission_profiles" must be an object when provided`);
1750
+ }
1751
+ if (
1752
+ c.permission_profiles === undefined &&
1753
+ legacyPermissions !== undefined &&
1754
+ typeof legacyPermissions === 'object' &&
1755
+ legacyPermissions !== null
1756
+ ) {
1757
+ const profiles = (legacyPermissions as Record<string, unknown>).profiles;
1758
+ if (
1759
+ profiles !== undefined &&
1760
+ (typeof profiles !== 'object' || profiles === null || Array.isArray(profiles))
1761
+ ) {
1762
+ throw new Error(`${source}: "permissions.profiles" must be an object when provided`);
1763
+ }
1764
+ }
1280
1765
 
1281
1766
  for (const agent of c.agents ?? []) {
1282
1767
  if (typeof agent !== 'object' || agent === null) {
@@ -1316,6 +1801,7 @@ export class WorkflowRunner {
1316
1801
  try {
1317
1802
  this.validateConfig(config);
1318
1803
  resolved = vars ? this.resolveVariables(config, vars) : config;
1804
+ resolved = this.applyPermissionProfiles(resolved);
1319
1805
  } catch (err) {
1320
1806
  errors.push(err instanceof Error ? err.message : String(err));
1321
1807
  return {
@@ -1331,7 +1817,11 @@ export class WorkflowRunner {
1331
1817
  };
1332
1818
  }
1333
1819
 
1334
- // 1b. Resolve and validate named paths
1820
+ // 1b. Validate permissions and resolve named paths
1821
+ const permissionResult = this.validatePermissions(resolved.agents, resolved.permission_profiles);
1822
+ errors.push(...permissionResult.errors);
1823
+ warnings.push(...permissionResult.warnings);
1824
+
1335
1825
  const pathResult = this.resolvePathDefinitions(resolved.paths, this.cwd);
1336
1826
  errors.push(...pathResult.errors);
1337
1827
  warnings.push(...pathResult.warnings);
@@ -1520,6 +2010,29 @@ export class WorkflowRunner {
1520
2010
  }
1521
2011
  }
1522
2012
 
2013
+ const permissions = resolved.agents.map((agent) => {
2014
+ const compiled = resolveAgentPermissions(agent.name, agent.permissions, this.cwd, this.workspaceId);
2015
+ const source: NonNullable<DryRunReport['permissions']>[number]['source'] = compiled.sources.some(
2016
+ (entry) => entry.type === 'yaml'
2017
+ )
2018
+ ? 'yaml'
2019
+ : compiled.sources.some((entry) => entry.type === 'preset')
2020
+ ? 'preset'
2021
+ : compiled.sources.some((entry) => entry.type === 'dotfile')
2022
+ ? 'dotfiles'
2023
+ : 'none';
2024
+
2025
+ return {
2026
+ agent: agent.name,
2027
+ access: compiled.effectiveAccess,
2028
+ readPaths: compiled.summary.readonly,
2029
+ writePaths: compiled.summary.readwrite,
2030
+ denyPaths: compiled.summary.denied,
2031
+ scopes: compiled.scopes.length,
2032
+ source,
2033
+ };
2034
+ });
2035
+
1523
2036
  // 4. Build agent summary
1524
2037
  const agents = resolved.agents.map((a) => ({
1525
2038
  name: a.name,
@@ -1590,6 +2103,7 @@ export class WorkflowRunner {
1590
2103
  description: workflow.description ?? resolved.description,
1591
2104
  pattern: resolved.swarm.pattern,
1592
2105
  agents,
2106
+ permissions,
1593
2107
  waves,
1594
2108
  totalSteps: workflow.steps.length,
1595
2109
  maxConcurrency,
@@ -1900,11 +2414,19 @@ export class WorkflowRunner {
1900
2414
  this.abortController = new AbortController();
1901
2415
  this.paused = false;
1902
2416
 
1903
- const resolved = vars ? this.resolveVariables(config, vars) : config;
2417
+ const resolved = this.applyPermissionProfiles(vars ? this.resolveVariables(config, vars) : config);
1904
2418
 
1905
2419
  // Validate config (catches cycles, missing deps, invalid steps, etc.)
1906
2420
  this.validateConfig(resolved);
1907
2421
 
2422
+ const permissionResult = this.validatePermissions(resolved.agents, resolved.permission_profiles);
2423
+ if (permissionResult.errors.length > 0) {
2424
+ throw new Error(`Permission validation failed:\n ${permissionResult.errors.join('\n ')}`);
2425
+ }
2426
+ for (const warning of permissionResult.warnings) {
2427
+ console.warn(`[WorkflowRunner] Warning: ${warning}`);
2428
+ }
2429
+
1908
2430
  // Resolve and validate named paths from the top-level `paths` config
1909
2431
  const pathResult = this.resolvePathDefinitions(resolved.paths, this.cwd);
1910
2432
  if (pathResult.errors.length > 0) {
@@ -2403,6 +2925,8 @@ export class WorkflowRunner {
2403
2925
  await this.runPreflightChecks(workflow.preflight, runId);
2404
2926
  }
2405
2927
 
2928
+ await this.provisionAgents(config);
2929
+
2406
2930
  this.log(`Executing ${workflow.steps.length} steps (pattern: ${config.swarm.pattern})`);
2407
2931
  await this.executeSteps(workflow, stepStates, agentMap, config.errorHandling, runId);
2408
2932
 
@@ -2523,6 +3047,8 @@ export class WorkflowRunner {
2523
3047
  this.currentConfig = undefined;
2524
3048
  this.currentRunId = undefined;
2525
3049
  this.activeAgentHandles.clear();
3050
+ await this.stopProvisionedMounts();
3051
+ this.agentTokens.clear(); // Prevent workflow-scoped tokens from leaking into a later run.
2526
3052
  }
2527
3053
 
2528
3054
  const finalRun = await this.db.getRun(runId);
@@ -4977,6 +5503,25 @@ export class WorkflowRunner {
4977
5503
 
4978
5504
  const stdoutChunks: string[] = [];
4979
5505
  const stderrChunks: string[] = [];
5506
+ const env = { ...(this.getRelayEnv() ?? filteredEnv()) };
5507
+ const agentToken = this.agentTokens.get(agentDef.name);
5508
+ const mount = this.agentMounts.get(agentDef.name);
5509
+ if (agentToken) {
5510
+ env.RELAY_AGENT_TOKEN = agentToken;
5511
+ env.RELAYFILE_TOKEN = agentToken;
5512
+ }
5513
+ if (mount) {
5514
+ env.RELAY_WORKSPACE = mount.mountPoint;
5515
+ env.RELAY_AGENT_NAME = agentName;
5516
+ env.RELAYFILE_WORKSPACE = this.workspaceId;
5517
+ env.RELAY_WORKSPACE_ID = this.workspaceId;
5518
+ env.RELAY_DEFAULT_WORKSPACE = this.workspaceId;
5519
+ }
5520
+ env.RELAYFILE_BASE_URL =
5521
+ env.RELAYFILE_BASE_URL ??
5522
+ this.getRelayEnv()?.RELAYFILE_BASE_URL ??
5523
+ process.env.RELAYFILE_BASE_URL ??
5524
+ 'http://127.0.0.1:8080';
4980
5525
 
4981
5526
  try {
4982
5527
  const {
@@ -4984,10 +5529,17 @@ export class WorkflowRunner {
4984
5529
  exitCode,
4985
5530
  exitSignal,
4986
5531
  } = await new Promise<{ stdout: string; exitCode?: number; exitSignal?: string }>((resolve, reject) => {
5532
+ const spawnEnv =
5533
+ agentDef.cli === 'opencode'
5534
+ ? {
5535
+ ...env,
5536
+ OPENCODE_PERMISSION: JSON.stringify({ '*': 'allow', external_directory: { '*': 'allow' } }),
5537
+ }
5538
+ : env;
4987
5539
  const child = spawnProcess([cmd, ...args], {
4988
5540
  stdio: ['ignore', 'pipe', 'pipe'],
4989
- cwd: this.resolveEffectiveCwd(step, agentDef),
4990
- env: this.getRelayEnv() ?? filteredEnv(),
5541
+ cwd: this.resolveExecutionCwd(step, agentDef),
5542
+ env: spawnEnv,
4991
5543
  });
4992
5544
 
4993
5545
  // Update workers.json with PID now that we have it
@@ -5189,7 +5741,7 @@ export class WorkflowRunner {
5189
5741
  let ptyChunks: string[] = [];
5190
5742
 
5191
5743
  try {
5192
- const agentCwd = this.resolveAgentCwd(agentDef);
5744
+ const agentCwd = this.resolveExecutionCwd(step, agentDef);
5193
5745
  const interactiveSpawnPolicy = resolveSpawnPolicy({
5194
5746
  AGENT_NAME: agentName,
5195
5747
  AGENT_CLI: agentDef.cli,
@@ -5205,6 +5757,7 @@ export class WorkflowRunner {
5205
5757
  task: preparedTask.spawnTaskText,
5206
5758
  idleThresholdSecs: agentDef.constraints?.idleThresholdSecs,
5207
5759
  cwd: agentCwd,
5760
+ agentToken: this.agentTokens.get(agentDef.name),
5208
5761
  });
5209
5762
 
5210
5763
  // Re-key PTY maps if broker assigned a different name than requested