safari-pilot 0.1.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.
Files changed (143) hide show
  1. package/.claude-plugin/plugin.json +35 -0
  2. package/.mcp.json +11 -0
  3. package/LICENSE +21 -0
  4. package/README.md +324 -0
  5. package/bin/.gitkeep +0 -0
  6. package/bin/Safari Pilot.app/Contents/CodeResources +0 -0
  7. package/bin/Safari Pilot.app/Contents/Info.plist +58 -0
  8. package/bin/Safari Pilot.app/Contents/MacOS/Safari Pilot +0 -0
  9. package/bin/Safari Pilot.app/Contents/PkgInfo +1 -0
  10. package/bin/Safari Pilot.app/Contents/PlugIns/Safari Pilot Extension.appex/Contents/Info.plist +55 -0
  11. package/bin/Safari Pilot.app/Contents/PlugIns/Safari Pilot Extension.appex/Contents/MacOS/Safari Pilot Extension +0 -0
  12. package/bin/Safari Pilot.app/Contents/PlugIns/Safari Pilot Extension.appex/Contents/Resources/background.js +294 -0
  13. package/bin/Safari Pilot.app/Contents/PlugIns/Safari Pilot Extension.appex/Contents/Resources/content-isolated.js +80 -0
  14. package/bin/Safari Pilot.app/Contents/PlugIns/Safari Pilot Extension.appex/Contents/Resources/content-main.js +310 -0
  15. package/bin/Safari Pilot.app/Contents/PlugIns/Safari Pilot Extension.appex/Contents/Resources/icons/icon-128.png +0 -0
  16. package/bin/Safari Pilot.app/Contents/PlugIns/Safari Pilot Extension.appex/Contents/Resources/icons/icon-48.png +0 -0
  17. package/bin/Safari Pilot.app/Contents/PlugIns/Safari Pilot Extension.appex/Contents/Resources/icons/icon-96.png +0 -0
  18. package/bin/Safari Pilot.app/Contents/PlugIns/Safari Pilot Extension.appex/Contents/Resources/manifest.json +39 -0
  19. package/bin/Safari Pilot.app/Contents/PlugIns/Safari Pilot Extension.appex/Contents/_CodeSignature/CodeResources +194 -0
  20. package/bin/Safari Pilot.app/Contents/Resources/AppIcon.icns +0 -0
  21. package/bin/Safari Pilot.app/Contents/Resources/Assets.car +0 -0
  22. package/bin/Safari Pilot.app/Contents/Resources/Base.lproj/Main.html +19 -0
  23. package/bin/Safari Pilot.app/Contents/Resources/Base.lproj/Main.storyboardc/Info.plist +0 -0
  24. package/bin/Safari Pilot.app/Contents/Resources/Base.lproj/Main.storyboardc/MainMenu.nib +0 -0
  25. package/bin/Safari Pilot.app/Contents/Resources/Base.lproj/Main.storyboardc/NSWindowController-B8D-0N-5wS.nib +0 -0
  26. package/bin/Safari Pilot.app/Contents/Resources/Base.lproj/Main.storyboardc/XfG-lQ-9wD-view-m2S-Jp-Qdl.nib +0 -0
  27. package/bin/Safari Pilot.app/Contents/Resources/Icon.png +0 -0
  28. package/bin/Safari Pilot.app/Contents/Resources/Script.js +22 -0
  29. package/bin/Safari Pilot.app/Contents/Resources/Style.css +45 -0
  30. package/bin/Safari Pilot.app/Contents/_CodeSignature/CodeResources +236 -0
  31. package/bin/Safari Pilot.zip +0 -0
  32. package/bin/SafariPilotd +0 -0
  33. package/dist/engine-selector.d.ts +10 -0
  34. package/dist/engine-selector.js +55 -0
  35. package/dist/engine-selector.js.map +1 -0
  36. package/dist/engines/applescript.d.ts +53 -0
  37. package/dist/engines/applescript.js +290 -0
  38. package/dist/engines/applescript.js.map +1 -0
  39. package/dist/engines/daemon.d.ts +19 -0
  40. package/dist/engines/daemon.js +187 -0
  41. package/dist/engines/daemon.js.map +1 -0
  42. package/dist/engines/engine.d.ts +15 -0
  43. package/dist/engines/engine.js +42 -0
  44. package/dist/engines/engine.js.map +1 -0
  45. package/dist/engines/extension.d.ts +34 -0
  46. package/dist/engines/extension.js +66 -0
  47. package/dist/engines/extension.js.map +1 -0
  48. package/dist/errors.d.ts +128 -0
  49. package/dist/errors.js +250 -0
  50. package/dist/errors.js.map +1 -0
  51. package/dist/index.d.ts +2 -0
  52. package/dist/index.js +11 -0
  53. package/dist/index.js.map +1 -0
  54. package/dist/security/audit-log.d.ts +23 -0
  55. package/dist/security/audit-log.js +68 -0
  56. package/dist/security/audit-log.js.map +1 -0
  57. package/dist/security/circuit-breaker.d.ts +29 -0
  58. package/dist/security/circuit-breaker.js +114 -0
  59. package/dist/security/circuit-breaker.js.map +1 -0
  60. package/dist/security/domain-policy.d.ts +29 -0
  61. package/dist/security/domain-policy.js +96 -0
  62. package/dist/security/domain-policy.js.map +1 -0
  63. package/dist/security/human-approval.d.ts +20 -0
  64. package/dist/security/human-approval.js +150 -0
  65. package/dist/security/human-approval.js.map +1 -0
  66. package/dist/security/idpi-scanner.d.ts +20 -0
  67. package/dist/security/idpi-scanner.js +102 -0
  68. package/dist/security/idpi-scanner.js.map +1 -0
  69. package/dist/security/kill-switch.d.ts +51 -0
  70. package/dist/security/kill-switch.js +103 -0
  71. package/dist/security/kill-switch.js.map +1 -0
  72. package/dist/security/rate-limiter.d.ts +30 -0
  73. package/dist/security/rate-limiter.js +70 -0
  74. package/dist/security/rate-limiter.js.map +1 -0
  75. package/dist/security/screenshot-redaction.d.ts +42 -0
  76. package/dist/security/screenshot-redaction.js +134 -0
  77. package/dist/security/screenshot-redaction.js.map +1 -0
  78. package/dist/security/tab-ownership.d.ts +46 -0
  79. package/dist/security/tab-ownership.js +85 -0
  80. package/dist/security/tab-ownership.js.map +1 -0
  81. package/dist/server.d.ts +53 -0
  82. package/dist/server.js +347 -0
  83. package/dist/server.js.map +1 -0
  84. package/dist/tools/clipboard.d.ts +15 -0
  85. package/dist/tools/clipboard.js +128 -0
  86. package/dist/tools/clipboard.js.map +1 -0
  87. package/dist/tools/compound.d.ts +68 -0
  88. package/dist/tools/compound.js +491 -0
  89. package/dist/tools/compound.js.map +1 -0
  90. package/dist/tools/extraction.d.ts +26 -0
  91. package/dist/tools/extraction.js +414 -0
  92. package/dist/tools/extraction.js.map +1 -0
  93. package/dist/tools/frames.d.ts +22 -0
  94. package/dist/tools/frames.js +165 -0
  95. package/dist/tools/frames.js.map +1 -0
  96. package/dist/tools/interaction.d.ts +30 -0
  97. package/dist/tools/interaction.js +651 -0
  98. package/dist/tools/interaction.js.map +1 -0
  99. package/dist/tools/navigation.d.ts +41 -0
  100. package/dist/tools/navigation.js +316 -0
  101. package/dist/tools/navigation.js.map +1 -0
  102. package/dist/tools/network.d.ts +27 -0
  103. package/dist/tools/network.js +721 -0
  104. package/dist/tools/network.js.map +1 -0
  105. package/dist/tools/performance.d.ts +16 -0
  106. package/dist/tools/performance.js +240 -0
  107. package/dist/tools/performance.js.map +1 -0
  108. package/dist/tools/permissions.d.ts +25 -0
  109. package/dist/tools/permissions.js +308 -0
  110. package/dist/tools/permissions.js.map +1 -0
  111. package/dist/tools/service-workers.d.ts +15 -0
  112. package/dist/tools/service-workers.js +136 -0
  113. package/dist/tools/service-workers.js.map +1 -0
  114. package/dist/tools/shadow.d.ts +21 -0
  115. package/dist/tools/shadow.js +126 -0
  116. package/dist/tools/shadow.js.map +1 -0
  117. package/dist/tools/storage.d.ts +30 -0
  118. package/dist/tools/storage.js +679 -0
  119. package/dist/tools/storage.js.map +1 -0
  120. package/dist/tools/structured-extraction.d.ts +22 -0
  121. package/dist/tools/structured-extraction.js +433 -0
  122. package/dist/tools/structured-extraction.js.map +1 -0
  123. package/dist/tools/wait.d.ts +18 -0
  124. package/dist/tools/wait.js +182 -0
  125. package/dist/tools/wait.js.map +1 -0
  126. package/dist/types.d.ts +85 -0
  127. package/dist/types.js +2 -0
  128. package/dist/types.js.map +1 -0
  129. package/extension/background.js +294 -0
  130. package/extension/content-isolated.js +80 -0
  131. package/extension/content-main.js +310 -0
  132. package/extension/icons/icon-128.png +0 -0
  133. package/extension/icons/icon-48.png +0 -0
  134. package/extension/icons/icon-96.png +0 -0
  135. package/extension/manifest.json +39 -0
  136. package/hooks/session-end.sh +67 -0
  137. package/hooks/session-start.sh +66 -0
  138. package/package.json +46 -0
  139. package/scripts/build-extension.sh +135 -0
  140. package/scripts/postinstall.sh +91 -0
  141. package/scripts/preuninstall.sh +25 -0
  142. package/scripts/update-daemon.sh +62 -0
  143. package/skills/safari-pilot/SKILL.md +157 -0
@@ -0,0 +1,310 @@
1
+ // content-main.js — MAIN world
2
+ // WARNING: This code runs in the page's context. It CAN be observed by page JS.
3
+ // Never store secrets. Never read credentials. Minimal footprint.
4
+
5
+ (() => {
6
+ 'use strict';
7
+
8
+ // Namespace to minimize collision risk
9
+ const SP = Object.create(null);
10
+
11
+ // ─── Shadow DOM Traversal ─────────────────────────────────────────────────
12
+ // Traverses open AND closed shadow roots. Closed roots are accessible from
13
+ // MAIN world because we intercept Element.attachShadow at document_idle.
14
+ // Primary reason this extension exists — no other automation layer can do this.
15
+
16
+ SP.queryShadow = (selector, shadowSelector) => {
17
+ // If shadowSelector provided: find hosts matching selector, then query inside their shadows
18
+ // If only selector: query entire document including all shadow subtrees
19
+ const results = [];
20
+
21
+ const walkShadow = (node, targetSelector) => {
22
+ const shadow = node.shadowRoot;
23
+ if (shadow) {
24
+ const found = shadow.querySelectorAll(targetSelector);
25
+ results.push(...found);
26
+ shadow.querySelectorAll('*').forEach(child => walkShadow(child, targetSelector));
27
+ }
28
+ };
29
+
30
+ if (shadowSelector) {
31
+ // Two-phase: find shadow hosts by selector, then query inside their shadows
32
+ const hosts = document.querySelectorAll(selector);
33
+ hosts.forEach(host => {
34
+ if (host.shadowRoot) {
35
+ results.push(...host.shadowRoot.querySelectorAll(shadowSelector));
36
+ host.shadowRoot.querySelectorAll('*').forEach(child => walkShadow(child, shadowSelector));
37
+ }
38
+ });
39
+ } else {
40
+ // Single-phase: query full document + all shadow subtrees
41
+ results.push(...document.querySelectorAll(selector));
42
+ document.querySelectorAll('*').forEach(el => walkShadow(el, selector));
43
+ }
44
+
45
+ return results;
46
+ };
47
+
48
+ SP.queryShadowAll = (selector, root = document) => {
49
+ const results = [];
50
+ const walk = (node) => {
51
+ if (node.shadowRoot) {
52
+ results.push(...node.shadowRoot.querySelectorAll(selector));
53
+ node.shadowRoot.querySelectorAll('*').forEach(walk);
54
+ }
55
+ node.querySelectorAll('*').forEach(child => {
56
+ if (child.shadowRoot) walk(child);
57
+ });
58
+ };
59
+ results.push(...root.querySelectorAll(selector));
60
+ walk(root);
61
+ return results;
62
+ };
63
+
64
+ // ─── Framework-Aware Form Filling ─────────────────────────────────────────
65
+ // React tracks input state via _valueTracker. A plain .value assignment won't
66
+ // trigger React's synthetic event system. We must use the native setter and
67
+ // delete the tracker so React sees the change as "new" input.
68
+
69
+ SP.fillReact = (element, value) => {
70
+ const nativeInputValueSetter =
71
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set ||
72
+ Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set;
73
+
74
+ if (!nativeInputValueSetter) {
75
+ throw new Error('Cannot find native value setter');
76
+ }
77
+
78
+ // Bypass React's _valueTracker so it sees the programmatic change as new
79
+ if (element._valueTracker) {
80
+ element._valueTracker.setValue('');
81
+ }
82
+
83
+ nativeInputValueSetter.call(element, value);
84
+ element.dispatchEvent(new Event('input', { bubbles: true }));
85
+ element.dispatchEvent(new Event('change', { bubbles: true }));
86
+ element.dispatchEvent(new FocusEvent('blur', { bubbles: true }));
87
+ };
88
+
89
+ SP.fillVue = async (element, value) => {
90
+ // Vue 3 uses Proxy-based reactivity — direct assignment triggers v-model
91
+ element.value = value;
92
+ element.dispatchEvent(new Event('input', { bubbles: true }));
93
+ // Wait for Vue's nextTick so watchers run before change event
94
+ await new Promise(resolve => setTimeout(resolve, 0));
95
+ element.dispatchEvent(new Event('change', { bubbles: true }));
96
+ };
97
+
98
+ // ─── Dialog Interception ──────────────────────────────────────────────────
99
+ // Replaces window.alert/confirm/prompt with captured versions.
100
+ // Must run in MAIN world — ISOLATED world cannot override window globals.
101
+
102
+ SP.interceptDialogs = () => {
103
+ const dialogQueue = [];
104
+ const handlers = {};
105
+
106
+ ['alert', 'confirm', 'prompt'].forEach(type => {
107
+ const original = window[type];
108
+ window[type] = function(message, defaultValue) {
109
+ const entry = { type, message, timestamp: Date.now() };
110
+ dialogQueue.push(entry);
111
+
112
+ if (handlers[type]) {
113
+ return handlers[type](message, defaultValue);
114
+ }
115
+ // Sensible defaults: accept alerts, auto-confirm, return empty prompt
116
+ if (type === 'alert') return undefined;
117
+ if (type === 'confirm') return true;
118
+ if (type === 'prompt') return defaultValue || '';
119
+ };
120
+ });
121
+
122
+ return {
123
+ getQueue: () => [...dialogQueue],
124
+ setHandler: (type, fn) => { handlers[type] = fn; },
125
+ clear: () => { dialogQueue.length = 0; },
126
+ };
127
+ };
128
+
129
+ // ─── Network Interception ─────────────────────────────────────────────────
130
+ // Monkey-patches fetch and XMLHttpRequest for capture and mocking.
131
+ // Only JS-initiated requests are interceptable — browser-native resource
132
+ // loading (img src, link href, etc.) bypasses this.
133
+
134
+ SP.interceptNetwork = () => {
135
+ const captured = [];
136
+ const originalFetch = window.fetch;
137
+ const OriginalXHR = window.XMLHttpRequest;
138
+
139
+ window.fetch = async function(...args) {
140
+ const request = new Request(...args);
141
+ const entry = {
142
+ type: 'fetch',
143
+ url: request.url,
144
+ method: request.method,
145
+ timestamp: Date.now(),
146
+ };
147
+ try {
148
+ const response = await originalFetch.apply(this, args);
149
+ entry.status = response.status;
150
+ entry.statusText = response.statusText;
151
+ captured.push(entry);
152
+ return response;
153
+ } catch (error) {
154
+ entry.error = error.message;
155
+ captured.push(entry);
156
+ throw error;
157
+ }
158
+ };
159
+
160
+ // Patch XMLHttpRequest
161
+ window.XMLHttpRequest = function() {
162
+ const xhr = new OriginalXHR();
163
+ const entry = { type: 'xhr', timestamp: Date.now() };
164
+
165
+ const originalOpen = xhr.open.bind(xhr);
166
+ xhr.open = function(method, url, ...rest) {
167
+ entry.method = method;
168
+ entry.url = url;
169
+ return originalOpen(method, url, ...rest);
170
+ };
171
+
172
+ xhr.addEventListener('load', () => {
173
+ entry.status = xhr.status;
174
+ entry.statusText = xhr.statusText;
175
+ captured.push({ ...entry });
176
+ });
177
+
178
+ xhr.addEventListener('error', () => {
179
+ entry.error = 'XHR error';
180
+ captured.push({ ...entry });
181
+ });
182
+
183
+ return xhr;
184
+ };
185
+
186
+ return {
187
+ getCaptured: () => [...captured],
188
+ clear: () => { captured.length = 0; },
189
+ restore: () => {
190
+ window.fetch = originalFetch;
191
+ window.XMLHttpRequest = OriginalXHR;
192
+ },
193
+ };
194
+ };
195
+
196
+ // ─── Framework Detection ──────────────────────────────────────────────────
197
+
198
+ SP.detectFramework = () => {
199
+ const detected = [];
200
+
201
+ if (window.__REACT_DEVTOOLS_GLOBAL_HOOK__ || document.querySelector('[data-reactroot]') || document.querySelector('[data-reactid]')) {
202
+ detected.push('react');
203
+ }
204
+ if (window.__vue_devtools_global_hook__ || document.querySelector('[data-v-app]')) {
205
+ detected.push('vue');
206
+ }
207
+ if (window.ng || window.getAllAngularRootElements?.()?.length > 0) {
208
+ detected.push('angular');
209
+ }
210
+ if (document.querySelector('[data-svelte]') || window.__svelte) {
211
+ detected.push('svelte');
212
+ }
213
+
214
+ return detected;
215
+ };
216
+
217
+ // ─── Expose namespace ──────────────────────────────────────────────────────
218
+ window.__safariPilot = SP;
219
+
220
+ // ─── Message Channel from ISOLATED World ──────────────────────────────────
221
+ // The ISOLATED world relay forwards background script commands here via
222
+ // window.postMessage. We respond with results on the same channel.
223
+ // SECURITY: use window.location.origin (never '*') as postMessage target.
224
+
225
+ window.addEventListener('message', (event) => {
226
+ if (event.source !== window) return;
227
+ if (event.data?.type !== 'SAFARI_PILOT_CMD') return;
228
+
229
+ const { requestId, method, params } = event.data;
230
+
231
+ const respond = (ok, payload) => {
232
+ window.postMessage(
233
+ { type: 'SAFARI_PILOT_RESPONSE', requestId, ok, ...payload },
234
+ window.location.origin
235
+ );
236
+ };
237
+
238
+ (async () => {
239
+ try {
240
+ let result;
241
+ switch (method) {
242
+ case 'queryShadow': {
243
+ const elements = SP.queryShadow(params.selector, params.shadowSelector);
244
+ result = Array.from(elements).map(el => ({
245
+ tagName: el.tagName,
246
+ id: el.id,
247
+ className: el.className,
248
+ textContent: el.textContent?.slice(0, 200),
249
+ }));
250
+ break;
251
+ }
252
+ case 'queryShadowAll': {
253
+ const elements = SP.queryShadowAll(params.selector);
254
+ result = Array.from(elements).map(el => ({
255
+ tagName: el.tagName,
256
+ id: el.id,
257
+ className: el.className,
258
+ textContent: el.textContent?.slice(0, 200),
259
+ }));
260
+ break;
261
+ }
262
+ case 'fillReact': {
263
+ const target = document.querySelector(params.selector);
264
+ if (!target) throw new Error(`Element not found: ${params.selector}`);
265
+ SP.fillReact(target, params.value);
266
+ result = { filled: true };
267
+ break;
268
+ }
269
+ case 'fillVue': {
270
+ const target = document.querySelector(params.selector);
271
+ if (!target) throw new Error(`Element not found: ${params.selector}`);
272
+ await SP.fillVue(target, params.value);
273
+ result = { filled: true };
274
+ break;
275
+ }
276
+ case 'interceptDialogs': {
277
+ const controller = SP.interceptDialogs();
278
+ // Store controller so later commands can query it
279
+ SP._dialogController = controller;
280
+ result = { intercepting: true };
281
+ break;
282
+ }
283
+ case 'getDialogQueue': {
284
+ result = SP._dialogController?.getQueue() ?? [];
285
+ break;
286
+ }
287
+ case 'interceptNetwork': {
288
+ const controller = SP.interceptNetwork();
289
+ SP._networkController = controller;
290
+ result = { intercepting: true };
291
+ break;
292
+ }
293
+ case 'getNetworkCaptures': {
294
+ result = SP._networkController?.getCaptured() ?? [];
295
+ break;
296
+ }
297
+ case 'detectFramework': {
298
+ result = SP.detectFramework();
299
+ break;
300
+ }
301
+ default:
302
+ throw new Error(`Unknown method: ${method}`);
303
+ }
304
+ respond(true, { value: result });
305
+ } catch (error) {
306
+ respond(false, { error: { message: error.message, name: error.name } });
307
+ }
308
+ })();
309
+ });
310
+ })();
Binary file
Binary file
Binary file
@@ -0,0 +1,39 @@
1
+ {
2
+ "manifest_version": 3,
3
+ "name": "Safari Pilot",
4
+ "version": "0.1.0",
5
+ "description": "Native Safari automation for AI agents",
6
+ "permissions": [
7
+ "activeTab",
8
+ "scripting",
9
+ "storage",
10
+ "cookies",
11
+ "declarativeNetRequest",
12
+ "nativeMessaging",
13
+ "tabs"
14
+ ],
15
+ "host_permissions": ["<all_urls>"],
16
+ "background": {
17
+ "service_worker": "background.js",
18
+ "type": "module"
19
+ },
20
+ "content_scripts": [
21
+ {
22
+ "matches": ["<all_urls>"],
23
+ "js": ["content-isolated.js"],
24
+ "run_at": "document_idle",
25
+ "world": "ISOLATED"
26
+ },
27
+ {
28
+ "matches": ["<all_urls>"],
29
+ "js": ["content-main.js"],
30
+ "run_at": "document_idle",
31
+ "world": "MAIN"
32
+ }
33
+ ],
34
+ "icons": {
35
+ "48": "icons/icon-48.png",
36
+ "96": "icons/icon-96.png",
37
+ "128": "icons/icon-128.png"
38
+ }
39
+ }
@@ -0,0 +1,67 @@
1
+ #!/bin/bash
2
+ # safari-pilot session-end hook
3
+ # Runs when Claude Code session ends (Stop event).
4
+ # Summarizes audit log and performs cleanup.
5
+
6
+ set -euo pipefail
7
+
8
+ SAFARI_PILOT_DATA="${SAFARI_PILOT_DATA:-$HOME/.safari-pilot}"
9
+ LOG_DIR="${SAFARI_PILOT_DATA}/logs"
10
+ AUDIT_LOG="${SAFARI_PILOT_DATA}/audit.log"
11
+ PID_FILE="${SAFARI_PILOT_DATA}/daemon.pid"
12
+
13
+ # ── 1. OS gate ─────────────────────────────────────────────────────────────────
14
+ if [[ "$(uname)" != "Darwin" ]]; then
15
+ exit 0
16
+ fi
17
+
18
+ # ── 2. Audit log summary ──────────────────────────────────────────────────────
19
+ if [[ -f "$AUDIT_LOG" ]]; then
20
+ TOTAL=$(wc -l < "$AUDIT_LOG" | tr -d ' ')
21
+ ERRORS=$(grep -c '"result":"error"' "$AUDIT_LOG" 2>/dev/null || echo 0)
22
+ OK=$(grep -c '"result":"ok"' "$AUDIT_LOG" 2>/dev/null || echo 0)
23
+ echo "safari-pilot: Session summary — ${TOTAL} actions logged (${OK} ok, ${ERRORS} errors)" >&2
24
+
25
+ # Archive the audit log with a timestamp to prevent unbounded growth
26
+ if [[ "$TOTAL" -gt 0 ]]; then
27
+ ARCHIVE="${LOG_DIR}/audit-$(date +%Y%m%d-%H%M%S).log"
28
+ mkdir -p "$LOG_DIR"
29
+ cp "$AUDIT_LOG" "$ARCHIVE" 2>/dev/null || true
30
+ # Truncate the live audit log for the next session
31
+ : > "$AUDIT_LOG" 2>/dev/null || true
32
+ echo "safari-pilot: Audit log archived to ${ARCHIVE}" >&2
33
+ fi
34
+ else
35
+ echo "safari-pilot: No audit log found — session had no tool calls" >&2
36
+ fi
37
+
38
+ # ── 3. Clean up stale session logs (keep last 10) ────────────────────────────
39
+ if [[ -d "$LOG_DIR" ]]; then
40
+ # List session logs sorted by time, remove all but the 10 most recent
41
+ SESSION_LOGS=$(ls -t "${LOG_DIR}"/session-*.log 2>/dev/null | tail -n +11)
42
+ if [[ -n "$SESSION_LOGS" ]]; then
43
+ echo "$SESSION_LOGS" | xargs rm -f 2>/dev/null || true
44
+ fi
45
+
46
+ # Keep only 20 most recent audit archives
47
+ AUDIT_ARCHIVES=$(ls -t "${LOG_DIR}"/audit-*.log 2>/dev/null | tail -n +21)
48
+ if [[ -n "$AUDIT_ARCHIVES" ]]; then
49
+ echo "$AUDIT_ARCHIVES" | xargs rm -f 2>/dev/null || true
50
+ fi
51
+ fi
52
+
53
+ # ── 4. Daemon shutdown (optional — leave running for fast restart) ─────────────
54
+ # The daemon is intentionally left running between sessions for faster startup.
55
+ # Uncomment below to stop it on session end:
56
+ #
57
+ # if [[ -f "$PID_FILE" ]]; then
58
+ # DAEMON_PID=$(cat "$PID_FILE")
59
+ # if kill -0 "$DAEMON_PID" 2>/dev/null; then
60
+ # kill "$DAEMON_PID" 2>/dev/null || true
61
+ # echo "safari-pilot: Daemon stopped (PID: $DAEMON_PID)" >&2
62
+ # fi
63
+ # rm -f "$PID_FILE"
64
+ # fi
65
+
66
+ echo "safari-pilot: Session ended" >&2
67
+ exit 0
@@ -0,0 +1,66 @@
1
+ #!/bin/bash
2
+ # safari-pilot session-start hook
3
+ # Runs at the start of every Claude Code session when safari-pilot is installed.
4
+ # Gates: OS check → macOS version → Safari presence → daemon startup → health check
5
+
6
+ set -euo pipefail
7
+
8
+ PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(dirname "$(cd "$(dirname "$0")" && pwd)")}"
9
+ DAEMON_BIN="${PLUGIN_ROOT}/bin/SafariPilotd"
10
+ LOG_DIR="${SAFARI_PILOT_DATA:-$HOME/.safari-pilot}/logs"
11
+ SESSION_LOG="${LOG_DIR}/session-$(date +%Y%m%d-%H%M%S).log"
12
+
13
+ # ── 1. OS gate ─────────────────────────────────────────────────────────────────
14
+ if [[ "$(uname)" != "Darwin" ]]; then
15
+ echo "safari-pilot: Skipped (not macOS)" >&2
16
+ exit 0
17
+ fi
18
+
19
+ # ── 2. macOS version check (require 12.0+) ────────────────────────────────────
20
+ OS_VERSION=$(sw_vers -productVersion 2>/dev/null || echo "0.0")
21
+ OS_MAJOR=$(echo "$OS_VERSION" | cut -d. -f1)
22
+ if [[ "$OS_MAJOR" -lt 12 ]]; then
23
+ echo "safari-pilot: Warning — macOS ${OS_VERSION} detected. Requires macOS 12.0 (Monterey) or later." >&2
24
+ exit 0
25
+ fi
26
+
27
+ # ── 3. Log directory setup ────────────────────────────────────────────────────
28
+ mkdir -p "$LOG_DIR"
29
+
30
+ # ── 4. Safari running check ───────────────────────────────────────────────────
31
+ SAFARI_RUNNING=$(osascript -e 'tell application "System Events" to (name of processes) contains "Safari"' 2>/dev/null || echo "false")
32
+ if [[ "$SAFARI_RUNNING" != "true" ]]; then
33
+ echo "safari-pilot: Safari is not running. Opening Safari..." >&2
34
+ open -a Safari 2>/dev/null || true
35
+ # Give Safari a moment to start
36
+ sleep 1
37
+ fi
38
+
39
+ # ── 5. Daemon startup ─────────────────────────────────────────────────────────
40
+ if [[ -x "$DAEMON_BIN" ]]; then
41
+ # Check if daemon is already running
42
+ if ! pgrep -f "SafariPilotd" > /dev/null 2>&1; then
43
+ echo "safari-pilot: Starting daemon..." >&2
44
+ "$DAEMON_BIN" --daemon >> "$SESSION_LOG" 2>&1 &
45
+ DAEMON_PID=$!
46
+ echo "safari-pilot: Daemon started (PID: $DAEMON_PID)" >&2
47
+ echo "$DAEMON_PID" > "${SAFARI_PILOT_DATA:-$HOME/.safari-pilot}/daemon.pid"
48
+ else
49
+ echo "safari-pilot: Daemon already running" >&2
50
+ fi
51
+ else
52
+ echo "safari-pilot: Daemon binary not found at ${DAEMON_BIN} — running in AppleScript-only mode" >&2
53
+ fi
54
+
55
+ # ── 6. JS from Apple Events check ────────────────────────────────────────────
56
+ JS_CHECK=$(osascript -e 'tell application "Safari" to do JavaScript "1+1" in current tab of front window' 2>&1 || true)
57
+ if echo "$JS_CHECK" | grep -q "2"; then
58
+ echo "safari-pilot: Ready (JS from Apple Events enabled)" >&2
59
+ else
60
+ echo "safari-pilot: Warning — JS from Apple Events may be disabled." >&2
61
+ echo " Enable in Safari: Develop menu → Allow JavaScript from Apple Events" >&2
62
+ echo " (If no Develop menu: Safari → Settings → Advanced → Show features for web developers)" >&2
63
+ fi
64
+
65
+ echo "safari-pilot: Session started on macOS ${OS_VERSION}" >&2
66
+ exit 0
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "safari-pilot",
3
+ "version": "0.1.0",
4
+ "description": "Native Safari browser automation for AI agents on macOS",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "files": [
9
+ "dist/",
10
+ "bin/",
11
+ "skills/",
12
+ "hooks/",
13
+ "extension/",
14
+ "scripts/",
15
+ ".claude-plugin/",
16
+ ".mcp.json",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "scripts": {
21
+ "build": "tsc",
22
+ "dev": "tsc --watch",
23
+ "test": "vitest",
24
+ "test:unit": "vitest run test/unit/",
25
+ "test:integration": "vitest run test/integration/",
26
+ "test:e2e": "vitest run test/e2e/",
27
+ "test:security": "vitest run test/security/",
28
+ "lint": "tsc --noEmit",
29
+ "postinstall": "bash scripts/postinstall.sh",
30
+ "prepublishOnly": "npm run build"
31
+ },
32
+ "dependencies": {
33
+ "@modelcontextprotocol/sdk": "^1.0.0"
34
+ },
35
+ "devDependencies": {
36
+ "typescript": "^5.4.0",
37
+ "vitest": "^2.0.0",
38
+ "@types/node": "^20.0.0"
39
+ },
40
+ "engines": {
41
+ "node": ">=20.0.0"
42
+ },
43
+ "os": ["darwin"],
44
+ "license": "MIT",
45
+ "author": "Aakash Kumar"
46
+ }
@@ -0,0 +1,135 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+
4
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
5
+ ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
6
+ EXT_DIR="$ROOT/extension"
7
+ APP_DIR="$ROOT/app"
8
+ XCODE_PROJECT_DIR="$APP_DIR/Safari Pilot"
9
+ BUNDLE_ID="com.safari-pilot.app"
10
+
11
+ echo "=== Safari Pilot Extension Build ==="
12
+
13
+ # Step 1: Generate Xcode project from extension source
14
+ echo "Generating Xcode project..."
15
+ xcrun safari-web-extension-packager "$EXT_DIR" \
16
+ --project-location "$APP_DIR" \
17
+ --app-name "Safari Pilot" \
18
+ --bundle-identifier "$BUNDLE_ID" \
19
+ --macos-only \
20
+ --no-open \
21
+ --no-prompt \
22
+ --force
23
+
24
+ # The packager generates the project inside a subdirectory named after the app
25
+ # Resulting path: app/Safari Pilot/Safari Pilot.xcodeproj
26
+ if [ ! -d "$XCODE_PROJECT_DIR/Safari Pilot.xcodeproj" ]; then
27
+ echo "ERROR: Xcode project not found at expected location: $XCODE_PROJECT_DIR/Safari Pilot.xcodeproj"
28
+ exit 1
29
+ fi
30
+
31
+ # Step 2: Fix bundle identifier in generated project
32
+ # The packager sets the app's bundle ID to com.safari-pilot.Safari-Pilot (derived from name)
33
+ # instead of our explicit com.safari-pilot.app — causing embedded binary validation failure.
34
+ # Fix: replace the auto-derived ID with our explicit bundle ID in both Debug and Release configs.
35
+ PBXPROJ="$XCODE_PROJECT_DIR/Safari Pilot.xcodeproj/project.pbxproj"
36
+ echo "Fixing bundle identifier in Xcode project..."
37
+ sed -i '' "s/PRODUCT_BUNDLE_IDENTIFIER = \"com.safari-pilot.Safari-Pilot\";/PRODUCT_BUNDLE_IDENTIFIER = \"$BUNDLE_ID\";/g" "$PBXPROJ"
38
+
39
+ # Step 3: Create placeholder Icon.png if missing
40
+ # The packager references Icon.png in the project but doesn't create it.
41
+ ICON_PATH="$XCODE_PROJECT_DIR/Safari Pilot/Resources/Icon.png"
42
+ if [ ! -f "$ICON_PATH" ]; then
43
+ echo "Creating placeholder Icon.png..."
44
+ python3 -c "
45
+ import struct, zlib
46
+
47
+ def png_chunk(name, data):
48
+ chunk = name + data
49
+ return struct.pack('>I', len(data)) + chunk + struct.pack('>I', zlib.crc32(chunk) & 0xffffffff)
50
+
51
+ w, h = 16, 16
52
+ raw = b''
53
+ for y in range(h):
54
+ raw += b'\x00'
55
+ for x in range(w):
56
+ raw += bytes([100, 100, 100])
57
+
58
+ sig = b'\x89PNG\r\n\x1a\n'
59
+ ihdr_data = struct.pack('>IIBBBBB', w, h, 8, 2, 0, 0, 0)
60
+ idat_data = zlib.compress(raw)
61
+
62
+ png = sig + png_chunk(b'IHDR', ihdr_data) + png_chunk(b'IDAT', idat_data) + png_chunk(b'IEND', b'')
63
+
64
+ with open('$ICON_PATH', 'wb') as f:
65
+ f.write(png)
66
+ "
67
+ fi
68
+
69
+ # Step 4: Build the app
70
+ echo "Building app (Release)..."
71
+ cd "$XCODE_PROJECT_DIR"
72
+ xcodebuild \
73
+ -project "Safari Pilot.xcodeproj" \
74
+ -scheme "Safari Pilot" \
75
+ -configuration Release \
76
+ -derivedDataPath "$ROOT/.build/extension" \
77
+ build 2>&1
78
+
79
+ # Step 5: Copy built app to bin/
80
+ APP_PATH=$(find "$ROOT/.build/extension" -name "Safari Pilot.app" -type d | head -1)
81
+ if [ -n "$APP_PATH" ]; then
82
+ echo "Built app at: $APP_PATH"
83
+ mkdir -p "$ROOT/bin"
84
+ rm -rf "$ROOT/bin/Safari Pilot.app"
85
+ cp -R "$APP_PATH" "$ROOT/bin/Safari Pilot.app"
86
+ echo "Copied to bin/Safari Pilot.app"
87
+ else
88
+ echo "ERROR: Built app not found in derived data"
89
+ exit 1
90
+ fi
91
+
92
+ echo "=== Build complete ==="
93
+
94
+ # ── Signing & Notarization ───────────────────────────────────────────────────
95
+
96
+ SIGN_IDENTITY="Developer ID Application: Aakash Kumar (V37WLKRXUJ)"
97
+ APP_PATH="$ROOT/bin/Safari Pilot.app"
98
+ APPEX_PATH="$APP_PATH/Contents/PlugIns/Safari Pilot Extension.appex"
99
+
100
+ echo "=== Signing Extension ==="
101
+
102
+ # Step 6: Sign the .appex FIRST (inside-out — NEVER use --deep)
103
+ echo "Signing .appex..."
104
+ codesign --force --options runtime --timestamp \
105
+ --sign "$SIGN_IDENTITY" \
106
+ "$APPEX_PATH"
107
+
108
+ # Step 7: Sign the .app container
109
+ echo "Signing .app..."
110
+ codesign --force --options runtime --timestamp \
111
+ --sign "$SIGN_IDENTITY" \
112
+ "$APP_PATH"
113
+
114
+ # Step 8: Verify signature
115
+ echo "Verifying signatures..."
116
+ codesign --verify --deep --strict --verbose=2 "$APP_PATH"
117
+ spctl -a -t exec -vv "$APP_PATH"
118
+
119
+ echo "=== Notarizing ==="
120
+
121
+ # Step 9: Create ZIP for notarization
122
+ ditto -c -k --keepParent "$APP_PATH" "$ROOT/bin/Safari Pilot.zip"
123
+
124
+ # Step 10: Submit for notarization and wait
125
+ xcrun notarytool submit "$ROOT/bin/Safari Pilot.zip" \
126
+ --keychain-profile "apple-notarytool" --wait
127
+
128
+ # Step 11: Staple the ticket
129
+ xcrun stapler staple "$APP_PATH"
130
+
131
+ # Step 12: Re-zip the stapled app for distribution
132
+ rm "$ROOT/bin/Safari Pilot.zip"
133
+ ditto -c -k --keepParent "$APP_PATH" "$ROOT/bin/Safari Pilot.zip"
134
+
135
+ echo "=== Signed, Notarized, and Stapled ==="