crewly 1.20.326 โ 1.20.328
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/config/skills/agent/core/reply-channel/SKILL.md +19 -0
- package/config/skills/agent/core/reply-channel/execute.sh +38 -14
- package/config/skills/agent/core/reply-channel/execute.test.sh +18 -0
- package/config/skills/agent/core/send-file/SKILL.md +5 -0
- package/config/skills/agent/core/slack-post/SKILL.md +1 -0
- package/config/skills/agent/core/slack-post/execute.sh +20 -4
- package/dist/backend/backend/src/constants.d.ts +33 -0
- package/dist/backend/backend/src/constants.d.ts.map +1 -1
- package/dist/backend/backend/src/constants.js +40 -0
- package/dist/backend/backend/src/constants.js.map +1 -1
- package/dist/backend/backend/src/controllers/chat-v2/chat-v2.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/chat-v2/chat-v2.controller.js +31 -1
- package/dist/backend/backend/src/controllers/chat-v2/chat-v2.controller.js.map +1 -1
- package/dist/backend/backend/src/controllers/slack/slack.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/slack/slack.controller.js +16 -2
- package/dist/backend/backend/src/controllers/slack/slack.controller.js.map +1 -1
- package/dist/backend/backend/src/index.d.ts.map +1 -1
- package/dist/backend/backend/src/index.js +17 -0
- package/dist/backend/backend/src/index.js.map +1 -1
- package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.d.ts.map +1 -1
- package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.js +4 -0
- package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.js.map +1 -1
- package/dist/backend/backend/src/services/chat-v2/chat-v2.dispatcher.service.d.ts +7 -0
- package/dist/backend/backend/src/services/chat-v2/chat-v2.dispatcher.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/chat-v2/chat-v2.dispatcher.service.js +13 -2
- package/dist/backend/backend/src/services/chat-v2/chat-v2.dispatcher.service.js.map +1 -1
- package/dist/backend/backend/src/services/file-handoff/file-handoff.inbound.d.ts +35 -0
- package/dist/backend/backend/src/services/file-handoff/file-handoff.inbound.d.ts.map +1 -0
- package/dist/backend/backend/src/services/file-handoff/file-handoff.inbound.js +45 -0
- package/dist/backend/backend/src/services/file-handoff/file-handoff.inbound.js.map +1 -0
- package/dist/backend/backend/src/services/file-handoff/file-handoff.outbound.d.ts +44 -0
- package/dist/backend/backend/src/services/file-handoff/file-handoff.outbound.d.ts.map +1 -0
- package/dist/backend/backend/src/services/file-handoff/file-handoff.outbound.js +50 -0
- package/dist/backend/backend/src/services/file-handoff/file-handoff.outbound.js.map +1 -0
- package/dist/backend/backend/src/services/file-handoff/file-handoff.service.d.ts +224 -0
- package/dist/backend/backend/src/services/file-handoff/file-handoff.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/file-handoff/file-handoff.service.js +454 -0
- package/dist/backend/backend/src/services/file-handoff/file-handoff.service.js.map +1 -0
- package/dist/backend/backend/src/services/file-handoff/file-handoff.wiring.d.ts +42 -0
- package/dist/backend/backend/src/services/file-handoff/file-handoff.wiring.d.ts.map +1 -0
- package/dist/backend/backend/src/services/file-handoff/file-handoff.wiring.js +92 -0
- package/dist/backend/backend/src/services/file-handoff/file-handoff.wiring.js.map +1 -0
- package/dist/backend/backend/src/services/slack/slack-team-channel.service.d.ts +13 -0
- package/dist/backend/backend/src/services/slack/slack-team-channel.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-team-channel.service.js +27 -6
- package/dist/backend/backend/src/services/slack/slack-team-channel.service.js.map +1 -1
- package/dist/backend/build-info.json +2 -2
- package/dist/cli/backend/src/constants.d.ts +33 -0
- package/dist/cli/backend/src/constants.d.ts.map +1 -1
- package/dist/cli/backend/src/constants.js +40 -0
- package/dist/cli/backend/src/constants.js.map +1 -1
- package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.d.ts.map +1 -1
- package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.js +4 -0
- package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.js.map +1 -1
- package/dist/cli/backend/src/services/chat-v2/chat-v2.dispatcher.service.d.ts +7 -0
- package/dist/cli/backend/src/services/chat-v2/chat-v2.dispatcher.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/chat-v2/chat-v2.dispatcher.service.js +13 -2
- package/dist/cli/backend/src/services/chat-v2/chat-v2.dispatcher.service.js.map +1 -1
- package/dist/cli/backend/src/services/slack/slack-team-channel.service.d.ts +13 -0
- package/dist/cli/backend/src/services/slack/slack-team-channel.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/slack/slack-team-channel.service.js +27 -6
- package/dist/cli/backend/src/services/slack/slack-team-channel.service.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Automatic cross-machine file hand-off.
|
|
3
|
+
*
|
|
4
|
+
* Agents on different machines cannot open each other's local paths. This
|
|
5
|
+
* service moves the file through Cloud's file-handoff relay (the same one the
|
|
6
|
+
* `send-file` / `receive-file` skills use) so nobody has to paste an
|
|
7
|
+
* instruction by hand:
|
|
8
|
+
*
|
|
9
|
+
* - Sender: {@link FileHandoffService.attachToMessage} uploads the files an
|
|
10
|
+
* agent attached (`--file`) or mentioned by path, and appends one compact
|
|
11
|
+
* marker line per file to the message text.
|
|
12
|
+
* - Receiver: {@link FileHandoffService.expandInbound} finds the markers in
|
|
13
|
+
* a delivered message, downloads each file into the project inbox, and
|
|
14
|
+
* rewrites the marker to the local path.
|
|
15
|
+
*
|
|
16
|
+
* Marker format (readable in Slack): `๐ report.pdf (2.1 MB) ยท crewly-file:hf_xxx`.
|
|
17
|
+
*
|
|
18
|
+
* @module services/file-handoff/file-handoff.service
|
|
19
|
+
*/
|
|
20
|
+
/** The slice of the Apps Cloud client this service calls. */
|
|
21
|
+
export interface HandoffApi {
|
|
22
|
+
request<T>(method: string, path: string, opts?: {
|
|
23
|
+
body?: unknown;
|
|
24
|
+
agent?: string;
|
|
25
|
+
asOwner?: boolean;
|
|
26
|
+
timeoutMs?: number;
|
|
27
|
+
}): Promise<T>;
|
|
28
|
+
}
|
|
29
|
+
/** Streams a local file to a presigned URL. */
|
|
30
|
+
export type PutFileFn = (target: {
|
|
31
|
+
url: string;
|
|
32
|
+
headers: Record<string, string>;
|
|
33
|
+
}, file: string, sizeBytes: number, timeoutMs: number) => Promise<void>;
|
|
34
|
+
/** Streams a presigned URL to a local file. */
|
|
35
|
+
export type GetFileFn = (url: string, dest: string, timeoutMs: number) => Promise<void>;
|
|
36
|
+
/** Dependencies (all injectable for tests). */
|
|
37
|
+
export interface FileHandoffDeps {
|
|
38
|
+
api: () => HandoffApi;
|
|
39
|
+
/** Directories no file may be read from (Crewly home) */
|
|
40
|
+
crewlyHomes?: () => string[];
|
|
41
|
+
putFile?: PutFileFn;
|
|
42
|
+
getFile?: GetFileFn;
|
|
43
|
+
log?: (level: 'info' | 'warn', message: string, meta?: Record<string, unknown>) => void;
|
|
44
|
+
}
|
|
45
|
+
/** One file that went (or was meant to go) with a message. */
|
|
46
|
+
export interface HandoffAttachment {
|
|
47
|
+
fileName: string;
|
|
48
|
+
sizeBytes: number;
|
|
49
|
+
handoffId: string;
|
|
50
|
+
}
|
|
51
|
+
/** A file that did not go, with the reason an agent can act on. */
|
|
52
|
+
export interface HandoffFailure {
|
|
53
|
+
file: string;
|
|
54
|
+
reason: string;
|
|
55
|
+
}
|
|
56
|
+
/** Outcome of {@link FileHandoffService.attachToMessage}. */
|
|
57
|
+
export interface AttachResult {
|
|
58
|
+
/** The message text with marker lines appended (unchanged when nothing went) */
|
|
59
|
+
content: string;
|
|
60
|
+
attached: HandoffAttachment[];
|
|
61
|
+
failed: HandoffFailure[];
|
|
62
|
+
}
|
|
63
|
+
/** Input of {@link FileHandoffService.attachToMessage}. */
|
|
64
|
+
export interface AttachInput {
|
|
65
|
+
content: string;
|
|
66
|
+
/** Paths the agent named with `--file` (always attempted; failures are reported) */
|
|
67
|
+
explicit?: readonly string[];
|
|
68
|
+
/** Also pick up paths written in the text (only for a room that has a colleague on another machine) */
|
|
69
|
+
auto?: boolean;
|
|
70
|
+
/** The agent's project directory: relative paths resolve here and no file outside it goes */
|
|
71
|
+
projectDir: string;
|
|
72
|
+
/** Agent session the upload is attributed to */
|
|
73
|
+
agent?: string;
|
|
74
|
+
}
|
|
75
|
+
/** Outcome of {@link FileHandoffService.downloadToInbox}. */
|
|
76
|
+
export interface InboxFile {
|
|
77
|
+
path: string;
|
|
78
|
+
fileName: string;
|
|
79
|
+
sizeBytes: number;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Human size, e.g. `2.1 MB`.
|
|
83
|
+
*
|
|
84
|
+
* @param bytes - Size in bytes
|
|
85
|
+
* @returns Short size string
|
|
86
|
+
*/
|
|
87
|
+
export declare function formatSize(bytes: number): string;
|
|
88
|
+
/**
|
|
89
|
+
* The marker line for one uploaded file.
|
|
90
|
+
*
|
|
91
|
+
* @param fileName - Name the receiver sees
|
|
92
|
+
* @param sizeBytes - File size
|
|
93
|
+
* @param handoffId - Cloud handoff id
|
|
94
|
+
* @returns `๐ report.pdf (2.1 MB) ยท crewly-file:hf_xxx`
|
|
95
|
+
*/
|
|
96
|
+
export declare function formatMarker(fileName: string, sizeBytes: number, handoffId: string): string;
|
|
97
|
+
/**
|
|
98
|
+
* Handoff ids named by markers in a text, first occurrence order, no repeats.
|
|
99
|
+
*
|
|
100
|
+
* @param text - Message text
|
|
101
|
+
* @returns Handoff ids
|
|
102
|
+
*/
|
|
103
|
+
export declare function findMarkerIds(text: string): string[];
|
|
104
|
+
/**
|
|
105
|
+
* Absolute (`/x/y`) and `./relative` paths written in a text.
|
|
106
|
+
*
|
|
107
|
+
* @param text - Message text
|
|
108
|
+
* @returns Candidate paths in order, without duplicates
|
|
109
|
+
*/
|
|
110
|
+
export declare function extractPathCandidates(text: string): string[];
|
|
111
|
+
/**
|
|
112
|
+
* Whether a file name or path is one that must never leave the machine
|
|
113
|
+
* (`.env*`, keys, credentials, anything under `.ssh` / `.aws` / `.git`).
|
|
114
|
+
*
|
|
115
|
+
* @param file - File path
|
|
116
|
+
* @returns True for an obvious secret
|
|
117
|
+
*/
|
|
118
|
+
export declare function isSecretPath(file: string): boolean;
|
|
119
|
+
/** A vetted file, or why it was refused. */
|
|
120
|
+
type Vetted = {
|
|
121
|
+
ok: true;
|
|
122
|
+
real: string;
|
|
123
|
+
size: number;
|
|
124
|
+
} | {
|
|
125
|
+
ok: false;
|
|
126
|
+
reason: string;
|
|
127
|
+
};
|
|
128
|
+
/** Default {@link PutFileFn}: a streamed `PUT` with an exact Content-Length. */
|
|
129
|
+
export declare const putFileStreamed: PutFileFn;
|
|
130
|
+
/** Default {@link GetFileFn}: a streamed `GET` written to `dest`. */
|
|
131
|
+
export declare const getFileStreamed: GetFileFn;
|
|
132
|
+
/**
|
|
133
|
+
* Uploads files for outgoing messages and downloads them for incoming ones.
|
|
134
|
+
*/
|
|
135
|
+
export declare class FileHandoffService {
|
|
136
|
+
private readonly deps;
|
|
137
|
+
private readonly putFile;
|
|
138
|
+
private readonly getFile;
|
|
139
|
+
constructor(deps: FileHandoffDeps);
|
|
140
|
+
private log;
|
|
141
|
+
private homes;
|
|
142
|
+
/**
|
|
143
|
+
* Check a candidate file against every guard.
|
|
144
|
+
*
|
|
145
|
+
* Refuses: missing, symlink, not a regular file, empty, over the cap, outside
|
|
146
|
+
* the project, inside Crewly's home, an obvious secret name, or text that
|
|
147
|
+
* the secret redactor flags.
|
|
148
|
+
*
|
|
149
|
+
* @param candidate - Path as written
|
|
150
|
+
* @param projectDir - The agent's project directory
|
|
151
|
+
* @returns The real path and size, or the reason
|
|
152
|
+
*/
|
|
153
|
+
vetFile(candidate: string, projectDir: string): Vetted;
|
|
154
|
+
/**
|
|
155
|
+
* Upload one vetted file as a handoff.
|
|
156
|
+
*
|
|
157
|
+
* @param file - Real path
|
|
158
|
+
* @param sizeBytes - Size
|
|
159
|
+
* @param agent - Agent session to attribute the upload to
|
|
160
|
+
* @returns The handoff
|
|
161
|
+
* @throws When Cloud or the storage upload fails (the half-made handoff is withdrawn)
|
|
162
|
+
*/
|
|
163
|
+
uploadFile(file: string, sizeBytes: number, agent?: string): Promise<HandoffAttachment>;
|
|
164
|
+
/**
|
|
165
|
+
* Upload the files of one outgoing message and append their marker lines.
|
|
166
|
+
* Never throws: a file that cannot go is reported in `failed` and the text
|
|
167
|
+
* is still returned so the message can be posted anyway.
|
|
168
|
+
*
|
|
169
|
+
* @param input - Text, explicit files, auto-detect switch, project dir
|
|
170
|
+
* @returns New text plus what went and what did not
|
|
171
|
+
*/
|
|
172
|
+
attachToMessage(input: AttachInput): Promise<AttachResult>;
|
|
173
|
+
/**
|
|
174
|
+
* Download one handoff into `<inboxRoot>/<handoffId>/<fileName>`.
|
|
175
|
+
*
|
|
176
|
+
* Not acknowledged: a room message reaches every machine (and every agent)
|
|
177
|
+
* in the room, and an ack deletes the Cloud copy, so the first receiver
|
|
178
|
+
* would take it from all the others. Cloud removes it after 24 h. A file
|
|
179
|
+
* already in this inbox is reused, and two local agents fetching the same
|
|
180
|
+
* one share a single download.
|
|
181
|
+
*
|
|
182
|
+
* @param handoffId - Id from a marker
|
|
183
|
+
* @param inboxRoot - The inbox folder (`<project data dir>/inbox`)
|
|
184
|
+
* @param agent - Receiving agent session
|
|
185
|
+
* @param timeoutMs - Ceiling for the transfer
|
|
186
|
+
* @returns Where the file is now
|
|
187
|
+
* @throws When the handoff is not ready, the transfer fails, or the size differs
|
|
188
|
+
*/
|
|
189
|
+
downloadToInbox(handoffId: string, inboxRoot: string, agent: string | undefined, timeoutMs?: number): Promise<InboxFile>;
|
|
190
|
+
/** Downloads in progress, by `<inboxRoot>/<handoffId>`. */
|
|
191
|
+
private readonly inFlight;
|
|
192
|
+
/**
|
|
193
|
+
* The finished file in a handoff's inbox folder, if there is one.
|
|
194
|
+
*
|
|
195
|
+
* @param dir - `<inboxRoot>/<handoffId>`
|
|
196
|
+
* @returns The file, or null
|
|
197
|
+
*/
|
|
198
|
+
private inboxFileIn;
|
|
199
|
+
/**
|
|
200
|
+
* The transfer itself, for {@link downloadToInbox}.
|
|
201
|
+
*
|
|
202
|
+
* @param handoffId - Id from a marker
|
|
203
|
+
* @param inboxRoot - The inbox folder
|
|
204
|
+
* @param agent - Receiving agent session
|
|
205
|
+
* @param timeoutMs - Ceiling for the transfer
|
|
206
|
+
* @returns Where the file is now
|
|
207
|
+
*/
|
|
208
|
+
private fetchToInbox;
|
|
209
|
+
/**
|
|
210
|
+
* Replace every marker in a delivered message with the local path of the
|
|
211
|
+
* downloaded file (`File from <sender>: <path>`). A file that cannot be
|
|
212
|
+
* fetched in time stays in the text with a note and the manual command.
|
|
213
|
+
* Downloads run in parallel under one overall deadline.
|
|
214
|
+
*
|
|
215
|
+
* @param text - Message text as delivered
|
|
216
|
+
* @param sender - Who sent it (display name)
|
|
217
|
+
* @param inboxRoot - The receiving agent's inbox folder
|
|
218
|
+
* @param agent - Receiving agent session
|
|
219
|
+
* @returns Text with the markers rewritten (unchanged when it has none)
|
|
220
|
+
*/
|
|
221
|
+
expandInbound(text: string, sender: string, inboxRoot: string, agent?: string): Promise<string>;
|
|
222
|
+
}
|
|
223
|
+
export {};
|
|
224
|
+
//# sourceMappingURL=file-handoff.service.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"file-handoff.service.d.ts","sourceRoot":"","sources":["../../../../../../backend/src/services/file-handoff/file-handoff.service.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Automatic cross-machine file hand-off.
|
|
3
|
+
*
|
|
4
|
+
* Agents on different machines cannot open each other's local paths. This
|
|
5
|
+
* service moves the file through Cloud's file-handoff relay (the same one the
|
|
6
|
+
* `send-file` / `receive-file` skills use) so nobody has to paste an
|
|
7
|
+
* instruction by hand:
|
|
8
|
+
*
|
|
9
|
+
* - Sender: {@link FileHandoffService.attachToMessage} uploads the files an
|
|
10
|
+
* agent attached (`--file`) or mentioned by path, and appends one compact
|
|
11
|
+
* marker line per file to the message text.
|
|
12
|
+
* - Receiver: {@link FileHandoffService.expandInbound} finds the markers in
|
|
13
|
+
* a delivered message, downloads each file into the project inbox, and
|
|
14
|
+
* rewrites the marker to the local path.
|
|
15
|
+
*
|
|
16
|
+
* Marker format (readable in Slack): `๐ report.pdf (2.1 MB) ยท crewly-file:hf_xxx`.
|
|
17
|
+
*
|
|
18
|
+
* @module services/file-handoff/file-handoff.service
|
|
19
|
+
*/
|
|
20
|
+
import * as fs from 'fs';
|
|
21
|
+
import * as http from 'http';
|
|
22
|
+
import * as https from 'https';
|
|
23
|
+
import * as os from 'os';
|
|
24
|
+
import * as path from 'path';
|
|
25
|
+
import { FILE_HANDOFF_CONSTANTS as C } from '../../constants.js';
|
|
26
|
+
import { redactSecretsWithCount } from '../../utils/secret-redactor.js';
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Pure helpers
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
const MARKER_ID_RE = new RegExp(`${escapeRe(C.MARKER_PREFIX)}(${C.ID_PATTERN})`, 'g');
|
|
31
|
+
const MARKER_TOKEN_RE = new RegExp(`(?:${escapeRe(C.MARKER_GLYPH)}[^\\n]*?ยท\\s*)?${escapeRe(C.MARKER_PREFIX)}(${C.ID_PATTERN})`, 'g');
|
|
32
|
+
const KIB = 1024;
|
|
33
|
+
const SIZE_UNITS = ['B', 'KB', 'MB', 'GB'];
|
|
34
|
+
/** Characters that end a path written in prose */
|
|
35
|
+
const PATH_TRAILING_PUNCT = /[.,;:!?)\]}>'"`]+$/;
|
|
36
|
+
/** Absolute or `./relative` paths in free text */
|
|
37
|
+
const PATH_IN_TEXT_RE = /(?:^|[\s"'`(<[])((?:\/|\.\/)[^\s"'`)>\]]+)/g;
|
|
38
|
+
function escapeRe(s) {
|
|
39
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Human size, e.g. `2.1 MB`.
|
|
43
|
+
*
|
|
44
|
+
* @param bytes - Size in bytes
|
|
45
|
+
* @returns Short size string
|
|
46
|
+
*/
|
|
47
|
+
export function formatSize(bytes) {
|
|
48
|
+
let value = bytes;
|
|
49
|
+
let unit = 0;
|
|
50
|
+
while (value >= KIB && unit < SIZE_UNITS.length - 1) {
|
|
51
|
+
value /= KIB;
|
|
52
|
+
unit += 1;
|
|
53
|
+
}
|
|
54
|
+
const text = unit === 0 || value >= 10 ? String(Math.round(value)) : value.toFixed(1);
|
|
55
|
+
return `${text} ${SIZE_UNITS[unit]}`;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The marker line for one uploaded file.
|
|
59
|
+
*
|
|
60
|
+
* @param fileName - Name the receiver sees
|
|
61
|
+
* @param sizeBytes - File size
|
|
62
|
+
* @param handoffId - Cloud handoff id
|
|
63
|
+
* @returns `๐ report.pdf (2.1 MB) ยท crewly-file:hf_xxx`
|
|
64
|
+
*/
|
|
65
|
+
export function formatMarker(fileName, sizeBytes, handoffId) {
|
|
66
|
+
const safeName = fileName.replace(/[\r\n]+/g, ' ').trim() || 'file';
|
|
67
|
+
return `${C.MARKER_GLYPH} ${safeName} (${formatSize(sizeBytes)}) ยท ${C.MARKER_PREFIX}${handoffId}`;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Handoff ids named by markers in a text, first occurrence order, no repeats.
|
|
71
|
+
*
|
|
72
|
+
* @param text - Message text
|
|
73
|
+
* @returns Handoff ids
|
|
74
|
+
*/
|
|
75
|
+
export function findMarkerIds(text) {
|
|
76
|
+
const ids = [];
|
|
77
|
+
for (const m of text.matchAll(MARKER_ID_RE)) {
|
|
78
|
+
if (!ids.includes(m[1]))
|
|
79
|
+
ids.push(m[1]);
|
|
80
|
+
}
|
|
81
|
+
return ids;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Absolute (`/x/y`) and `./relative` paths written in a text.
|
|
85
|
+
*
|
|
86
|
+
* @param text - Message text
|
|
87
|
+
* @returns Candidate paths in order, without duplicates
|
|
88
|
+
*/
|
|
89
|
+
export function extractPathCandidates(text) {
|
|
90
|
+
const found = [];
|
|
91
|
+
for (const m of text.matchAll(PATH_IN_TEXT_RE)) {
|
|
92
|
+
const p = m[1].replace(PATH_TRAILING_PUNCT, '');
|
|
93
|
+
if (p.length > 2 && !found.includes(p))
|
|
94
|
+
found.push(p);
|
|
95
|
+
}
|
|
96
|
+
return found;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Whether a file name or path is one that must never leave the machine
|
|
100
|
+
* (`.env*`, keys, credentials, anything under `.ssh` / `.aws` / `.git`).
|
|
101
|
+
*
|
|
102
|
+
* @param file - File path
|
|
103
|
+
* @returns True for an obvious secret
|
|
104
|
+
*/
|
|
105
|
+
export function isSecretPath(file) {
|
|
106
|
+
const parts = file.split(path.sep).filter(Boolean).map((s) => s.toLowerCase());
|
|
107
|
+
const name = parts[parts.length - 1] ?? '';
|
|
108
|
+
if (C.SECRET_NAME_PATTERNS.some((re) => re.test(name)))
|
|
109
|
+
return true;
|
|
110
|
+
return parts.slice(0, -1).some((d) => C.SECRET_DIR_NAMES.includes(d));
|
|
111
|
+
}
|
|
112
|
+
function within(child, parent) {
|
|
113
|
+
return child === parent || child.startsWith(parent.endsWith(path.sep) ? parent : parent + path.sep);
|
|
114
|
+
}
|
|
115
|
+
function real(p) {
|
|
116
|
+
try {
|
|
117
|
+
return fs.realpathSync(p);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/** A safe local file name (last segment, no leading dots or control characters). */
|
|
124
|
+
function safeName(name) {
|
|
125
|
+
// eslint-disable-next-line no-control-regex
|
|
126
|
+
const base = name.replace(/\\/g, '/').split('/').pop().replace(/[\u0000-\u001f]/g, '').replace(/^[.\s]+/, '').trim();
|
|
127
|
+
return base || 'file';
|
|
128
|
+
}
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// Network (streamed from / to disk, never buffered)
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
/** Default {@link PutFileFn}: a streamed `PUT` with an exact Content-Length. */
|
|
133
|
+
export const putFileStreamed = (target, file, sizeBytes, timeoutMs) => new Promise((resolve, reject) => {
|
|
134
|
+
const url = new URL(target.url);
|
|
135
|
+
const lib = url.protocol === 'http:' ? http : https;
|
|
136
|
+
const req = lib.request(url, { method: 'PUT', headers: { ...target.headers, 'Content-Length': String(sizeBytes) }, timeout: timeoutMs }, (res) => {
|
|
137
|
+
res.resume();
|
|
138
|
+
res.on('end', () => (res.statusCode === 200 ? resolve() : reject(new Error(`upload failed (HTTP ${res.statusCode ?? 0})`))));
|
|
139
|
+
});
|
|
140
|
+
req.on('timeout', () => req.destroy(new Error('upload timed out')));
|
|
141
|
+
req.on('error', reject);
|
|
142
|
+
const src = fs.createReadStream(file);
|
|
143
|
+
src.on('error', (e) => req.destroy(e));
|
|
144
|
+
src.pipe(req);
|
|
145
|
+
});
|
|
146
|
+
/** Default {@link GetFileFn}: a streamed `GET` written to `dest`. */
|
|
147
|
+
export const getFileStreamed = (url, dest, timeoutMs) => new Promise((resolve, reject) => {
|
|
148
|
+
const target = new URL(url);
|
|
149
|
+
const lib = target.protocol === 'http:' ? http : https;
|
|
150
|
+
const req = lib.get(target, { timeout: timeoutMs }, (res) => {
|
|
151
|
+
if (res.statusCode !== 200) {
|
|
152
|
+
res.resume();
|
|
153
|
+
reject(new Error(`download failed (HTTP ${res.statusCode ?? 0})`));
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const out = fs.createWriteStream(dest);
|
|
157
|
+
res.pipe(out);
|
|
158
|
+
out.on('finish', () => out.close(() => resolve()));
|
|
159
|
+
out.on('error', reject);
|
|
160
|
+
res.on('error', reject);
|
|
161
|
+
});
|
|
162
|
+
req.on('timeout', () => req.destroy(new Error('download timed out')));
|
|
163
|
+
req.on('error', reject);
|
|
164
|
+
});
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// Service
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
/**
|
|
169
|
+
* Uploads files for outgoing messages and downloads them for incoming ones.
|
|
170
|
+
*/
|
|
171
|
+
export class FileHandoffService {
|
|
172
|
+
deps;
|
|
173
|
+
putFile;
|
|
174
|
+
getFile;
|
|
175
|
+
constructor(deps) {
|
|
176
|
+
this.deps = deps;
|
|
177
|
+
this.putFile = deps.putFile ?? putFileStreamed;
|
|
178
|
+
this.getFile = deps.getFile ?? getFileStreamed;
|
|
179
|
+
}
|
|
180
|
+
log(level, message, meta) {
|
|
181
|
+
this.deps.log?.(level, message, meta);
|
|
182
|
+
}
|
|
183
|
+
homes() {
|
|
184
|
+
const configured = this.deps.crewlyHomes?.() ?? [path.join(os.homedir(), '.crewly')];
|
|
185
|
+
return configured.map((h) => real(h) ?? path.resolve(h));
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Check a candidate file against every guard.
|
|
189
|
+
*
|
|
190
|
+
* Refuses: missing, symlink, not a regular file, empty, over the cap, outside
|
|
191
|
+
* the project, inside Crewly's home, an obvious secret name, or text that
|
|
192
|
+
* the secret redactor flags.
|
|
193
|
+
*
|
|
194
|
+
* @param candidate - Path as written
|
|
195
|
+
* @param projectDir - The agent's project directory
|
|
196
|
+
* @returns The real path and size, or the reason
|
|
197
|
+
*/
|
|
198
|
+
vetFile(candidate, projectDir) {
|
|
199
|
+
const abs = path.resolve(projectDir, candidate);
|
|
200
|
+
let st;
|
|
201
|
+
try {
|
|
202
|
+
st = fs.lstatSync(abs);
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return { ok: false, reason: 'file not found' };
|
|
206
|
+
}
|
|
207
|
+
if (st.isSymbolicLink())
|
|
208
|
+
return { ok: false, reason: 'is a symbolic link; pass the real file' };
|
|
209
|
+
if (!st.isFile())
|
|
210
|
+
return { ok: false, reason: 'is not a regular file' };
|
|
211
|
+
if (st.size === 0)
|
|
212
|
+
return { ok: false, reason: 'is empty' };
|
|
213
|
+
if (st.size > C.MAX_FILE_BYTES)
|
|
214
|
+
return { ok: false, reason: `is larger than the ${formatSize(C.MAX_FILE_BYTES)} limit` };
|
|
215
|
+
const r = real(abs);
|
|
216
|
+
const project = real(projectDir);
|
|
217
|
+
if (!r || !project)
|
|
218
|
+
return { ok: false, reason: 'cannot resolve the project directory' };
|
|
219
|
+
if (!within(r, project))
|
|
220
|
+
return { ok: false, reason: 'is outside your project directory; copy it there first' };
|
|
221
|
+
if (this.homes().some((h) => within(r, h)))
|
|
222
|
+
return { ok: false, reason: "is inside Crewly's home directory" };
|
|
223
|
+
// Check the path below the project root, so a project that lives under e.g. node_modules still works.
|
|
224
|
+
if (isSecretPath(path.relative(project, r)))
|
|
225
|
+
return { ok: false, reason: 'looks like a secret (credentials or key file) and is never sent' };
|
|
226
|
+
if (st.size <= C.SECRET_SCAN_MAX_BYTES && looksLikeText(r)) {
|
|
227
|
+
try {
|
|
228
|
+
if (redactSecretsWithCount(fs.readFileSync(r, 'utf8')).count > 0) {
|
|
229
|
+
return { ok: false, reason: 'contains what looks like a secret and is never sent' };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return { ok: false, reason: 'could not be read' };
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return { ok: true, real: r, size: st.size };
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Upload one vetted file as a handoff.
|
|
240
|
+
*
|
|
241
|
+
* @param file - Real path
|
|
242
|
+
* @param sizeBytes - Size
|
|
243
|
+
* @param agent - Agent session to attribute the upload to
|
|
244
|
+
* @returns The handoff
|
|
245
|
+
* @throws When Cloud or the storage upload fails (the half-made handoff is withdrawn)
|
|
246
|
+
*/
|
|
247
|
+
async uploadFile(file, sizeBytes, agent) {
|
|
248
|
+
const api = this.deps.api();
|
|
249
|
+
const attr = agent ? { agent } : { asOwner: true };
|
|
250
|
+
const fileName = safeName(path.basename(file));
|
|
251
|
+
const created = await api.request('POST', '/handoffs', {
|
|
252
|
+
body: { fileName, sizeBytes, contentType: C.DEFAULT_CONTENT_TYPE },
|
|
253
|
+
...attr,
|
|
254
|
+
});
|
|
255
|
+
const id = created.handoffId;
|
|
256
|
+
const url = created.upload?.url;
|
|
257
|
+
if (!id || !url)
|
|
258
|
+
throw new Error('Cloud answered without an upload URL');
|
|
259
|
+
try {
|
|
260
|
+
await this.putFile({ url, headers: created.upload?.headers ?? {} }, file, sizeBytes, C.UPLOAD_TIMEOUT_MS);
|
|
261
|
+
await api.request('POST', `/handoffs/${id}/complete`, attr);
|
|
262
|
+
}
|
|
263
|
+
catch (err) {
|
|
264
|
+
await api.request('DELETE', `/handoffs/${id}`, attr).catch(() => undefined);
|
|
265
|
+
throw err;
|
|
266
|
+
}
|
|
267
|
+
return { fileName, sizeBytes, handoffId: id };
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Upload the files of one outgoing message and append their marker lines.
|
|
271
|
+
* Never throws: a file that cannot go is reported in `failed` and the text
|
|
272
|
+
* is still returned so the message can be posted anyway.
|
|
273
|
+
*
|
|
274
|
+
* @param input - Text, explicit files, auto-detect switch, project dir
|
|
275
|
+
* @returns New text plus what went and what did not
|
|
276
|
+
*/
|
|
277
|
+
async attachToMessage(input) {
|
|
278
|
+
const attached = [];
|
|
279
|
+
const failed = [];
|
|
280
|
+
const tried = new Set();
|
|
281
|
+
const attempt = async (candidate, explicit) => {
|
|
282
|
+
const key = path.resolve(input.projectDir, candidate);
|
|
283
|
+
if (tried.has(key))
|
|
284
|
+
return;
|
|
285
|
+
tried.add(key);
|
|
286
|
+
if (attached.length >= C.MAX_FILES_PER_MESSAGE) {
|
|
287
|
+
if (explicit)
|
|
288
|
+
failed.push({ file: candidate, reason: `more than ${C.MAX_FILES_PER_MESSAGE} files in one message` });
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const vetted = this.vetFile(candidate, input.projectDir);
|
|
292
|
+
if (!vetted.ok) {
|
|
293
|
+
// A path merely mentioned in prose that is not a sendable file is not an error.
|
|
294
|
+
if (explicit)
|
|
295
|
+
failed.push({ file: candidate, reason: vetted.reason });
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
try {
|
|
299
|
+
attached.push(await this.uploadFile(vetted.real, vetted.size, input.agent));
|
|
300
|
+
}
|
|
301
|
+
catch (err) {
|
|
302
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
303
|
+
this.log('warn', 'File handoff upload failed', { file: path.basename(candidate), reason });
|
|
304
|
+
failed.push({ file: candidate, reason });
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
for (const f of input.explicit ?? [])
|
|
308
|
+
await attempt(f, true);
|
|
309
|
+
if (input.auto) {
|
|
310
|
+
for (const f of extractPathCandidates(input.content)) {
|
|
311
|
+
if (attached.length >= C.MAX_FILES_PER_MESSAGE)
|
|
312
|
+
break;
|
|
313
|
+
await attempt(f, false);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
const lines = attached.map((a) => formatMarker(a.fileName, a.sizeBytes, a.handoffId));
|
|
317
|
+
const content = lines.length > 0 ? `${input.content.replace(/\s+$/, '')}\n${lines.join('\n')}` : input.content;
|
|
318
|
+
return { content, attached, failed };
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Download one handoff into `<inboxRoot>/<handoffId>/<fileName>`.
|
|
322
|
+
*
|
|
323
|
+
* Not acknowledged: a room message reaches every machine (and every agent)
|
|
324
|
+
* in the room, and an ack deletes the Cloud copy, so the first receiver
|
|
325
|
+
* would take it from all the others. Cloud removes it after 24 h. A file
|
|
326
|
+
* already in this inbox is reused, and two local agents fetching the same
|
|
327
|
+
* one share a single download.
|
|
328
|
+
*
|
|
329
|
+
* @param handoffId - Id from a marker
|
|
330
|
+
* @param inboxRoot - The inbox folder (`<project data dir>/inbox`)
|
|
331
|
+
* @param agent - Receiving agent session
|
|
332
|
+
* @param timeoutMs - Ceiling for the transfer
|
|
333
|
+
* @returns Where the file is now
|
|
334
|
+
* @throws When the handoff is not ready, the transfer fails, or the size differs
|
|
335
|
+
*/
|
|
336
|
+
async downloadToInbox(handoffId, inboxRoot, agent, timeoutMs = C.DOWNLOAD_TIMEOUT_MS) {
|
|
337
|
+
const key = path.join(inboxRoot, handoffId);
|
|
338
|
+
const existing = this.inboxFileIn(key);
|
|
339
|
+
if (existing)
|
|
340
|
+
return existing;
|
|
341
|
+
const running = this.inFlight.get(key);
|
|
342
|
+
if (running)
|
|
343
|
+
return running;
|
|
344
|
+
const job = this.fetchToInbox(handoffId, inboxRoot, agent, timeoutMs).finally(() => this.inFlight.delete(key));
|
|
345
|
+
this.inFlight.set(key, job);
|
|
346
|
+
return job;
|
|
347
|
+
}
|
|
348
|
+
/** Downloads in progress, by `<inboxRoot>/<handoffId>`. */
|
|
349
|
+
inFlight = new Map();
|
|
350
|
+
/**
|
|
351
|
+
* The finished file in a handoff's inbox folder, if there is one.
|
|
352
|
+
*
|
|
353
|
+
* @param dir - `<inboxRoot>/<handoffId>`
|
|
354
|
+
* @returns The file, or null
|
|
355
|
+
*/
|
|
356
|
+
inboxFileIn(dir) {
|
|
357
|
+
try {
|
|
358
|
+
const name = fs.readdirSync(dir).find((n) => !n.startsWith('.'));
|
|
359
|
+
if (!name)
|
|
360
|
+
return null;
|
|
361
|
+
const dest = path.join(dir, name);
|
|
362
|
+
return { path: dest, fileName: name, sizeBytes: fs.statSync(dest).size };
|
|
363
|
+
}
|
|
364
|
+
catch {
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* The transfer itself, for {@link downloadToInbox}.
|
|
370
|
+
*
|
|
371
|
+
* @param handoffId - Id from a marker
|
|
372
|
+
* @param inboxRoot - The inbox folder
|
|
373
|
+
* @param agent - Receiving agent session
|
|
374
|
+
* @param timeoutMs - Ceiling for the transfer
|
|
375
|
+
* @returns Where the file is now
|
|
376
|
+
*/
|
|
377
|
+
async fetchToInbox(handoffId, inboxRoot, agent, timeoutMs) {
|
|
378
|
+
const api = this.deps.api();
|
|
379
|
+
const attr = agent ? { agent } : { asOwner: true };
|
|
380
|
+
const meta = await api.request('GET', `/handoffs/${handoffId}`, { ...attr, timeoutMs });
|
|
381
|
+
if (meta.status !== 'ready' || !meta.download?.url)
|
|
382
|
+
throw new Error('the sender has not finished uploading');
|
|
383
|
+
const size = Number(meta.sizeBytes);
|
|
384
|
+
const name = safeName(meta.fileName ?? 'file');
|
|
385
|
+
const dir = path.join(inboxRoot, handoffId);
|
|
386
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
387
|
+
const dest = path.join(dir, name);
|
|
388
|
+
const part = path.join(dir, `.${handoffId}.part`);
|
|
389
|
+
try {
|
|
390
|
+
await this.getFile(meta.download.url, part, timeoutMs);
|
|
391
|
+
const got = fs.statSync(part).size;
|
|
392
|
+
if (Number.isFinite(size) && got !== size)
|
|
393
|
+
throw new Error(`size mismatch (got ${got} bytes, expected ${size})`);
|
|
394
|
+
fs.renameSync(part, dest);
|
|
395
|
+
}
|
|
396
|
+
catch (err) {
|
|
397
|
+
fs.rmSync(part, { force: true });
|
|
398
|
+
throw err;
|
|
399
|
+
}
|
|
400
|
+
return { path: dest, fileName: name, sizeBytes: fs.statSync(dest).size };
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Replace every marker in a delivered message with the local path of the
|
|
404
|
+
* downloaded file (`File from <sender>: <path>`). A file that cannot be
|
|
405
|
+
* fetched in time stays in the text with a note and the manual command.
|
|
406
|
+
* Downloads run in parallel under one overall deadline.
|
|
407
|
+
*
|
|
408
|
+
* @param text - Message text as delivered
|
|
409
|
+
* @param sender - Who sent it (display name)
|
|
410
|
+
* @param inboxRoot - The receiving agent's inbox folder
|
|
411
|
+
* @param agent - Receiving agent session
|
|
412
|
+
* @returns Text with the markers rewritten (unchanged when it has none)
|
|
413
|
+
*/
|
|
414
|
+
async expandInbound(text, sender, inboxRoot, agent) {
|
|
415
|
+
const ids = findMarkerIds(text);
|
|
416
|
+
if (ids.length === 0)
|
|
417
|
+
return text;
|
|
418
|
+
const deadline = Date.now() + C.DOWNLOAD_TOTAL_TIMEOUT_MS;
|
|
419
|
+
const results = new Map();
|
|
420
|
+
await Promise.all(ids.slice(0, C.MAX_FILES_PER_MESSAGE).map(async (id) => {
|
|
421
|
+
try {
|
|
422
|
+
const budget = Math.max(1, Math.min(C.DOWNLOAD_TIMEOUT_MS, deadline - Date.now()));
|
|
423
|
+
const file = await this.downloadToInbox(id, inboxRoot, agent, budget);
|
|
424
|
+
results.set(id, `File from ${sender}: ${file.path}`);
|
|
425
|
+
this.log('info', 'File handoff received', { handoffId: id, bytes: file.sizeBytes });
|
|
426
|
+
}
|
|
427
|
+
catch (err) {
|
|
428
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
429
|
+
this.log('warn', 'File handoff download failed', { handoffId: id, reason });
|
|
430
|
+
results.set(id, `File from ${sender} could not be fetched (${reason}). Fetch it yourself: receive-file --id ${id}`);
|
|
431
|
+
}
|
|
432
|
+
}));
|
|
433
|
+
return text.replace(MARKER_TOKEN_RE, (whole, id) => results.get(id) ?? whole);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
/** Whether the first bytes look like text (no NUL byte). */
|
|
437
|
+
function looksLikeText(file) {
|
|
438
|
+
const probeBytes = 4096;
|
|
439
|
+
let fd = null;
|
|
440
|
+
try {
|
|
441
|
+
fd = fs.openSync(file, 'r');
|
|
442
|
+
const buf = Buffer.alloc(probeBytes);
|
|
443
|
+
const n = fs.readSync(fd, buf, 0, probeBytes, 0);
|
|
444
|
+
return !buf.subarray(0, n).includes(0);
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
return false;
|
|
448
|
+
}
|
|
449
|
+
finally {
|
|
450
|
+
if (fd !== null)
|
|
451
|
+
fs.closeSync(fd);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
//# sourceMappingURL=file-handoff.service.js.map
|