maf 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +44 -0
- data/README.md +4 -0
- data/assets/coord +60 -8
- data/assets/dispatcher +82 -5
- data/assets/steward +398 -0
- data/lib/maf/backup.rb +63 -0
- data/lib/maf/bootstrap/installer.rb +2 -2
- data/lib/maf/bootstrap/script_planner.rb +4 -0
- data/lib/maf/bootstrap.rb +1 -0
- data/lib/maf/budget.rb +2 -0
- data/lib/maf/cli.rb +24 -5
- data/lib/maf/flow/prompt_text.rb +22 -4
- data/lib/maf/op_command.rb +28 -0
- data/lib/maf/restore.rb +62 -0
- data/lib/maf/session_records.rb +39 -0
- data/lib/maf/setup_agent/steward.rb +18 -0
- data/lib/maf/setup_agent.rb +2 -0
- data/lib/maf/shared/flow_files.rb +33 -0
- data/lib/maf/shared/sandbox/paths.rb +10 -2
- data/lib/maf/shared/sandbox/seatbelt.rb +23 -2
- data/lib/maf/shared/sandbox.rb +7 -1
- data/lib/maf/team.rb +10 -2
- data/lib/maf/team_command.rb +8 -1
- data/lib/maf/uninstall/scripts.rb +1 -0
- data/lib/maf/untrack.rb +17 -1
- data/lib/maf/version.rb +1 -1
- data/lib/maf/worker_control.rb +12 -2
- data/lib/maf/worker_list.rb +2 -13
- metadata +8 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9d59c62e5b37a338b1da8b57db74bfb670bb735ca4fa90059d6b874a18ffd25e
|
|
4
|
+
data.tar.gz: 2c33e35cec6ad395fc539a4bc5ef3813a2e73fdcdb17464a247dbfdf1111ec0d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1fe86c50be42049d5dedef7e561c6e042469d7686828bc7932931d63cece3f6fcb54e516e08c2fef48792d5fd86aa4e16932d7e5d3e7e7ae72dd1bc9f6a0f72b
|
|
7
|
+
data.tar.gz: 19f1244d7243c253c6f1852fd1376028e25c9e67d0bd249cb0e3fc44f25dd88b7b293ab466eb670d5040faec8e48c87fe00a8cb1a1cc02a81677a0eff37f9742
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,50 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.5.0] - 2026-10-22
|
|
6
|
+
|
|
7
|
+
- Fix: in the macOS sandbox, headless Chrome aborted at start, and macOS showed a crash dialog
|
|
8
|
+
for each try. New `.maf/sandbox.json` key `launch_services` lists the apps that can use
|
|
9
|
+
LaunchServices. After each run, the dispatcher reports apps that the run started and that
|
|
10
|
+
crashed, and LaunchServices denials, once for each app to the project manager.
|
|
11
|
+
- Fix: after Ctrl+C or SIGHUP, the dispatcher stopped, but the agent run continued in its own
|
|
12
|
+
session. The dispatcher now stops the process group of the agent first.
|
|
13
|
+
- New: `steward`, a daemon outside the sandbox that runs a fixed list of host operations for the
|
|
14
|
+
leads: `worker-restart`, `worker-retire`, `worktree-sync`, `goal-sync`, `runtime-restore`. An
|
|
15
|
+
agent asks with `coord op request OPERATION [ARGUMENT]`. steward checks each argument, runs no
|
|
16
|
+
shell, allows 3 runs per hour for one argument, and sends the result to the inbox of the role
|
|
17
|
+
that asked. `maf start` starts steward. The project manager and the architect prompts list the
|
|
18
|
+
operations. `maf update` installs `.maf/bin/steward`.
|
|
19
|
+
- New: operations that wait for the user: `worker-start`, `worker-model WORKER MODEL`, and
|
|
20
|
+
`bundle-install` (frozen to `Gemfile.lock`). `maf op list|approve ID|deny ID` handles them.
|
|
21
|
+
`.maf/sandbox.json` key `ops` sets the level of each operation: `auto`, `approve`, or `off`.
|
|
22
|
+
- After `maf untrack`, maf moves the old branches. `maf start` fast-forwards a worker branch
|
|
23
|
+
also when the only changes are in flow files that the base branch no longer tracks: maf
|
|
24
|
+
discards them first. `maf update` runs the new `coord goal migrate`, which merges the base
|
|
25
|
+
branch into each goal branch that still tracks the flow files. Before, the user did these
|
|
26
|
+
steps by hand, because the sandbox blocks an agent from deleting the flow files.
|
|
27
|
+
- Fix: `maf worker stop` did nothing for a session that `maf worker list` showed as running.
|
|
28
|
+
`stop` read only the presence file, and that file can name an older session. `stop`, `start`,
|
|
29
|
+
and `status` now find the session from the records of `maf start`, as `list` does.
|
|
30
|
+
- `maf worker stop` reports a session that does not stop after TERM, and prints the `kill -9`
|
|
31
|
+
command. A session in a closed terminal can ignore TERM.
|
|
32
|
+
|
|
33
|
+
## [0.4.0] - 2026-10-21
|
|
34
|
+
|
|
35
|
+
- Fix: in the macOS sandbox, a worker that the project manager started failed on each run with
|
|
36
|
+
`sandbox_apply: Operation not permitted`. macOS cannot start a sandbox inside a sandbox. The
|
|
37
|
+
sandbox now sets `MAF_SEATBELT=1`. In the sandbox, `maf prepare --dispatch` prepares the worker
|
|
38
|
+
and prints the start commands, and a dispatcher stops at once with the reason in its log.
|
|
39
|
+
- The project manager no longer adds workers on its own. Without a team budget, `maf prepare`
|
|
40
|
+
refuses when an agent runs it. Only the user sets the budget: `maf team set` refuses when an
|
|
41
|
+
agent runs it.
|
|
42
|
+
- Backup of the flow config. Each maf command copies `.maf/config.json`, `roles.yml`,
|
|
43
|
+
`workflow.md`, and `sandbox.json` to `.git/maf/backup/` when they changed, and keeps 10
|
|
44
|
+
copies. New command `maf restore [--list] [BACKUP] [--force]` puts them back and runs
|
|
45
|
+
`maf update`. When `.maf/` exists without `config.json`, maf names the backup. Before, a
|
|
46
|
+
merge of the `maf untrack` commit into a branch that still tracked the flow deleted the
|
|
47
|
+
config, and the agents, `base_branch`, and `github` settings were lost.
|
|
48
|
+
|
|
5
49
|
## [0.3.0] - 2026-10-21
|
|
6
50
|
|
|
7
51
|
- Fix: each dispatched Hermes run failed. Hermes 0.22 refuses `--run-budget` with `--format
|
data/README.md
CHANGED
|
@@ -192,6 +192,8 @@ multi_agent_flow/
|
|
|
192
192
|
flow/workflow.rb # reads .maf/workflow.md for the architect prompt
|
|
193
193
|
flow/mcp_config.rb # writes the graphify MCP server into .maf/mcp/
|
|
194
194
|
untrack.rb # maf untrack: removes an older install from git
|
|
195
|
+
backup.rb # copies of the flow config in .git/maf/backup
|
|
196
|
+
restore.rb # maf restore: puts the flow config back from a backup
|
|
195
197
|
local_exclude.rb # the flow block in .git/info/exclude
|
|
196
198
|
shared/ # stdlib-only code that the maf CLI and the scripts share (installed as .maf/lib/maf/shared/)
|
|
197
199
|
shared/harness/ # the harness registry, the default of each harness method, shared modules
|
|
@@ -234,6 +236,7 @@ multi_agent_flow/
|
|
|
234
236
|
worker_control_test.rb # tests for maf worker
|
|
235
237
|
worker_list_test.rb # tests for maf worker list
|
|
236
238
|
untrack_test.rb # tests for maf untrack
|
|
239
|
+
backup_test.rb # tests for the backup and maf restore
|
|
237
240
|
analyst_test.rb # tests for the analyst
|
|
238
241
|
shared_test.rb # tests for lib/maf/shared/
|
|
239
242
|
gemspec_test.rb # tests that the gem holds every runtime file
|
|
@@ -371,6 +374,7 @@ ruby test/context_watch_test.rb # covers the context-watch hook
|
|
|
371
374
|
ruby test/worker_control_test.rb # covers maf worker
|
|
372
375
|
ruby test/worker_list_test.rb # covers maf worker list
|
|
373
376
|
ruby test/untrack_test.rb # covers maf untrack
|
|
377
|
+
ruby test/backup_test.rb # covers the backup and maf restore
|
|
374
378
|
ruby test/analyst_test.rb # covers the analyst
|
|
375
379
|
ruby test/shared_test.rb # covers lib/maf/shared/
|
|
376
380
|
```
|
data/assets/coord
CHANGED
|
@@ -25,6 +25,7 @@ begin
|
|
|
25
25
|
require_relative "../lib/maf/shared/git_exclude"
|
|
26
26
|
require_relative "../lib/maf/shared/git_identity"
|
|
27
27
|
require_relative "../lib/maf/shared/harness"
|
|
28
|
+
require_relative "../lib/maf/shared/flow_files"
|
|
28
29
|
rescue LoadError
|
|
29
30
|
abort "coord: the maf shared library is missing. Run maf update."
|
|
30
31
|
end
|
|
@@ -72,6 +73,7 @@ module Coord
|
|
|
72
73
|
coord goal list list open goals
|
|
73
74
|
coord goal show ID show a goal and its tasks
|
|
74
75
|
coord goal sync ID merge the base branch into the goal branch
|
|
76
|
+
coord goal migrate sync each goal branch that still tracks the flow files (after maf untrack)
|
|
75
77
|
coord goal done ID close a goal (refused while a task is open or the goal lacks the base head)
|
|
76
78
|
coord goal pr ID push the goal branch as the GitHub bot and open or update its pull request
|
|
77
79
|
coord review-watch [--once] [--interval S]
|
|
@@ -97,6 +99,10 @@ module Coord
|
|
|
97
99
|
coord lesson ID dead_end|corrected TEXT...
|
|
98
100
|
save a lesson of a task to the graph memory: an approach that
|
|
99
101
|
failed (dead_end), or the right way (corrected)
|
|
102
|
+
coord op request OPERATION [ARGUMENT...] ask steward for a host operation; the result comes to your inbox.
|
|
103
|
+
auto: worker-restart W, worker-retire W, worktree-sync W, goal-sync ID,
|
|
104
|
+
runtime-restore. The user approves: worker-start W, worker-model W MODEL,
|
|
105
|
+
bundle-install
|
|
100
106
|
coord msg [--from A] [--task ID] [--fyi] TO TEXT...
|
|
101
107
|
TO is a role or a worker; a worker's mail goes to its role inbox.
|
|
102
108
|
--task also saves the text as a note on the task.
|
|
@@ -134,7 +140,7 @@ module Coord
|
|
|
134
140
|
"land" => :cmd_land, "gc" => :cmd_gc, "reap" => :cmd_reap, "review-watch" => :cmd_review_watch,
|
|
135
141
|
"show" => :cmd_show, "escalate" => :cmd_escalate,
|
|
136
142
|
"status" => :cmd_status, "board" => :cmd_board, "export" => :cmd_export,
|
|
137
|
-
"who" => :cmd_who, "help" => :cmd_help
|
|
143
|
+
"who" => :cmd_who, "op" => :cmd_op, "help" => :cmd_help
|
|
138
144
|
}.freeze
|
|
139
145
|
|
|
140
146
|
ADD_OPTS = { "--role" => :role, "--scope" => :scope, "--ref" => :ref,
|
|
@@ -1260,8 +1266,10 @@ end
|
|
|
1260
1266
|
goal
|
|
1261
1267
|
end
|
|
1262
1268
|
|
|
1269
|
+
def open = @tasks.pending.select { |task| task["role"] == ROLE }
|
|
1270
|
+
|
|
1263
1271
|
def list
|
|
1264
|
-
goals =
|
|
1272
|
+
goals = open
|
|
1265
1273
|
return puts("no open goals") if goals.empty?
|
|
1266
1274
|
|
|
1267
1275
|
goals.each { |goal| puts goal_line(goal) }
|
|
@@ -1332,7 +1340,7 @@ end
|
|
|
1332
1340
|
paths = Git.changed(@dir)
|
|
1333
1341
|
return if paths.empty?
|
|
1334
1342
|
|
|
1335
|
-
flow = paths.all? { |path| path
|
|
1343
|
+
flow = paths.all? { |path| Maf::Shared::FlowFiles.path?(path) || Worktree::RUNTIME_FILES.include?(path) }
|
|
1336
1344
|
raise Error, ["uncommitted changes in #{@dir}: #{paths.first(5).join(", ")}#{" ..." if paths.size > 5}.",
|
|
1337
1345
|
flow ? FLOW_FILES : "Commit or stash them first."].join(" ")
|
|
1338
1346
|
end
|
|
@@ -1496,11 +1504,13 @@ end
|
|
|
1496
1504
|
|
|
1497
1505
|
# A missing base ref has no head to lack.
|
|
1498
1506
|
def behind? = Git.run(@root, "rev-parse", "-q", "--verify", ref) && !Git.ancestor?(@root, ref, @branch)
|
|
1507
|
+
def untracked_upstream? = Dir.exist?(@dir) && Maf::Shared::FlowFiles.tracked?(@dir) && !flow_in_ref?
|
|
1508
|
+
def flow_in_ref? = !Git.out(@root, "ls-tree", "--name-only", ref, "--", ".maf").empty?
|
|
1499
1509
|
def ref = @ref ||= Git.base_ref(@root, @base)
|
|
1500
1510
|
|
|
1501
1511
|
def run
|
|
1502
1512
|
return puts("#{@branch} already contains #{ref}") unless behind?
|
|
1503
|
-
raise Error, "uncommitted changes in #{@dir}. Commit or stash them first." unless
|
|
1513
|
+
raise Error, "uncommitted changes in #{@dir}. Commit or stash them first." unless clean?
|
|
1504
1514
|
|
|
1505
1515
|
merge
|
|
1506
1516
|
puts "merged #{ref} into #{@branch}"
|
|
@@ -1508,6 +1518,8 @@ end
|
|
|
1508
1518
|
|
|
1509
1519
|
private
|
|
1510
1520
|
|
|
1521
|
+
def clean? = Maf::Shared::FlowFiles.discard(@dir, ref) && Git.clean?(@dir)
|
|
1522
|
+
|
|
1511
1523
|
def merge
|
|
1512
1524
|
return if Git.run(@dir, "merge", "-q", "--no-edit", ref)
|
|
1513
1525
|
|
|
@@ -1945,14 +1957,18 @@ end
|
|
|
1945
1957
|
|
|
1946
1958
|
private
|
|
1947
1959
|
|
|
1948
|
-
|
|
1960
|
+
# The flow files are discarded last, only when the fast-forward can run.
|
|
1961
|
+
def checks = ready_checks + [[-> { clean? }, "the worktree has uncommitted changes"]]
|
|
1962
|
+
|
|
1963
|
+
def ready_checks
|
|
1949
1964
|
[[-> { head == @branch }, "#{@branch} is not checked out"],
|
|
1950
|
-
[-> { Git.clean?(@dir) }, "the worktree has uncommitted changes"],
|
|
1951
1965
|
[-> { Git.run(@dir, "fetch", "-q", "origin", @base) }, "git fetch origin #{@base} failed"],
|
|
1952
1966
|
[-> { Git.run(@dir, "merge-base", "--is-ancestor", "HEAD", target) },
|
|
1953
1967
|
"#{@branch} has commits that #{target} lacks"]]
|
|
1954
1968
|
end
|
|
1955
1969
|
|
|
1970
|
+
def clean? = Maf::Shared::FlowFiles.discard(@dir, target) && Git.clean?(@dir)
|
|
1971
|
+
|
|
1956
1972
|
def merge
|
|
1957
1973
|
return if rev("HEAD") == rev(target)
|
|
1958
1974
|
|
|
@@ -2183,6 +2199,7 @@ end
|
|
|
2183
2199
|
def shift = @argv.shift
|
|
2184
2200
|
def first = @argv.first
|
|
2185
2201
|
def empty? = @argv.empty?
|
|
2202
|
+
def rest = @argv.shift(@argv.size)
|
|
2186
2203
|
def join = @argv.join(" ")
|
|
2187
2204
|
|
|
2188
2205
|
# Splits argv at the first literal "--" and returns the words after it,
|
|
@@ -2669,11 +2686,11 @@ end
|
|
|
2669
2686
|
# Goal commands create, sync, land, and close goals.
|
|
2670
2687
|
module GoalCommands
|
|
2671
2688
|
GOAL_COMMANDS = { "add" => :goal_add, "list" => :goal_list, "show" => :goal_show, "done" => :goal_done,
|
|
2672
|
-
"sync" => :goal_sync, "pr" => :goal_pr }.freeze
|
|
2689
|
+
"sync" => :goal_sync, "migrate" => :goal_migrate, "pr" => :goal_pr }.freeze
|
|
2673
2690
|
|
|
2674
2691
|
def cmd_goal
|
|
2675
2692
|
sub = @args.shift
|
|
2676
|
-
abort "usage: coord goal add|list|show|sync|pr|done" unless GOAL_COMMANDS.key?(sub)
|
|
2693
|
+
abort "usage: coord goal add|list|show|sync|migrate|pr|done" unless GOAL_COMMANDS.key?(sub)
|
|
2677
2694
|
|
|
2678
2695
|
send(GOAL_COMMANDS[sub])
|
|
2679
2696
|
end
|
|
@@ -2713,6 +2730,20 @@ end
|
|
|
2713
2730
|
event_log.append("goal-sync", @worker, goal["description"])
|
|
2714
2731
|
end
|
|
2715
2732
|
|
|
2733
|
+
def goal_migrate = goals.open.each { |goal| migrate_goal(goal) }
|
|
2734
|
+
|
|
2735
|
+
def migrate_goal(goal)
|
|
2736
|
+
sync = goal_sync_for(goal)
|
|
2737
|
+
sync.untracked_upstream? && run_migration(sync, goal)
|
|
2738
|
+
end
|
|
2739
|
+
|
|
2740
|
+
def run_migration(sync, goal)
|
|
2741
|
+
sync.run
|
|
2742
|
+
event_log.append("goal-sync", @worker, goal["description"])
|
|
2743
|
+
rescue Error => e
|
|
2744
|
+
warn "coord: #{e.message}"
|
|
2745
|
+
end
|
|
2746
|
+
|
|
2716
2747
|
def goal_sync_for(goal) = GoalSync.new(main_root, goal["uuid"], Git.base_branch(main_root, manifest))
|
|
2717
2748
|
|
|
2718
2749
|
def goal_done
|
|
@@ -2890,6 +2921,26 @@ end
|
|
|
2890
2921
|
end
|
|
2891
2922
|
end
|
|
2892
2923
|
|
|
2924
|
+
# OpCommands writes a request for steward. steward validates and runs it on the host.
|
|
2925
|
+
module OpCommands
|
|
2926
|
+
OP_USAGE = "usage: coord op request OPERATION [ARGUMENT...]"
|
|
2927
|
+
|
|
2928
|
+
def cmd_op
|
|
2929
|
+
abort OP_USAGE unless @args.shift == "request" && (op = @args.shift)
|
|
2930
|
+
|
|
2931
|
+
id = "#{Time.now.utc.strftime("%Y%m%dT%H%M%SZ")}-#{format("%06x", rand(0xffffff))}"
|
|
2932
|
+
write_op(id, "id" => id, "op" => op, "args" => @args.rest, "role" => @role, "worker" => @worker)
|
|
2933
|
+
puts "op #{id} requested. steward sends the result to the #{@role} inbox."
|
|
2934
|
+
end
|
|
2935
|
+
|
|
2936
|
+
def write_op(id, request)
|
|
2937
|
+
dir = File.join(paths.coord_dir, "ops", "requests")
|
|
2938
|
+
FileUtils.mkdir_p(dir)
|
|
2939
|
+
File.write(File.join(dir, "#{id}.json"), JSON.generate(request.merge("at" => Time.now.utc.iso8601)))
|
|
2940
|
+
event_log.append("op-request", @worker, [request["op"], *request["args"]].join(" "))
|
|
2941
|
+
end
|
|
2942
|
+
end
|
|
2943
|
+
|
|
2893
2944
|
# Upkeep commands handle locks, the event log, worktrees, and cleanup.
|
|
2894
2945
|
module UpkeepCommands
|
|
2895
2946
|
def cmd_lock
|
|
@@ -3029,6 +3080,7 @@ end
|
|
|
3029
3080
|
include GoalCommands
|
|
3030
3081
|
include ReviewCommands
|
|
3031
3082
|
include UpkeepCommands
|
|
3083
|
+
include OpCommands
|
|
3032
3084
|
include CommandHelpers
|
|
3033
3085
|
include Wiring
|
|
3034
3086
|
|
data/assets/dispatcher
CHANGED
|
@@ -137,6 +137,7 @@ require "fileutils"
|
|
|
137
137
|
require "optparse"
|
|
138
138
|
require "rbconfig"
|
|
139
139
|
require "shellwords"
|
|
140
|
+
require "time"
|
|
140
141
|
begin
|
|
141
142
|
require_relative "../lib/maf/shared/processes"
|
|
142
143
|
require_relative "../lib/maf/shared/project"
|
|
@@ -175,6 +176,13 @@ module Dispatcher
|
|
|
175
176
|
end
|
|
176
177
|
end
|
|
177
178
|
|
|
179
|
+
def self.refuse_nested
|
|
180
|
+
return unless Maf::Shared::Sandbox.nested?
|
|
181
|
+
|
|
182
|
+
Log.line(Maf::Shared::Sandbox::NESTED.delete_prefix("maf: "))
|
|
183
|
+
exit 1
|
|
184
|
+
end
|
|
185
|
+
|
|
178
186
|
# Project finds the main checkout from any worktree. Lead roles own no
|
|
179
187
|
# tasks: they get an inbox prompt and no task polling.
|
|
180
188
|
Project = Maf::Shared::Project
|
|
@@ -683,8 +691,10 @@ module Dispatcher
|
|
|
683
691
|
|
|
684
692
|
# Spawn runs a command in its own process group. Any end reason except a
|
|
685
693
|
# normal exit kills the whole group (TERM, then KILL), which also stops the
|
|
686
|
-
# children of an `sh -c` template.
|
|
687
|
-
# the
|
|
694
|
+
# children of an `sh -c` template. An exception in the dispatcher (Ctrl+C,
|
|
695
|
+
# SIGHUP) also kills the group, so no agent run outlives the dispatcher.
|
|
696
|
+
# A completion signal is success even when the process must be killed.
|
|
697
|
+
# An abort signal is failure.
|
|
688
698
|
module Spawn
|
|
689
699
|
GRACE = 5
|
|
690
700
|
TIMEOUTS = %i[timeout idle].freeze
|
|
@@ -717,6 +727,8 @@ module Dispatcher
|
|
|
717
727
|
reason = watch.wait(waiter, stream)
|
|
718
728
|
stop(pid, waiter) unless reason == :exit
|
|
719
729
|
result(stream.close(watch.grace), waiter, watch, reason)
|
|
730
|
+
ensure
|
|
731
|
+
stop(pid, waiter) if waiter&.alive?
|
|
720
732
|
end
|
|
721
733
|
|
|
722
734
|
def self.result(output, waiter, watch, reason)
|
|
@@ -734,6 +746,71 @@ module Dispatcher
|
|
|
734
746
|
end
|
|
735
747
|
end
|
|
736
748
|
|
|
749
|
+
# SandboxCrashes reports, once per app, a program that a run started in the
|
|
750
|
+
# macOS sandbox and that crashed, or that the sandbox refused LaunchServices.
|
|
751
|
+
# macOS shows a crash dialog for each crash, so the user must learn the cause.
|
|
752
|
+
module SandboxCrashes
|
|
753
|
+
REPORTS = File.expand_path("~/Library/Logs/DiagnosticReports")
|
|
754
|
+
DENIAL = /Sandbox: (.+?)\(\d+\) deny\(\d+\) mach-lookup com\.apple\.coreservices\.launchservicesd/
|
|
755
|
+
PREDICATE = 'sender == "Sandbox" AND eventMessage CONTAINS "com.apple.coreservices.launchservicesd"'
|
|
756
|
+
NOTICE = "Dispatcher of %<worker>s: in the macOS sandbox, %<found>s. If the project needs the app, " \
|
|
757
|
+
"the user adds it to \"launch_services\" in .maf/sandbox.json. Then the app can start other " \
|
|
758
|
+
"apps outside the sandbox. An app with its own sandbox (Chrome) also needs it off (--no-sandbox), " \
|
|
759
|
+
"because macOS cannot start a sandbox in a sandbox. Tell the user."
|
|
760
|
+
|
|
761
|
+
@seen = []
|
|
762
|
+
|
|
763
|
+
def self.guard(harness, config, env, &block)
|
|
764
|
+
Maf::Shared::Sandbox.keeping_links(harness, coord_dir: config.coord_dir) { watch(config, env, &block) }
|
|
765
|
+
end
|
|
766
|
+
|
|
767
|
+
def self.watch(config, env, reports: REPORTS)
|
|
768
|
+
return yield unless Maf::Shared::Sandbox.mode == "seatbelt"
|
|
769
|
+
|
|
770
|
+
start = Time.now
|
|
771
|
+
yield.tap { report(config, env, crashes(start, reports), denials(start)) }
|
|
772
|
+
end
|
|
773
|
+
|
|
774
|
+
def self.report(config, env, crashes, denied)
|
|
775
|
+
crashes = crashes.except(*@seen)
|
|
776
|
+
denied -= @seen
|
|
777
|
+
return if crashes.empty? && denied.empty?
|
|
778
|
+
|
|
779
|
+
@seen.concat(crashes.keys, denied)
|
|
780
|
+
text = format(NOTICE, worker: config.worker, found: found(crashes, denied))
|
|
781
|
+
Log.line(text)
|
|
782
|
+
CoordCall.run(env, "escalate", text)
|
|
783
|
+
end
|
|
784
|
+
|
|
785
|
+
def self.found(crashes, denied)
|
|
786
|
+
parts = []
|
|
787
|
+
parts << "these apps crashed: #{crashes.map { |app, count| "#{app} (#{count}x)" }.join(", ")}" if crashes.any?
|
|
788
|
+
parts << "the sandbox refused LaunchServices to #{denied.join(", ")}" if denied.any?
|
|
789
|
+
parts.join("; ")
|
|
790
|
+
end
|
|
791
|
+
|
|
792
|
+
def self.crashes(start, reports)
|
|
793
|
+
files = Dir.glob(File.join(reports, "*.ips")).select { |path| File.mtime(path) >= start - 1 }
|
|
794
|
+
files.filter_map { |path| crashed_app(path, start) }.tally
|
|
795
|
+
end
|
|
796
|
+
|
|
797
|
+
def self.crashed_app(path, start)
|
|
798
|
+
body = JSON.parse(File.read(path).split("\n", 2).last.to_s)
|
|
799
|
+
return unless body["procPath"] && Time.parse(body["procLaunch"].to_s) >= start - 1
|
|
800
|
+
|
|
801
|
+
body["procPath"][%r{\A.*?\.app(?=/)}] || body["procPath"]
|
|
802
|
+
rescue SystemCallError, JSON::ParserError, ArgumentError
|
|
803
|
+
nil
|
|
804
|
+
end
|
|
805
|
+
|
|
806
|
+
def self.denials(start)
|
|
807
|
+
args = ["show", "--start", start.strftime("%Y-%m-%d %H:%M:%S"), "--style", "compact", "--predicate", PREDICATE]
|
|
808
|
+
IO.popen(["/usr/bin/log", *args], err: File::NULL, &:read).scan(DENIAL).flatten.uniq
|
|
809
|
+
rescue SystemCallError
|
|
810
|
+
[]
|
|
811
|
+
end
|
|
812
|
+
end
|
|
813
|
+
|
|
737
814
|
Events = Maf::Shared::Harness::Events
|
|
738
815
|
|
|
739
816
|
# TokenUsage adds the token usage of one run to the totals of the worker in
|
|
@@ -886,9 +963,8 @@ module Dispatcher
|
|
|
886
963
|
command = Project.manifest["verify"].to_s
|
|
887
964
|
return true if command.empty? || LEADS.include?(config.role)
|
|
888
965
|
|
|
889
|
-
sandbox = Maf::Shared::Sandbox
|
|
890
966
|
run = -> { Spawn.run(sandboxed(command, config), env: env, timeout: config.timeout) }
|
|
891
|
-
check(
|
|
967
|
+
check(SandboxCrashes.guard(Harness::Generic, config, env, &run), command)
|
|
892
968
|
end
|
|
893
969
|
|
|
894
970
|
def self.sandboxed(command, config)
|
|
@@ -1089,7 +1165,7 @@ module Dispatcher
|
|
|
1089
1165
|
end
|
|
1090
1166
|
|
|
1091
1167
|
def sandboxed(cmd) = Maf::Shared::Sandbox.wrap(cmd, @harness, coord_dir: @config.coord_dir)
|
|
1092
|
-
def keeping_links(&block) =
|
|
1168
|
+
def keeping_links(&block) = SandboxCrashes.guard(@harness, @config, @env, &block)
|
|
1093
1169
|
|
|
1094
1170
|
def session_label(id) = id ? "#{id}, idle #{(@session.idle_seconds / 60).round}m" : "new"
|
|
1095
1171
|
|
|
@@ -1512,5 +1588,6 @@ if __FILE__ == $PROGRAM_NAME
|
|
|
1512
1588
|
ENV["PATH"] = [File.dirname(File.expand_path(__FILE__)), ENV["PATH"]].compact.join(File::PATH_SEPARATOR)
|
|
1513
1589
|
# Each run inherits the git persona of the agents.
|
|
1514
1590
|
Maf::Shared::GitIdentity.apply(Dispatcher::Project.manifest)
|
|
1591
|
+
Dispatcher.refuse_nested
|
|
1515
1592
|
Dispatcher::Main.new(Dispatcher::Options.parse(ARGV)).run
|
|
1516
1593
|
end
|