clawgram 2.27.0 → 2.28.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.
@@ -119,19 +119,23 @@ async function handleReadAction(ctx) {
119
119
  // that path would pull the file out from under an earlier `both`
120
120
  // fetch of the same message that handed the caller a path.
121
121
  // Не общий /tmp: там файлы видит каждый локальный пользователь, а на
122
- // этом хосте живёт ещё и раннер деплоя. Каталог состояния OpenClaw
123
- // принадлежит агенту; если он не задан, остаётся /tmp — но права
124
- // 0700/0600 ставятся в любом случае (A5-13).
125
- // Каталог состояния принадлежит агенту; при явно заданном
126
- // OPENCLAW_STATE_DIR вложения не покидают его.
122
+ // этом хосте живёт ещё и раннер деплоя (A5-13). При заданном
123
+ // OPENCLAW_STATE_DIR вложения не покидают каталог состояния; иначе
124
+ // корень лежит в /tmp и несёт uid процесса в имени.
125
+ //
126
+ // Прежде это был `/tmp/clawgram-fetched` — постоянное имя в каталоге,
127
+ // который делят все пользователи хоста. Когда агент переехал на свою
128
+ // учётку, имя осталось занято каталогом прежней, и каждая картинка с
129
+ // 05.09 по 07.09.2026 падала с `EACCES`. Имя с uid разводит учётки, а
130
+ // `ensurePrivateDir` проверяет, что каталог действительно наш и закрыт.
127
131
  const mediaRoot = process.env.OPENCLAW_STATE_DIR?.trim()
128
132
  ? node_path_1.default.join((0, state_dir_1.resolveStateDir)(), "tmp")
129
- : node_os_1.default.tmpdir();
133
+ : node_path_1.default.join(node_os_1.default.tmpdir(), `clawgram-${typeof process.getuid === "function" ? process.getuid() : "user"}`);
134
+ await (0, media_1.ensurePrivateDir)(mediaRoot);
130
135
  const sharedFetchDir = node_path_1.default.join(mediaRoot, "clawgram-fetched");
131
136
  let fetchDir = sharedFetchDir;
132
137
  if (fetchParams.mode === "read") {
133
- const { mkdtemp, mkdir } = await import("node:fs/promises");
134
- await mkdir(mediaRoot, { recursive: true, mode: 0o700 });
138
+ const { mkdtemp } = await import("node:fs/promises");
135
139
  fetchDir = await mkdtemp(node_path_1.default.join(mediaRoot, "clawgram-media-"));
136
140
  }
137
141
  else {
package/dist/media.js CHANGED
@@ -18,6 +18,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
18
18
  exports.describeMedia = describeMedia;
19
19
  exports.inboundMediaUnderstanding = inboundMediaUnderstanding;
20
20
  exports.downloadInboundMediaToTempFile = downloadInboundMediaToTempFile;
21
+ exports.describePrivateDirProblem = describePrivateDirProblem;
22
+ exports.ensurePrivateDir = ensurePrivateDir;
21
23
  exports.downloadMessageMediaToFile = downloadMessageMediaToFile;
22
24
  exports.pruneFetchedMedia = pruneFetchedMedia;
23
25
  exports.isLocalMediaPath = isLocalMediaPath;
@@ -153,6 +155,79 @@ async function downloadInboundMediaToTempFile(params) {
153
155
  fileNameFor: ({ extension }) => `attachment.${extension}`,
154
156
  });
155
157
  }
158
+ /**
159
+ * Why a directory that merely exists is not good enough.
160
+ *
161
+ * `mkdir(..., { recursive: true })` is a no-op on an existing directory and
162
+ * `chmod` on a directory owned by somebody else fails — and that failure used
163
+ * to be swallowed. The write then hit `EACCES` and the agent was handed the
164
+ * bare errno with nothing to act on.
165
+ *
166
+ * That is not hypothetical: the shared fetch directory had a fixed name in
167
+ * world-writable `/tmp`, and when the agent moved to its own account
168
+ * (04.09.2026) the old account's leftover kept the name. Every picture sent
169
+ * to the agent failed from 05.09 to 07.09 with
170
+ * `EACCES: permission denied, open '/tmp/clawgram-fetched/…'`, and the agent
171
+ * told its owner its "disk access was not restored" — the closest reading it
172
+ * could make of an errno.
173
+ *
174
+ * The same shape is a way in, not only an accident: any local user (this host
175
+ * also runs a deploy runner) could pre-create that predictable path and read
176
+ * every attachment written into it. So the check is ownership and mode, not
177
+ * existence.
178
+ */
179
+ function describePrivateDirProblem(input) {
180
+ if (input.isSymbolicLink) {
181
+ return `clawgram: ${input.path} is a symlink — refusing to write attachments through it`;
182
+ }
183
+ if (!input.isDirectory) {
184
+ return `clawgram: ${input.path} exists and is not a directory`;
185
+ }
186
+ if (input.selfUid !== undefined && input.uid !== input.selfUid) {
187
+ return `clawgram: ${input.path} belongs to uid ${input.uid}, this process runs as ${input.selfUid}`
188
+ + " — a leftover from another account is holding the path; remove it or give it to this account";
189
+ }
190
+ const bits = input.mode & 0o777;
191
+ if ((bits & 0o077) !== 0) {
192
+ return `clawgram: ${input.path} is readable beyond this account (mode ${bits.toString(8)})`;
193
+ }
194
+ return undefined;
195
+ }
196
+ /**
197
+ * Creates the directory, makes it private, and proves it — see above.
198
+ */
199
+ async function ensurePrivateDir(dir) {
200
+ const { mkdir, chmod, lstat } = await import("node:fs/promises");
201
+ // `EEXIST` means something already holds the name — a file, a symlink,
202
+ // another account's directory. The check below says which, and that is
203
+ // the whole point; an errno is what the agent could not act on. Any other
204
+ // failure (no space, read-only mount) is still the caller's problem.
205
+ await mkdir(dir, { recursive: true, mode: 0o700 }).catch((err) => {
206
+ if (err?.code !== "EEXIST") {
207
+ throw err;
208
+ }
209
+ });
210
+ // Only tighten what is already our directory: `chmod` on a stray file
211
+ // would change a mode that is none of our business, and on somebody
212
+ // else's directory it fails anyway — silently, which is how this stayed
213
+ // invisible for three days.
214
+ const found = await lstat(dir);
215
+ if (found.isDirectory()) {
216
+ await chmod(dir, 0o700).catch(() => undefined);
217
+ }
218
+ const stats = await lstat(dir);
219
+ const problem = describePrivateDirProblem({
220
+ path: dir,
221
+ isDirectory: stats.isDirectory(),
222
+ isSymbolicLink: stats.isSymbolicLink(),
223
+ uid: stats.uid,
224
+ mode: stats.mode,
225
+ selfUid: typeof process.getuid === "function" ? process.getuid() : undefined,
226
+ });
227
+ if (problem) {
228
+ throw new Error(problem);
229
+ }
230
+ }
156
231
  /**
157
232
  * Downloads an attachment into a directory the caller names and owns.
158
233
  *
@@ -178,7 +253,7 @@ async function downloadMessageMediaToFile(params) {
178
253
  if (!buffer || !(buffer instanceof Buffer) || buffer.length === 0) {
179
254
  return undefined;
180
255
  }
181
- const { mkdir, writeFile, chmod } = await import("node:fs/promises");
256
+ const { writeFile, chmod } = await import("node:fs/promises");
182
257
  const { join } = await import("node:path");
183
258
  // Личная переписка на диске: каталог и файл принадлежат только агенту.
184
259
  // По умолчанию (umask 022) выходило 0755/0644, то есть вложения из личных
@@ -186,8 +261,7 @@ async function downloadMessageMediaToFile(params) {
186
261
  // gitlab-runner (A5-13). `mode` у mkdir и writeFile маскируется umask,
187
262
  // поэтому права выставляются отдельным chmod, как это уже делается для
188
263
  // конфига в update-config.ts.
189
- await mkdir(params.dir, { recursive: true, mode: 0o700 });
190
- await chmod(params.dir, 0o700).catch(() => undefined);
264
+ await ensurePrivateDir(params.dir);
191
265
  const extension = extensionFor(described, understanding);
192
266
  const path = join(params.dir, params.fileNameFor({ media: described, extension }));
193
267
  await writeFile(path, buffer, { mode: 0o600 });
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "clawgram",
3
- "version": "2.27.0",
3
+ "version": "2.28.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "clawgram",
9
- "version": "2.27.0",
9
+ "version": "2.28.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "json5": "2.2.3",
@@ -2,7 +2,7 @@
2
2
  "id": "clawgram",
3
3
  "name": "Clawgram",
4
4
  "description": "Clawgram — personal Telegram (MTProto userbot) channel for OpenClaw. Your AI assistant reads and responds as you.",
5
- "version": "2.27.0",
5
+ "version": "2.28.0",
6
6
  "configSchema": {
7
7
  "type": "object",
8
8
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawgram",
3
- "version": "2.27.0",
3
+ "version": "2.28.0",
4
4
  "description": "Clawgram — personal Telegram (MTProto userbot) channel for OpenClaw. Your AI assistant reads and responds as you.",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {