crewly 1.20.40 → 1.20.47

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 (72) hide show
  1. package/config/skills/_common/desktop-guards.sh +485 -0
  2. package/config/skills/_common/desktop-guards.test.sh +242 -0
  3. package/config/skills/_common/desktop-perceive.swift +530 -0
  4. package/config/skills/_common/desktop-presence.swift +343 -0
  5. package/config/skills/agent/_common/desktop-guards.sh +4 -0
  6. package/config/skills/agent/computer-use/SKILL.md +88 -0
  7. package/config/skills/agent/computer-use/execute.sh +249 -3
  8. package/config/skills/agent/desktop-app-control/SKILL.md +19 -0
  9. package/config/skills/agent/remote-browser/SKILL.md +19 -0
  10. package/dist/backend/backend/src/controllers/desktop/desktop.controller.d.ts +105 -0
  11. package/dist/backend/backend/src/controllers/desktop/desktop.controller.d.ts.map +1 -0
  12. package/dist/backend/backend/src/controllers/desktop/desktop.controller.js +278 -0
  13. package/dist/backend/backend/src/controllers/desktop/desktop.controller.js.map +1 -0
  14. package/dist/backend/backend/src/controllers/desktop/desktop.routes.d.ts +21 -0
  15. package/dist/backend/backend/src/controllers/desktop/desktop.routes.d.ts.map +1 -0
  16. package/dist/backend/backend/src/controllers/desktop/desktop.routes.js +31 -0
  17. package/dist/backend/backend/src/controllers/desktop/desktop.routes.js.map +1 -0
  18. package/dist/backend/backend/src/routes/api.routes.d.ts.map +1 -1
  19. package/dist/backend/backend/src/routes/api.routes.js +3 -0
  20. package/dist/backend/backend/src/routes/api.routes.js.map +1 -1
  21. package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.d.ts.map +1 -1
  22. package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.js +9 -0
  23. package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.js.map +1 -1
  24. package/dist/backend/backend/src/services/slack/slack-team-channel.service.d.ts +24 -0
  25. package/dist/backend/backend/src/services/slack/slack-team-channel.service.d.ts.map +1 -1
  26. package/dist/backend/backend/src/services/slack/slack-team-channel.service.js +60 -1
  27. package/dist/backend/backend/src/services/slack/slack-team-channel.service.js.map +1 -1
  28. package/dist/backend/backend/src/services/slack/slack.service.d.ts +17 -0
  29. package/dist/backend/backend/src/services/slack/slack.service.d.ts.map +1 -1
  30. package/dist/backend/backend/src/services/slack/slack.service.js +32 -0
  31. package/dist/backend/backend/src/services/slack/slack.service.js.map +1 -1
  32. package/dist/backend/backend/src/types/slack.types.d.ts +10 -0
  33. package/dist/backend/backend/src/types/slack.types.d.ts.map +1 -1
  34. package/dist/backend/backend/src/types/slack.types.js.map +1 -1
  35. package/dist/backend/backend/src/utils/incomplete-turn.utils.d.ts +1 -1
  36. package/dist/backend/backend/src/utils/incomplete-turn.utils.d.ts.map +1 -1
  37. package/dist/backend/backend/src/utils/incomplete-turn.utils.js +4 -0
  38. package/dist/backend/backend/src/utils/incomplete-turn.utils.js.map +1 -1
  39. package/dist/backend/build-info.json +2 -2
  40. package/dist/cli/backend/src/services/slack/slack-team-channel.service.d.ts +24 -0
  41. package/dist/cli/backend/src/services/slack/slack-team-channel.service.d.ts.map +1 -1
  42. package/dist/cli/backend/src/services/slack/slack-team-channel.service.js +60 -1
  43. package/dist/cli/backend/src/services/slack/slack-team-channel.service.js.map +1 -1
  44. package/dist/cli/backend/src/services/slack/slack.service.d.ts +17 -0
  45. package/dist/cli/backend/src/services/slack/slack.service.d.ts.map +1 -1
  46. package/dist/cli/backend/src/services/slack/slack.service.js +32 -0
  47. package/dist/cli/backend/src/services/slack/slack.service.js.map +1 -1
  48. package/dist/cli/backend/src/types/slack.types.d.ts +10 -0
  49. package/dist/cli/backend/src/types/slack.types.d.ts.map +1 -1
  50. package/dist/cli/backend/src/types/slack.types.js.map +1 -1
  51. package/dist/cli/backend/src/utils/incomplete-turn.utils.d.ts +1 -1
  52. package/dist/cli/backend/src/utils/incomplete-turn.utils.d.ts.map +1 -1
  53. package/dist/cli/backend/src/utils/incomplete-turn.utils.js +4 -0
  54. package/dist/cli/backend/src/utils/incomplete-turn.utils.js.map +1 -1
  55. package/package.json +1 -1
  56. package/packages/crewly-agent/src/eval/desktop/desktop-tasks.test.ts +96 -0
  57. package/packages/crewly-agent/src/eval/desktop/desktop-tasks.ts +226 -0
  58. package/packages/crewly-agent/src/runtime/agent-runner.service.ts +20 -1
  59. package/packages/crewly-agent/src/runtime/computer.tool.test.ts +219 -0
  60. package/packages/crewly-agent/src/runtime/computer.tool.ts +405 -0
  61. package/packages/crewly-agent/src/runtime/desktop-checkpoint.test.ts +136 -0
  62. package/packages/crewly-agent/src/runtime/desktop-checkpoint.ts +231 -0
  63. package/packages/crewly-agent/src/runtime/desktop-recovery.test.ts +100 -0
  64. package/packages/crewly-agent/src/runtime/desktop-recovery.ts +195 -0
  65. package/packages/crewly-agent/src/runtime/desktop-task-runtime.test.ts +251 -0
  66. package/packages/crewly-agent/src/runtime/desktop-task-runtime.ts +423 -0
  67. package/packages/crewly-agent/src/runtime/desktop-task.tool.test.ts +218 -0
  68. package/packages/crewly-agent/src/runtime/desktop-task.tool.ts +343 -0
  69. package/packages/crewly-agent/src/runtime/tool-registry.test.ts +17 -0
  70. package/packages/crewly-agent/src/runtime/tool-registry.ts +54 -0
  71. package/packages/crewly-agent/src/runtime/types.ts +10 -1
  72. package/config/skills/agent/vnc-browser/SKILL.md +0 -140
@@ -0,0 +1,242 @@
1
+ #!/usr/bin/env bash
2
+ # =============================================================================
3
+ # Tests for the desktop safety rails.
4
+ #
5
+ # These matter more than most skill tests: every rail here stands between an
6
+ # agent and the owner's real keyboard. Before 2026-09-20 none of them existed
7
+ # — a denied TCC prompt produced a black screenshot the agent read as "the
8
+ # click did nothing", ⌘Q quit the user's app, `type` went into password boxes,
9
+ # and two agents fought over one mouse.
10
+ #
11
+ # Run: bash config/skills/_common/desktop-guards.test.sh
12
+ # =============================================================================
13
+ set -uo pipefail
14
+
15
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
16
+ SKILL="${SCRIPT_DIR}/../agent/computer-use/execute.sh"
17
+ export CREWLY_HOME="$(mktemp -d)"
18
+ export CREWLY_SESSION_NAME="test-agent"
19
+ PASS=0; FAIL=0; SKIP=0
20
+
21
+ # A locked screen refuses everything transient, so the rails that sit behind
22
+ # that check cannot be exercised. Skipping is honest; asserting would either
23
+ # fail on a locked Mac or pass for the wrong reason.
24
+ SCREEN_LOCKED=$(osascript -l JavaScript -e 'ObjC.import("CoreGraphics"); ObjC.bindFunction("CGSessionCopyCurrentDictionary", ["id", []]); var d = $.CGSessionCopyCurrentDictionary(); d && ObjC.unwrap(d.objectForKey("CGSSessionScreenIsLocked")) ? "yes" : "no"' 2>/dev/null)
25
+
26
+ # assert_output <description> <json-input> <grep pattern>
27
+ #
28
+ # Captures rather than pipes. `set -o pipefail` above makes `cmd | grep` fail
29
+ # whenever cmd exits non-zero — which a correct refusal does — so piping
30
+ # reported failure even when the pattern matched. That caught me three times
31
+ # before this helper existed.
32
+ assert_output() {
33
+ local desc="$1" input="$2" pattern="$3" out
34
+ out=$(bash "$SKILL" "$input" 2>&1 || true)
35
+ if printf '%s' "$out" | grep -q "$pattern"; then
36
+ PASS=$((PASS+1)); echo " ✓ $desc"
37
+ else
38
+ FAIL=$((FAIL+1)); echo " ✗ $desc"; echo " wanted /$pattern/ in: ${out:0:160}"
39
+ fi
40
+ }
41
+
42
+ # skip_if_locked <description>
43
+ skip_if_locked() {
44
+ [ "$SCREEN_LOCKED" != "yes" ] && return 1
45
+ SKIP=$((SKIP+1)); echo " ⊘ $1 (screen is locked)"
46
+ return 0
47
+ }
48
+
49
+ # assert_reason <description> <json-input> <expected reason>
50
+ assert_reason() {
51
+ local desc="$1" input="$2" want="$3" got
52
+ got=$(bash "$SKILL" "$input" 2>&1 | jq -r '.reason // empty' 2>/dev/null)
53
+ if [ "$got" = "$want" ]; then PASS=$((PASS+1)); echo " ✓ $desc"
54
+ else FAIL=$((FAIL+1)); echo " ✗ $desc"; echo " want reason=$want got=${got:-<none>}"; fi
55
+ }
56
+
57
+ # assert_allowed <description> <json-input> — guard must not refuse it.
58
+ # Runs dry so the suite never actually clicks, types or opens an app on the
59
+ # machine it runs on (the first version of this file opened TextEdit).
60
+ assert_allowed() {
61
+ local desc="$1" input="$2" got
62
+ got=$(CREWLY_DESKTOP_DRY_RUN=1 bash "$SKILL" "$input" 2>&1 | jq -r '.reason // "none"' 2>/dev/null)
63
+ case "$got" in
64
+ none|permission_required) PASS=$((PASS+1)); echo " ✓ $desc" ;;
65
+ *) FAIL=$((FAIL+1)); echo " ✗ $desc"; echo " unexpectedly refused: $got" ;;
66
+ esac
67
+ }
68
+
69
+ echo "destructive key combos"
70
+ for combo in "command+q" "cmd+w" "Command + Shift + Delete" "COMMAND+BACKSPACE" "command+option+escape"; do
71
+ assert_reason "refuses $combo" "{\"action\":\"key\",\"key\":\"$combo\"}" "destructive_blocked"
72
+ done
73
+ skip_if_locked "allows command+c" || assert_allowed "allows command+c" '{"action":"key","key":"command+c"}'
74
+ skip_if_locked "allows command+shift+s" || assert_allowed "allows command+shift+s" '{"action":"key","key":"command+shift+s"}'
75
+
76
+ echo "credential apps"
77
+ for app in "Keychain Access" "1Password" "System Settings" "bitwarden"; do
78
+ assert_reason "refuses focus on $app" "{\"action\":\"focus\",\"app\":\"$app\"}" "app_not_allowed"
79
+ done
80
+ skip_if_locked "allows focus on TextEdit" || assert_allowed "allows focus on TextEdit" '{"action":"focus","app":"TextEdit"}'
81
+
82
+ echo "stop switch"
83
+ touch "$CREWLY_HOME/desktop.stop"
84
+ assert_reason "halts a click" '{"action":"click","x":5,"y":5}' "stopped_by_user"
85
+ assert_reason "halts a type" '{"action":"type","text":"x"}' "stopped_by_user"
86
+ rm -f "$CREWLY_HOME/desktop.stop"
87
+
88
+ echo "desktop lock"
89
+ printf '{"holder":"other-agent","expiresAt":%s}' "$(( $(date +%s) + 300 ))" > "$CREWLY_HOME/desktop.lock"
90
+ skip_if_locked "refuses while another agent holds it" || assert_reason "refuses while another agent holds it" '{"action":"click","x":5,"y":5}' "desktop_busy"
91
+ printf '{"holder":"dead-agent","expiresAt":%s}' "$(( $(date +%s) - 60 ))" > "$CREWLY_HOME/desktop.lock"
92
+ skip_if_locked "ignores an expired lock" || assert_allowed "ignores an expired lock" '{"action":"list-apps"}'
93
+ rm -f "$CREWLY_HOME/desktop.lock"
94
+
95
+ echo "dry run"
96
+ if skip_if_locked "reports what would run without doing it"; then :
97
+ elif CREWLY_DESKTOP_DRY_RUN=1 bash "$SKILL" '{"action":"click","x":5,"y":5}' 2>&1 | jq -e '.dryRun == true and .wouldRun == true' >/dev/null 2>&1; then
98
+ PASS=$((PASS+1)); echo " ✓ reports what would run without doing it"
99
+ else
100
+ FAIL=$((FAIL+1)); echo " ✗ dry run did not report cleanly"
101
+ fi
102
+ # A refusal must still win over a dry run, or the rails could be inspected away.
103
+ assert_reason "a refusal still wins in dry run" '{"action":"key","key":"command+q"}' "destructive_blocked"
104
+
105
+ echo "perception (Phase 2)"
106
+ # `displays` reads no window and touches nothing, so it must work even when
107
+ # everything else is refused — including on a locked screen.
108
+ if bash "$SKILL" '{"action":"displays"}' 2>&1 | jq -e '.success and (.displays | length > 0)' >/dev/null 2>&1; then
109
+ PASS=$((PASS+1)); echo " ✓ displays works with no permission or lock needed"
110
+ else
111
+ FAIL=$((FAIL+1)); echo " ✗ displays did not report any screen"
112
+ fi
113
+
114
+ # Looking must not take the desktop lock: an agent waiting its turn still
115
+ # needs to see, and two readers cannot corrupt each other.
116
+ printf '{"holder":"other-agent","expiresAt":%s}' "$(( $(date +%s) + 300 ))" > "$CREWLY_HOME/desktop.lock"
117
+ for act in snapshot ocr resolve; do
118
+ got=$(bash "$SKILL" "{\"action\":\"$act\",\"ref\":\"@e1\"}" 2>&1 | jq -r '.reason // "none"' 2>/dev/null)
119
+ # Whatever else it answers, it must not have stopped at the other agent's lock.
120
+ if [ "$got" != "desktop_busy" ]; then PASS=$((PASS+1)); echo " ✓ $act does not wait on the desktop lock"
121
+ else FAIL=$((FAIL+1)); echo " ✗ $act was blocked by another agent's lock"; fi
122
+ done
123
+ rm -f "$CREWLY_HOME/desktop.lock"
124
+
125
+ # Changing things must take it.
126
+ printf '{"holder":"other-agent","expiresAt":%s}' "$(( $(date +%s) + 300 ))" > "$CREWLY_HOME/desktop.lock"
127
+ skip_if_locked "click-ref waits on the lock" || assert_reason "click-ref waits on the lock" '{"action":"click-ref","ref":"@e1"}' "desktop_busy"
128
+ skip_if_locked "fill-ref waits on the lock" || assert_reason "fill-ref waits on the lock" '{"action":"fill-ref","ref":"@e1","text":"x"}' "desktop_busy"
129
+ rm -f "$CREWLY_HOME/desktop.lock"
130
+
131
+ # A locked screen makes accessibility answer with placeholder data rather
132
+ # than failing, so reading it would look plausible and be wrong.
133
+ if [ "$(bash "$SKILL" '{"action":"check-permissions"}' 2>&1 | jq -r '.ready')" = "true" ]; then
134
+ locked=$(osascript -l JavaScript -e 'ObjC.import("CoreGraphics"); ObjC.bindFunction("CGSessionCopyCurrentDictionary", ["id", []]); var d = $.CGSessionCopyCurrentDictionary(); d && ObjC.unwrap(d.objectForKey("CGSSessionScreenIsLocked")) ? "yes" : "no"' 2>/dev/null)
135
+ got=$(bash "$SKILL" '{"action":"snapshot"}' 2>&1 | jq -r '.reason // "none"' 2>/dev/null)
136
+ if [ "$locked" = "yes" ]; then
137
+ if [ "$got" = "screen_locked" ]; then PASS=$((PASS+1)); echo " ✓ refuses to read a locked screen"
138
+ else FAIL=$((FAIL+1)); echo " ✗ read a locked screen anyway (got: $got)"; fi
139
+ else
140
+ if [ "$got" = "none" ]; then PASS=$((PASS+1)); echo " ✓ snapshot runs on an unlocked screen"
141
+ else FAIL=$((FAIL+1)); echo " ✗ snapshot failed on an unlocked screen: $got"; fi
142
+ fi
143
+ fi
144
+
145
+ # wait-for must reject a call that says nothing to wait for, rather than
146
+ # blocking for the whole timeout.
147
+ # Captured first, not piped: `set -o pipefail` above makes a pipeline fail
148
+ # when *any* stage does, and this command correctly exits non-zero — so
149
+ # `cmd | grep -q` reported failure even when grep matched.
150
+ skip_if_locked "wait-for demands a condition" || assert_output "wait-for demands a condition" '{"action":"wait-for"}' "one of"
151
+
152
+ echo "takeover (Phase 5)"
153
+ touch "$CREWLY_HOME/desktop.pause"
154
+ assert_reason "pausing holds a click" '{"action":"click","x":5,"y":5}' "paused"
155
+ assert_reason "pausing holds a type" '{"action":"type","text":"x"}' "paused"
156
+ # A pause is the owner using the machine, not a cancellation — the agent has
157
+ # to be told it can come back.
158
+ # Captured, not piped — see the pipefail note above; the skill exits
159
+ # non-zero on a refusal and would fail the pipeline whatever jq said.
160
+ PAUSE_OUT=$(bash "$SKILL" '{"action":"click","x":5,"y":5}' 2>&1 || true)
161
+ if printf '%s' "$PAUSE_OUT" | jq -e '.recoverable == true' >/dev/null 2>&1; then
162
+ PASS=$((PASS+1)); echo " ✓ a pause says the task is not cancelled"
163
+ else
164
+ FAIL=$((FAIL+1)); echo " ✗ a pause did not mark itself recoverable"
165
+ fi
166
+ # Permanent refusals still outrank it, or an agent would wait for a resume
167
+ # that will never let ⌘Q through.
168
+ assert_reason "a refusal still wins over a pause" '{"action":"key","key":"command+q"}' "destructive_blocked"
169
+ rm -f "$CREWLY_HOME/desktop.pause"
170
+ # Looking is not taking the machine over, so it must not be paused either.
171
+ skip_if_locked "reading is allowed while paused" || {
172
+ touch "$CREWLY_HOME/desktop.pause"
173
+ got=$(bash "$SKILL" '{"action":"displays"}' 2>&1 | jq -r '.reason // "none"' 2>/dev/null)
174
+ if [ "$got" = "none" ]; then PASS=$((PASS+1)); echo " ✓ displays still works while paused"
175
+ else FAIL=$((FAIL+1)); echo " ✗ displays was blocked by a pause ($got)"; fi
176
+ rm -f "$CREWLY_HOME/desktop.pause"
177
+ }
178
+
179
+ # A banner the owner learns to ignore is worse than none, so it must not
180
+ # appear for a dry run (which moves nothing) or for merely looking.
181
+ pkill -f "desktop-presence begin" 2>/dev/null || true
182
+ CREWLY_DESKTOP_DRY_RUN=1 bash "$SKILL" '{"action":"click","x":5,"y":5}' >/dev/null 2>&1 || true
183
+ bash "$SKILL" '{"action":"displays"}' >/dev/null 2>&1 || true
184
+ sleep 0.3
185
+ if pgrep -f "desktop-presence begin" >/dev/null 2>&1; then
186
+ FAIL=$((FAIL+1)); echo " ✗ the banner appeared for a dry run or a read"
187
+ pkill -f "desktop-presence begin" 2>/dev/null || true
188
+ else
189
+ PASS=$((PASS+1)); echo " ✓ no banner for a dry run or for looking"
190
+ fi
191
+
192
+ echo "reach (Phase 6)"
193
+ # Asking for a person is how an agent gets out of being stuck, so it has to
194
+ # work while paused — otherwise the only move left is retrying the thing it
195
+ # already cannot do.
196
+ touch "$CREWLY_HOME/desktop.pause"
197
+ HUMAN_OUT=$(CREWLY_DESKTOP_NO_BANNER=1 bash "$SKILL" '{"action":"request-human","reason":"needs a 2FA code"}' 2>&1 || true)
198
+ if printf '%s' "$HUMAN_OUT" | jq -e '.waiting == true' >/dev/null 2>&1; then
199
+ PASS=$((PASS+1)); echo " ✓ request-human works even while paused"
200
+ else
201
+ FAIL=$((FAIL+1)); echo " ✗ request-human was refused while paused"
202
+ fi
203
+ # And it leaves the desktop paused, not stopped: the task is waiting, not dead.
204
+ if [ -f "$CREWLY_HOME/desktop.pause" ] && [ ! -f "$CREWLY_HOME/desktop.stop" ]; then
205
+ PASS=$((PASS+1)); echo " ✓ handing over pauses rather than cancels"
206
+ else
207
+ FAIL=$((FAIL+1)); echo " ✗ handing over left the wrong state"
208
+ fi
209
+ rm -f "$CREWLY_HOME/desktop.pause"
210
+ # It must demand a reason — "a person is needed" with no reason is useless
211
+ # to whoever gets the notification.
212
+ assert_output "request-human demands a reason" '{"action":"request-human"}' "reason"
213
+
214
+ echo "permissions report"
215
+ if bash "$SKILL" '{"action":"check-permissions"}' 2>&1 | jq -e 'has("screenRecording") and has("accessibility") and has("askingProcess")' >/dev/null 2>&1; then
216
+ PASS=$((PASS+1)); echo " ✓ check-permissions reports both grants and who to grant them to"
217
+ else
218
+ FAIL=$((FAIL+1)); echo " ✗ check-permissions did not report the expected fields"
219
+ fi
220
+
221
+ echo "audit trail"
222
+ if [ -s "$CREWLY_HOME/desktop-actions.jsonl" ] \
223
+ && jq -e 'has("at") and has("session") and has("action")' < <(head -1 "$CREWLY_HOME/desktop-actions.jsonl") >/dev/null 2>&1; then
224
+ PASS=$((PASS+1)); echo " ✓ every action is logged with time, session and action"
225
+ else
226
+ FAIL=$((FAIL+1)); echo " ✗ action log missing or malformed"
227
+ fi
228
+
229
+ echo "bash 3.2 compatibility"
230
+ # macOS ships bash 3.2, which has no negative array indices. Using one made
231
+ # every modifier combo fail with "bad array subscript".
232
+ if bash -c 'IFS="+" read -ra p <<< "command+shift+s"; echo "${p[$(( ${#p[@]} - 1 ))]}"' 2>/dev/null | grep -q '^s$'; then
233
+ PASS=$((PASS+1)); echo " ✓ key parsing uses a 3.2-safe last-element index"
234
+ else
235
+ FAIL=$((FAIL+1)); echo " ✗ key parsing is not bash 3.2 safe"
236
+ fi
237
+
238
+ rm -rf "$CREWLY_HOME"
239
+ echo
240
+ echo "=== Results: $PASS passed, $FAIL failed, $SKIP skipped ==="
241
+ [ "$SKIP" -gt 0 ] && echo " (unlock the screen and re-run to cover the skipped rails)"
242
+ [ "$FAIL" -eq 0 ]