@siftd/connect-agent 0.2.31 → 0.2.33

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.
@@ -8,7 +8,7 @@
8
8
  * - asset.group - Get assets by group
9
9
  * - view.get - Get ViewSpec
10
10
  */
11
- import { AssetManifest, AssetPreview, ViewSpec } from './assets.js';
11
+ import { AssetManifest, AssetPreview, ViewSpec, ClientAssetManifest } from './assets.js';
12
12
  export interface AssetRequest {
13
13
  type: 'asset.list' | 'asset.get' | 'asset.preview' | 'asset.group' | 'view.get' | 'view.list';
14
14
  requestId: string;
@@ -19,7 +19,7 @@ export interface AssetRequest {
19
19
  export interface AssetListResponse {
20
20
  type: 'asset.list';
21
21
  requestId: string;
22
- assets: AssetManifest[];
22
+ assets: ClientAssetManifest[];
23
23
  }
24
24
  export interface AssetGetResponse {
25
25
  type: 'asset.get';
@@ -33,14 +33,14 @@ export interface AssetPreviewResponse {
33
33
  type: 'asset.preview';
34
34
  requestId: string;
35
35
  assetId: string;
36
- manifest: AssetManifest;
36
+ manifest: ClientAssetManifest;
37
37
  preview: AssetPreview | null;
38
38
  }
39
39
  export interface AssetGroupResponse {
40
40
  type: 'asset.group';
41
41
  requestId: string;
42
42
  groupId: string;
43
- assets: AssetManifest[];
43
+ assets: ClientAssetManifest[];
44
44
  previews: (AssetPreview | null)[];
45
45
  }
46
46
  export interface ViewGetResponse {
@@ -79,11 +79,11 @@ export interface GalleryState {
79
79
  export declare function buildGalleryState(): GalleryState;
80
80
  /**
81
81
  * Convert gallery state to WebSocket message format
82
- * Compatible with existing gallery_workers message
82
+ * Strips absolute paths from assets for client safety
83
83
  */
84
84
  export declare function galleryStateToMessage(state: GalleryState): {
85
85
  type: 'gallery_state';
86
- assets: AssetManifest[];
86
+ assets: ClientAssetManifest[];
87
87
  previews: {
88
88
  [key: string]: AssetPreview;
89
89
  };
@@ -9,7 +9,25 @@
9
9
  * - view.get - Get ViewSpec
10
10
  */
11
11
  import { readFileSync, existsSync, statSync } from 'fs';
12
+ import { toClientManifest, isPathWithinRoot, } from './assets.js';
12
13
  import { getAsset, getPreview, getAllAssets, getAssetsByGroup, getAssetRegistry, } from './preview-worker.js';
14
+ import { getSharedOutputPath } from './hub.js';
15
+ // Allowed roots for asset access (security)
16
+ function getAllowedRoots() {
17
+ const home = process.env.HOME || '/tmp';
18
+ return [
19
+ getSharedOutputPath(),
20
+ `${home}/Lia-Hub`,
21
+ '/tmp',
22
+ ];
23
+ }
24
+ /**
25
+ * Validate that a path is within allowed roots (uses realpath to prevent traversal/symlink escapes)
26
+ */
27
+ function isPathAllowed(filePath) {
28
+ const allowedRoots = getAllowedRoots();
29
+ return allowedRoots.some(root => isPathWithinRoot(filePath, root));
30
+ }
13
31
  // ============================================================================
14
32
  // VIEW REGISTRY
15
33
  // ============================================================================
@@ -64,7 +82,7 @@ function handleAssetList(request) {
64
82
  return {
65
83
  type: 'asset.list',
66
84
  requestId: request.requestId,
67
- assets: getAllAssets(),
85
+ assets: getAllAssets().map(toClientManifest),
68
86
  };
69
87
  }
70
88
  function handleAssetGet(request) {
@@ -76,8 +94,12 @@ function handleAssetGet(request) {
76
94
  if (!manifest) {
77
95
  return { type: 'asset.error', requestId, error: `Asset not found: ${assetId}` };
78
96
  }
97
+ // Security: validate path is within allowed roots
98
+ if (!isPathAllowed(manifest.path)) {
99
+ return { type: 'asset.error', requestId, error: 'Access denied' };
100
+ }
79
101
  if (!existsSync(manifest.path)) {
80
- return { type: 'asset.error', requestId, error: `File not found: ${manifest.path}` };
102
+ return { type: 'asset.error', requestId, error: 'File not found' };
81
103
  }
82
104
  // Read file and encode as base64
83
105
  const data = readFileSync(manifest.path);
@@ -105,7 +127,7 @@ function handleAssetPreview(request) {
105
127
  type: 'asset.preview',
106
128
  requestId,
107
129
  assetId,
108
- manifest,
130
+ manifest: toClientManifest(manifest),
109
131
  preview,
110
132
  };
111
133
  }
@@ -120,7 +142,7 @@ function handleAssetGroup(request) {
120
142
  type: 'asset.group',
121
143
  requestId,
122
144
  groupId,
123
- assets,
145
+ assets: assets.map(toClientManifest),
124
146
  previews,
125
147
  };
126
148
  }
@@ -161,7 +183,7 @@ export function buildGalleryState() {
161
183
  }
162
184
  /**
163
185
  * Convert gallery state to WebSocket message format
164
- * Compatible with existing gallery_workers message
186
+ * Strips absolute paths from assets for client safety
165
187
  */
166
188
  export function galleryStateToMessage(state) {
167
189
  const previews = {};
@@ -174,7 +196,7 @@ export function galleryStateToMessage(state) {
174
196
  }
175
197
  return {
176
198
  type: 'gallery_state',
177
- assets: state.assets,
199
+ assets: state.assets.map(toClientManifest),
178
200
  previews,
179
201
  groups,
180
202
  views: state.views,
@@ -5,9 +5,9 @@
5
5
  * The preview-worker generates thumbnails/metadata WITHOUT using the LLM.
6
6
  */
7
7
  export interface AssetManifest {
8
- /** Unique asset ID */
8
+ /** Unique asset ID (content-addressed) */
9
9
  id: string;
10
- /** Absolute path to the file */
10
+ /** Absolute path to the file (INTERNAL ONLY - never send to client) */
11
11
  path: string;
12
12
  /** Filename */
13
13
  name: string;
@@ -28,6 +28,29 @@ export interface AssetManifest {
28
28
  /** For kind=view, the ViewSpec */
29
29
  viewSpec?: ViewSpec;
30
30
  }
31
+ /**
32
+ * Client-safe asset manifest - no absolute paths exposed
33
+ */
34
+ export interface ClientAssetManifest {
35
+ id: string;
36
+ name: string;
37
+ mime: string;
38
+ sha256: string;
39
+ size: number;
40
+ createdAt: number;
41
+ groupId?: string;
42
+ label?: 'old' | 'new' | 'diff' | 'output' | 'source';
43
+ kind: 'file' | 'view';
44
+ viewSpec?: ViewSpec;
45
+ }
46
+ /**
47
+ * Convert internal manifest to client-safe version (strips path)
48
+ */
49
+ export declare function toClientManifest(manifest: AssetManifest): ClientAssetManifest;
50
+ /**
51
+ * Validate that a resolved path is within an allowed root (prevents traversal/symlink escapes)
52
+ */
53
+ export declare function isPathWithinRoot(filePath: string, rootDir: string): boolean;
31
54
  export interface AssetPreview {
32
55
  /** Reference to the asset */
33
56
  assetId: string;
@@ -124,15 +147,18 @@ export declare function hashFile(filePath: string): string;
124
147
  */
125
148
  export declare function getMimeType(filePath: string): string;
126
149
  /**
127
- * Generate unique asset ID
150
+ * Generate deterministic asset ID from content hash + relative path
151
+ * Content-addressed: same file always gets same ID
128
152
  */
129
- export declare function generateAssetId(): string;
153
+ export declare function generateAssetId(sha256: string, relativePath?: string): string;
130
154
  /**
131
155
  * Create asset manifest for a file
156
+ * @param rootDir - Root directory for computing relative path (for ID stability)
132
157
  */
133
158
  export declare function createAssetManifest(filePath: string, options?: {
134
159
  groupId?: string;
135
160
  label?: AssetManifest['label'];
161
+ rootDir?: string;
136
162
  }): AssetManifest;
137
163
  /**
138
164
  * Write asset manifest alongside the file
@@ -148,6 +174,7 @@ export declare function readAssetManifest(filePath: string): AssetManifest | nul
148
174
  export declare function getPreviewCacheDir(): string;
149
175
  /**
150
176
  * Generate cache key for preview
177
+ * Uses canonical stringify for stable key generation regardless of key order
151
178
  */
152
179
  export declare function getPreviewCacheKey(sha256: string, params?: ViewerParams): string;
153
180
  /**
@@ -5,8 +5,28 @@
5
5
  * The preview-worker generates thumbnails/metadata WITHOUT using the LLM.
6
6
  */
7
7
  import { createHash } from 'crypto';
8
- import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from 'fs';
9
- import { basename, extname, join } from 'path';
8
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync, realpathSync } from 'fs';
9
+ import { basename, extname, join, relative, sep } from 'path';
10
+ /**
11
+ * Convert internal manifest to client-safe version (strips path)
12
+ */
13
+ export function toClientManifest(manifest) {
14
+ const { path, ...clientSafe } = manifest;
15
+ return clientSafe;
16
+ }
17
+ /**
18
+ * Validate that a resolved path is within an allowed root (prevents traversal/symlink escapes)
19
+ */
20
+ export function isPathWithinRoot(filePath, rootDir) {
21
+ try {
22
+ const resolvedPath = realpathSync(filePath);
23
+ const resolvedRoot = realpathSync(rootDir);
24
+ return resolvedPath.startsWith(resolvedRoot + sep);
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ }
10
30
  // ============================================================================
11
31
  // UTILITY FUNCTIONS
12
32
  // ============================================================================
@@ -63,24 +83,35 @@ export function getMimeType(filePath) {
63
83
  return mimeMap[ext] || 'application/octet-stream';
64
84
  }
65
85
  /**
66
- * Generate unique asset ID
86
+ * Generate deterministic asset ID from content hash + relative path
87
+ * Content-addressed: same file always gets same ID
67
88
  */
68
- export function generateAssetId() {
69
- const timestamp = Date.now().toString(36);
70
- const random = Math.random().toString(36).substring(2, 8);
71
- return `asset_${timestamp}_${random}`;
89
+ export function generateAssetId(sha256, relativePath) {
90
+ // Use first 12 chars of sha256 + path hash for uniqueness
91
+ const prefix = sha256.substring(0, 12);
92
+ if (relativePath) {
93
+ const pathHash = createHash('sha256').update(relativePath).digest('hex').substring(0, 6);
94
+ return `asset_${prefix}_${pathHash}`;
95
+ }
96
+ return `asset_${prefix}`;
72
97
  }
73
98
  /**
74
99
  * Create asset manifest for a file
100
+ * @param rootDir - Root directory for computing relative path (for ID stability)
75
101
  */
76
102
  export function createAssetManifest(filePath, options = {}) {
77
103
  const stats = statSync(filePath);
104
+ const sha256 = hashFile(filePath);
105
+ // Compute relative path using path.relative() for stable IDs
106
+ const relativePath = options.rootDir
107
+ ? relative(options.rootDir, filePath)
108
+ : basename(filePath);
78
109
  return {
79
- id: generateAssetId(),
110
+ id: generateAssetId(sha256, relativePath),
80
111
  path: filePath,
81
112
  name: basename(filePath),
82
113
  mime: getMimeType(filePath),
83
- sha256: hashFile(filePath),
114
+ sha256,
84
115
  size: stats.size,
85
116
  createdAt: Date.now(),
86
117
  groupId: options.groupId,
@@ -120,11 +151,26 @@ export function getPreviewCacheDir() {
120
151
  }
121
152
  return cacheDir;
122
153
  }
154
+ /**
155
+ * Canonical JSON stringify - sorts keys recursively for stable hashing
156
+ */
157
+ function canonicalStringify(obj) {
158
+ if (obj === null || typeof obj !== 'object') {
159
+ return JSON.stringify(obj);
160
+ }
161
+ if (Array.isArray(obj)) {
162
+ return '[' + obj.map(canonicalStringify).join(',') + ']';
163
+ }
164
+ const sortedKeys = Object.keys(obj).sort();
165
+ const pairs = sortedKeys.map(key => JSON.stringify(key) + ':' + canonicalStringify(obj[key]));
166
+ return '{' + pairs.join(',') + '}';
167
+ }
123
168
  /**
124
169
  * Generate cache key for preview
170
+ * Uses canonical stringify for stable key generation regardless of key order
125
171
  */
126
172
  export function getPreviewCacheKey(sha256, params) {
127
- const paramsStr = params ? JSON.stringify(params) : '';
173
+ const paramsStr = params ? canonicalStringify(params) : '';
128
174
  return createHash('sha256').update(sha256 + paramsStr).digest('hex').substring(0, 16);
129
175
  }
130
176
  /**
@@ -8,6 +8,10 @@
8
8
  *
9
9
  * NO LLM involvement - pure local, deterministic processing.
10
10
  * Targets sub-second response for most assets.
11
+ *
12
+ * Uses chokidar for reliable cross-platform file watching with:
13
+ * - awaitWriteFinish to handle half-written files
14
+ * - Ignore patterns to avoid loops (*.asset.json, thumbs, etc.)
11
15
  */
12
16
  import { AssetManifest, AssetPreview } from './assets.js';
13
17
  export interface PreviewWorkerOptions {
@@ -23,25 +27,27 @@ export interface PreviewWorkerOptions {
23
27
  verbose?: boolean;
24
28
  }
25
29
  export declare class PreviewWorker {
26
- private watchers;
30
+ private watcher;
27
31
  private queue;
28
32
  private processing;
33
+ private debounceTimers;
29
34
  private concurrency;
30
35
  private options;
31
36
  private running;
37
+ private rootDir;
32
38
  constructor(options?: PreviewWorkerOptions);
33
39
  /**
34
40
  * Start watching directories
35
41
  */
36
42
  start(): void;
37
43
  /**
38
- * Stop watching
44
+ * Handle file add/change with debouncing
39
45
  */
40
- stop(): void;
46
+ private handleFile;
41
47
  /**
42
- * Scan directory for existing files
48
+ * Stop watching
43
49
  */
44
- private scanDirectory;
50
+ stop(): void;
45
51
  /**
46
52
  * Add file to processing queue
47
53
  */
@@ -8,23 +8,41 @@
8
8
  *
9
9
  * NO LLM involvement - pure local, deterministic processing.
10
10
  * Targets sub-second response for most assets.
11
+ *
12
+ * Uses chokidar for reliable cross-platform file watching with:
13
+ * - awaitWriteFinish to handle half-written files
14
+ * - Ignore patterns to avoid loops (*.asset.json, thumbs, etc.)
11
15
  */
12
- import { watch } from 'fs';
13
- import { readFileSync, existsSync, readdirSync, statSync, mkdirSync } from 'fs';
16
+ import chokidar from 'chokidar';
17
+ import { readFileSync, existsSync, mkdirSync } from 'fs';
14
18
  import { join, basename } from 'path';
15
19
  import { execSync } from 'child_process';
16
20
  import { createAssetManifest, writeAssetManifest, readAssetManifest, getPreviewCacheDir, getPreviewCacheKey, getPreviewFromCache, savePreviewToCache, } from './assets.js';
17
21
  import { getSharedOutputPath } from './hub.js';
22
+ // Files to ignore when watching (prevents watcher loops)
23
+ const IGNORE_PATTERNS = [
24
+ '**/*.asset.json',
25
+ '**/*.preview.json',
26
+ '**/.thumbs/**',
27
+ '**/thumbs/**',
28
+ '**/*.tmp',
29
+ '**/.DS_Store',
30
+ '**/node_modules/**',
31
+ '**/.git/**',
32
+ ];
18
33
  export class PreviewWorker {
19
- watchers = [];
34
+ watcher = null;
20
35
  queue = [];
21
36
  processing = new Set();
37
+ debounceTimers = new Map();
22
38
  concurrency;
23
39
  options;
24
40
  running = false;
41
+ rootDir;
25
42
  constructor(options = {}) {
26
43
  this.options = options;
27
44
  this.concurrency = options.concurrency || 4;
45
+ this.rootDir = getSharedOutputPath();
28
46
  }
29
47
  /**
30
48
  * Start watching directories
@@ -33,65 +51,59 @@ export class PreviewWorker {
33
51
  if (this.running)
34
52
  return;
35
53
  this.running = true;
36
- const dirs = this.options.watchDirs || [getSharedOutputPath()];
54
+ const dirs = this.options.watchDirs || [this.rootDir];
37
55
  for (const dir of dirs) {
38
56
  if (!existsSync(dir)) {
39
57
  mkdirSync(dir, { recursive: true });
40
58
  }
41
- this.log(`Watching: ${dir}`);
42
- // Initial scan
43
- this.scanDirectory(dir);
44
- // Watch for changes
45
- const watcher = watch(dir, { recursive: true }, (event, filename) => {
46
- if (!filename)
47
- return;
48
- const filePath = join(dir, filename);
49
- // Skip manifest files and hidden files
50
- if (filename.endsWith('.asset.json') || filename.startsWith('.'))
51
- return;
52
- // Skip directories
53
- try {
54
- if (statSync(filePath).isDirectory())
55
- return;
56
- }
57
- catch {
58
- return; // File may have been deleted
59
- }
60
- this.enqueue(filePath);
61
- });
62
- this.watchers.push(watcher);
63
59
  }
64
- // Start processing queue
65
- this.processQueue();
60
+ this.log(`Watching: ${dirs.join(', ')}`);
61
+ // Use chokidar for reliable watching with:
62
+ // - awaitWriteFinish: wait until file size is stable (handles half-written files)
63
+ // - ignored: prevent watcher loops
64
+ this.watcher = chokidar.watch(dirs, {
65
+ ignoreInitial: false, // Process existing files on startup
66
+ persistent: true,
67
+ awaitWriteFinish: {
68
+ stabilityThreshold: 400, // Wait 400ms for file size to stabilize
69
+ pollInterval: 100,
70
+ },
71
+ ignored: IGNORE_PATTERNS,
72
+ depth: 10,
73
+ });
74
+ this.watcher.on('add', (filePath) => this.handleFile(filePath));
75
+ this.watcher.on('change', (filePath) => this.handleFile(filePath));
76
+ this.watcher.on('error', (error) => this.log(`Watcher error: ${error}`));
66
77
  }
67
78
  /**
68
- * Stop watching
79
+ * Handle file add/change with debouncing
69
80
  */
70
- stop() {
71
- this.running = false;
72
- for (const watcher of this.watchers) {
73
- watcher.close();
81
+ handleFile(filePath) {
82
+ // Clear existing debounce timer for this file
83
+ const existingTimer = this.debounceTimers.get(filePath);
84
+ if (existingTimer) {
85
+ clearTimeout(existingTimer);
74
86
  }
75
- this.watchers = [];
87
+ // Debounce: wait 100ms before processing to batch rapid changes
88
+ const timer = setTimeout(() => {
89
+ this.debounceTimers.delete(filePath);
90
+ this.enqueue(filePath);
91
+ }, 100);
92
+ this.debounceTimers.set(filePath, timer);
76
93
  }
77
94
  /**
78
- * Scan directory for existing files
95
+ * Stop watching
79
96
  */
80
- scanDirectory(dir) {
81
- try {
82
- const entries = readdirSync(dir, { withFileTypes: true });
83
- for (const entry of entries) {
84
- const fullPath = join(dir, entry.name);
85
- if (entry.isDirectory()) {
86
- this.scanDirectory(fullPath);
87
- }
88
- else if (!entry.name.endsWith('.asset.json') && !entry.name.startsWith('.')) {
89
- this.enqueue(fullPath);
90
- }
91
- }
97
+ stop() {
98
+ this.running = false;
99
+ // Clear all debounce timers
100
+ for (const timer of this.debounceTimers.values()) {
101
+ clearTimeout(timer);
92
102
  }
93
- catch (error) {
94
- this.log(`Scan error: ${error}`);
103
+ this.debounceTimers.clear();
104
+ if (this.watcher) {
105
+ this.watcher.close();
106
+ this.watcher = null;
95
107
  }
96
108
  }
97
109
  /**
@@ -132,9 +144,10 @@ export class PreviewWorker {
132
144
  // Check for existing manifest
133
145
  let manifest = readAssetManifest(filePath);
134
146
  if (!manifest) {
135
- // Create new manifest
147
+ // Create new manifest with rootDir for stable IDs
136
148
  manifest = createAssetManifest(filePath, {
137
149
  groupId: this.extractGroupId(filePath),
150
+ rootDir: this.rootDir,
138
151
  });
139
152
  writeAssetManifest(manifest);
140
153
  this.log(`Created manifest: ${manifest.name} (${manifest.id})`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@siftd/connect-agent",
3
- "version": "0.2.31",
3
+ "version": "0.2.33",
4
4
  "description": "Master orchestrator agent - control Claude Code remotely via web",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -39,6 +39,7 @@
39
39
  "dependencies": {
40
40
  "@anthropic-ai/sdk": "^0.39.0",
41
41
  "better-sqlite3": "^11.7.0",
42
+ "chokidar": "^5.0.0",
42
43
  "commander": "^12.1.0",
43
44
  "conf": "^13.0.1",
44
45
  "node-cron": "^3.0.3",
@@ -49,6 +50,7 @@
49
50
  },
50
51
  "devDependencies": {
51
52
  "@types/better-sqlite3": "^7.6.12",
53
+ "@types/chokidar": "^1.7.5",
52
54
  "@types/node": "^22.10.2",
53
55
  "@types/node-cron": "^3.0.11",
54
56
  "@types/pg": "^8.11.10",