@gricha/perry 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -85,6 +85,14 @@ Open http://localhost:7391 (or your Tailscale host) and click "+" to create a wo
85
85
 
86
86
  Perry is designed for use within **secure private networks** like [Tailscale](https://tailscale.com). The web UI and API currently have no authentication - this is intentional for private network use where all devices are trusted.
87
87
 
88
+ NOTE: Using this software can be dangerous, don't expose it on the network. Any user that can access perry's web server may be able to do serious damage to your system. Keep it closed in Tailscale network.
89
+
90
+ Perry by default allows the API to interact with the host machine as well - while it's intended purpose is to manage docker containers, sometimes, for simplicity I run some of my jobs directly on my machine. This can be disabled. When you start perry, you can pass a `--no-host-access` flag.
91
+
92
+ `perry agent run --no-host-access`
93
+
94
+ This will ensure that perry can only stand up/tear down docker containers. While this reduces the attack surface, it is only as good as docker is as sandbox (and it may very well not be).
95
+
88
96
  ## Configuration
89
97
 
90
98
  Configure credentials and agent settings via Web UI → Settings or edit `~/.config/perry/config.json`:
@@ -0,0 +1,138 @@
1
+ import { watch } from 'fs';
2
+ import { access } from 'fs/promises';
3
+ import { expandPath } from '../config/loader';
4
+ const STANDARD_CREDENTIAL_FILES = [
5
+ '~/.gitconfig',
6
+ '~/.claude/.credentials.json',
7
+ '~/.codex/auth.json',
8
+ '~/.codex/config.toml',
9
+ ];
10
+ export class FileWatcher {
11
+ watchers = new Map();
12
+ config;
13
+ syncCallback;
14
+ debounceMs;
15
+ debounceTimer = null;
16
+ pendingSync = false;
17
+ constructor(options) {
18
+ this.config = options.config;
19
+ this.syncCallback = options.syncCallback;
20
+ this.debounceMs = options.debounceMs ?? 500;
21
+ this.setupWatchers();
22
+ }
23
+ updateConfig(config) {
24
+ this.config = config;
25
+ this.rebuildWatchers();
26
+ }
27
+ stop() {
28
+ if (this.debounceTimer) {
29
+ clearTimeout(this.debounceTimer);
30
+ this.debounceTimer = null;
31
+ }
32
+ for (const [filePath, watcher] of this.watchers) {
33
+ watcher.close();
34
+ console.log(`[file-watcher] Stopped watching: ${filePath}`);
35
+ }
36
+ this.watchers.clear();
37
+ }
38
+ collectWatchPaths() {
39
+ const paths = new Set();
40
+ for (const sourcePath of Object.values(this.config.credentials.files)) {
41
+ paths.add(expandPath(sourcePath));
42
+ }
43
+ for (const stdPath of STANDARD_CREDENTIAL_FILES) {
44
+ paths.add(expandPath(stdPath));
45
+ }
46
+ if (this.config.ssh?.global?.copy) {
47
+ for (const keyPath of this.config.ssh.global.copy) {
48
+ paths.add(expandPath(keyPath));
49
+ }
50
+ }
51
+ if (this.config.ssh?.workspaces) {
52
+ for (const wsConfig of Object.values(this.config.ssh.workspaces)) {
53
+ if (wsConfig.copy) {
54
+ for (const keyPath of wsConfig.copy) {
55
+ paths.add(expandPath(keyPath));
56
+ }
57
+ }
58
+ }
59
+ }
60
+ return Array.from(paths);
61
+ }
62
+ async setupWatchers() {
63
+ const paths = this.collectWatchPaths();
64
+ for (const filePath of paths) {
65
+ await this.watchFile(filePath);
66
+ }
67
+ }
68
+ async watchFile(filePath) {
69
+ if (this.watchers.has(filePath)) {
70
+ return;
71
+ }
72
+ try {
73
+ await access(filePath);
74
+ }
75
+ catch {
76
+ return;
77
+ }
78
+ try {
79
+ const watcher = watch(filePath, (eventType) => {
80
+ if (eventType === 'change' || eventType === 'rename') {
81
+ this.handleFileChange(filePath);
82
+ }
83
+ });
84
+ watcher.on('error', (err) => {
85
+ console.error(`[file-watcher] Error watching ${filePath}:`, err);
86
+ this.watchers.delete(filePath);
87
+ });
88
+ this.watchers.set(filePath, watcher);
89
+ console.log(`[file-watcher] Watching: ${filePath}`);
90
+ }
91
+ catch (err) {
92
+ console.error(`[file-watcher] Failed to watch ${filePath}:`, err);
93
+ }
94
+ }
95
+ handleFileChange(filePath) {
96
+ console.log(`[file-watcher] Change detected: ${filePath}`);
97
+ this.scheduleSync();
98
+ }
99
+ scheduleSync() {
100
+ if (this.debounceTimer) {
101
+ clearTimeout(this.debounceTimer);
102
+ }
103
+ this.pendingSync = true;
104
+ this.debounceTimer = setTimeout(async () => {
105
+ this.debounceTimer = null;
106
+ if (this.pendingSync) {
107
+ this.pendingSync = false;
108
+ try {
109
+ console.log('[file-watcher] Triggering sync...');
110
+ await this.syncCallback();
111
+ console.log('[file-watcher] Sync completed');
112
+ }
113
+ catch (err) {
114
+ console.error('[file-watcher] Sync failed:', err);
115
+ }
116
+ }
117
+ }, this.debounceMs);
118
+ }
119
+ rebuildWatchers() {
120
+ const newPaths = new Set(this.collectWatchPaths());
121
+ const currentPaths = new Set(this.watchers.keys());
122
+ for (const filePath of currentPaths) {
123
+ if (!newPaths.has(filePath)) {
124
+ const watcher = this.watchers.get(filePath);
125
+ if (watcher) {
126
+ watcher.close();
127
+ this.watchers.delete(filePath);
128
+ console.log(`[file-watcher] Stopped watching: ${filePath}`);
129
+ }
130
+ }
131
+ }
132
+ for (const filePath of newPaths) {
133
+ if (!currentPaths.has(filePath)) {
134
+ this.watchFile(filePath);
135
+ }
136
+ }
137
+ }
138
+ }
@@ -3,7 +3,7 @@ import * as z from 'zod';
3
3
  import os_module from 'os';
4
4
  import { promises as fs } from 'fs';
5
5
  import path from 'path';
6
- import { HOST_WORKSPACE_NAME } from '../shared/types';
6
+ import { HOST_WORKSPACE_NAME } from '../shared/client-types';
7
7
  import { getDockerVersion, execInContainer } from '../docker';
8
8
  import { saveAgentConfig } from '../config/loader';
9
9
  import { setSessionName, getSessionNamesForWorkspace, deleteSessionName, } from '../sessions/metadata';
@@ -222,6 +222,7 @@ export function createRouter(ctx) {
222
222
  const newConfig = { ...currentConfig, credentials: input };
223
223
  ctx.config.set(newConfig);
224
224
  await saveAgentConfig(newConfig, ctx.configDir);
225
+ ctx.triggerAutoSync();
225
226
  return input;
226
227
  });
227
228
  const getScripts = os.output(ScriptsSchema).handler(async () => {
@@ -235,6 +236,7 @@ export function createRouter(ctx) {
235
236
  const newConfig = { ...currentConfig, scripts: input };
236
237
  ctx.config.set(newConfig);
237
238
  await saveAgentConfig(newConfig, ctx.configDir);
239
+ ctx.triggerAutoSync();
238
240
  return input;
239
241
  });
240
242
  const getAgents = os.output(CodingAgentsSchema).handler(async () => {
@@ -248,6 +250,7 @@ export function createRouter(ctx) {
248
250
  const newConfig = { ...currentConfig, agents: input };
249
251
  ctx.config.set(newConfig);
250
252
  await saveAgentConfig(newConfig, ctx.configDir);
253
+ ctx.triggerAutoSync();
251
254
  return input;
252
255
  });
253
256
  const getSSHSettings = os.output(SSHSettingsSchema).handler(async () => {
@@ -266,6 +269,7 @@ export function createRouter(ctx) {
266
269
  const newConfig = { ...currentConfig, ssh: input };
267
270
  ctx.config.set(newConfig);
268
271
  await saveAgentConfig(newConfig, ctx.configDir);
272
+ ctx.triggerAutoSync();
269
273
  return input;
270
274
  });
271
275
  const listSSHKeys = os.output(z.array(SSHKeyInfoSchema)).handler(async () => {
package/dist/agent/run.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createServer } from 'http';
2
2
  import { RPCHandler } from '@orpc/server/node';
3
3
  import { loadAgentConfig, getConfigDir, ensureConfigDir } from '../config/loader';
4
- import { HOST_WORKSPACE_NAME } from '../shared/types';
4
+ import { HOST_WORKSPACE_NAME } from '../shared/client-types';
5
5
  import { DEFAULT_AGENT_PORT } from '../shared/constants';
6
6
  import { WorkspaceManager } from '../workspace/manager';
7
7
  import { containerRunning, getContainerName } from '../docker';
@@ -13,6 +13,7 @@ import { createRouter } from './router';
13
13
  import { serveStatic } from './static';
14
14
  import { SessionsCacheManager } from '../sessions/cache';
15
15
  import { ModelCacheManager } from '../models/cache';
16
+ import { FileWatcher } from './file-watcher';
16
17
  import { getTailscaleStatus, getTailscaleIdentity, startTailscaleServe, stopTailscaleServe, } from '../tailscale';
17
18
  import pkg from '../../package.json';
18
19
  const startTime = Date.now();
@@ -25,6 +26,23 @@ function createAgentServer(configDir, config, tailscale) {
25
26
  const workspaces = new WorkspaceManager(configDir, currentConfig);
26
27
  const sessionsCache = new SessionsCacheManager(configDir);
27
28
  const modelCache = new ModelCacheManager(configDir);
29
+ const syncAllRunning = async () => {
30
+ const allWorkspaces = await workspaces.list();
31
+ const running = allWorkspaces.filter((ws) => ws.status === 'running');
32
+ for (const ws of running) {
33
+ try {
34
+ await workspaces.sync(ws.name);
35
+ console.log(`[sync] Synced workspace: ${ws.name}`);
36
+ }
37
+ catch (err) {
38
+ console.error(`[sync] Failed to sync ${ws.name}:`, err);
39
+ }
40
+ }
41
+ };
42
+ const fileWatcher = new FileWatcher({
43
+ config: currentConfig,
44
+ syncCallback: syncAllRunning,
45
+ });
28
46
  const isWorkspaceRunning = async (name) => {
29
47
  if (name === HOST_WORKSPACE_NAME) {
30
48
  return currentConfig.allowHostAccess === true;
@@ -46,6 +64,11 @@ function createAgentServer(configDir, config, tailscale) {
46
64
  isHostAccessAllowed: () => currentConfig.allowHostAccess === true,
47
65
  getConfig: () => currentConfig,
48
66
  });
67
+ const triggerAutoSync = () => {
68
+ syncAllRunning().catch((err) => {
69
+ console.error('[sync] Auto-sync failed:', err);
70
+ });
71
+ };
49
72
  const router = createRouter({
50
73
  workspaces,
51
74
  config: {
@@ -53,6 +76,7 @@ function createAgentServer(configDir, config, tailscale) {
53
76
  set: (newConfig) => {
54
77
  currentConfig = newConfig;
55
78
  workspaces.updateConfig(newConfig);
79
+ fileWatcher.updateConfig(newConfig);
56
80
  },
57
81
  },
58
82
  configDir,
@@ -62,6 +86,7 @@ function createAgentServer(configDir, config, tailscale) {
62
86
  sessionsCache,
63
87
  modelCache,
64
88
  tailscale,
89
+ triggerAutoSync,
65
90
  });
66
91
  const rpcHandler = new RPCHandler(router);
67
92
  const server = createServer(async (req, res) => {
@@ -125,7 +150,7 @@ function createAgentServer(configDir, config, tailscale) {
125
150
  socket.destroy();
126
151
  }
127
152
  });
128
- return { server, terminalServer, chatServer, opencodeServer };
153
+ return { server, terminalServer, chatServer, opencodeServer, fileWatcher };
129
154
  }
130
155
  async function getProcessUsingPort(port) {
131
156
  try {
@@ -189,7 +214,7 @@ export async function startAgent(options = {}) {
189
214
  httpsUrl: tailscaleServeActive ? `https://${tailscale.dnsName}` : undefined,
190
215
  }
191
216
  : undefined;
192
- const { server, terminalServer, chatServer, opencodeServer } = createAgentServer(configDir, config, tailscaleInfo);
217
+ const { server, terminalServer, chatServer, opencodeServer, fileWatcher } = createAgentServer(configDir, config, tailscaleInfo);
193
218
  server.on('error', async (err) => {
194
219
  if (err.code === 'EADDRINUSE') {
195
220
  console.error(`[agent] Error: Port ${port} is already in use.`);
@@ -222,6 +247,7 @@ export async function startAgent(options = {}) {
222
247
  });
223
248
  const shutdown = async () => {
224
249
  console.log('[agent] Shutting down...');
250
+ fileWatcher.stop();
225
251
  if (tailscaleServeActive) {
226
252
  console.log('[agent] Stopping Tailscale Serve...');
227
253
  await stopTailscaleServe();