Skip to content

fix(msteams): handle fileConsent/invoke callback for bot-to-user file upload (#55386)#63954

Closed
sudie-codes wants to merge 1 commit intoopenclaw:mainfrom
sudie-codes:fix/msteams-file-upload-consent-55386
Closed

fix(msteams): handle fileConsent/invoke callback for bot-to-user file upload (#55386)#63954
sudie-codes wants to merge 1 commit intoopenclaw:mainfrom
sudie-codes:fix/msteams-file-upload-consent-55386

Conversation

@sudie-codes
Copy link
Copy Markdown
Contributor

Summary

Bot-to-user file uploads via `openclaw message send --media` were silently failing because the `fileConsent/invoke` callback could not find the pending upload. The root cause was cross-process: the CLI sender registered the pending upload in its own in-memory Map and then exited, but the `fileConsent/invoke` callback lands on the long-running monitor webhook in a different process, so the invoke handler always saw an empty store and could not honor "Accept".

Fixes #55386.

Fix

  • Add a new filesystem-backed pending upload store (`pending-uploads-fs.ts`) that persists the file bytes under the msteams state dir (`msteams-pending-uploads/.blob` + `index.json`) so any process with access to the state dir can honor the consent callback.
  • Add `prepareFileConsentActivityFs` helper that writes to the FS store, and route the CLI `sendMessageMSTeams` path through it so CLI-originated consent cards survive the process boundary.
  • Extend `handleFileConsentInvoke` with a fallback lookup: check the in-memory store first (fast path for same-process sends), then the FS store (cross-process CLI path). Removal targets the same store the pending upload was resolved from. FS store errors are logged at debug so a misconfigured state dir never crashes the invoke handler.
  • Validate UUID-shaped upload ids before FS operations (defense in depth against path traversal), and prune expired entries plus orphaned blobs on read.

Relationship to PR #49580

PR #49580 (`fix/msteams-file-consent-card-update`) is in flight and updates the consent card after a successful upload (calls `context.updateActivity`). That PR extends the in-memory `PendingUpload` with `consentCardActivityId` and teaches the handler to replace the consent card in place. This PR is a separate concern — it fixes the cross-process pending upload lookup so the accept handler actually has a file to upload in the first place. The two changes touch overlapping files (`monitor-handler.ts`, `send.ts`, `file-consent-helpers.ts`) so some manual rebasing may be needed, but the behaviors are compatible: the FS store can persist `consentCardActivityId` in a follow-up if needed.

Test plan

  • `pnpm test extensions/msteams/` — all 589 msteams tests pass (54 test files)
  • New `pending-uploads-fs.test.ts` covers: cross-process round trip (two store handles sharing a temp dir), TTL pruning + orphan blob handling, safe upload id validation, handler registration, accept → `uploadToConsentUrl` PUT + fs drain, decline → fs drain, missing pending upload → expired message, cross-conversation accept rejected with pending upload preserved
  • Existing `monitor-handler.file-consent.test.ts` still passes unchanged (in-memory fast path untouched)
  • Manual: `openclaw message send --channel msteams --media ` to a personal DM, click Accept, confirm the file uploads to OneDrive and the file info card arrives

@openclaw-barnacle openclaw-barnacle Bot added channel: msteams Channel integration: msteams size: L r: too-many-prs Auto-close: author has more than twenty active PRs. labels Apr 9, 2026
@openclaw-barnacle
Copy link
Copy Markdown

Closing this PR because the author has more than 10 active PRs in this repo. Please reduce the active PR queue and reopen or resubmit once it is back under the limit. You can close your own PRs to get back under the limit.

@openclaw-barnacle openclaw-barnacle Bot closed this Apr 9, 2026
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 9, 2026

Greptile Summary

This PR fixes a cross-process race in the MSTeams fileConsent/invoke flow: the CLI sender wrote the pending upload to an in-memory Map and then exited, leaving the long-running monitor webhook with an empty store. The fix adds a filesystem-backed store (pending-uploads-fs.ts) under the msteams state dir and routes the CLI send path through it, with a dual-lookup fallback in the invoke handler (memory first, FS second) and conversation-mismatch protection preserved across both stores. Test coverage is thorough: cross-process round-trip, TTL pruning, orphan-metadata handling, decline/expired paths, and cross-conversation rejection are all covered.

Confidence Score: 5/5

Safe to merge; all remaining findings are P2 style/quality improvements that do not affect correctness or security.

The core fix is correct: the dual-lookup strategy, file locking, TTL pruning, UUID validation, and conversation-mismatch protection all work as intended. Errors from the FS store are caught at every call site so a misconfigured state dir cannot crash the invoke handler. The three P2 findings (orphan blob accumulation, missing ensureStoreDir in read paths, overly permissive UUID regex) are quality improvements without impact on the primary user path.

extensions/msteams/src/pending-uploads-fs.ts — minor gaps in orphan-blob cleanup and storeDir pre-existence assumptions.

Prompt To Fix All With AI
This is a comment left during a code review.
Path: extensions/msteams/src/pending-uploads-fs.ts
Line: 183-184

Comment:
**Orphan blobs from crash are never reclaimed**

The comment says orphan blobs (written before the index update) are "disk litter that the next prune cycle can skip," but `readAndPruneLocked` only iterates over *expired index entries* — it never scans the filesystem for blobs with no matching index entry. A blob written here that then crashes before `withFileLock` updates the index will persist on disk indefinitely, accumulating across deployments.

Consider adding a periodic or startup sweep that lists `*.blob` files in the store dir and unlinks any whose ID is absent from the index, or acknowledge the gap in the comment.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: extensions/msteams/src/pending-uploads-fs.ts
Line: 206-213

Comment:
**`get()`, `remove()`, `count()` throw instead of returning defaults when `storeDir` doesn't exist**

`withFileLock` calls `ensureJsonFile`, which on ENOENT tries to create the index file via `writeJsonFileAtomically`. If the store directory hasn't been created yet (i.e., `store()` was never called), the parent-dir creation in `writeJsonFileAtomically` will fail with ENOENT, and `get()`/`remove()`/`count()` will throw rather than returning `undefined`/`0`.

`resolvePendingUpload` and `removeResolvedPendingUpload` both catch and swallow these errors, so the runtime is unaffected. But `count()` has no such guard and will throw unexpectedly for any caller that invokes it on a freshly created store before the first `store()` call (e.g., future monitoring/diagnostic code). Adding `await ensureStoreDir(storeDir)` at the top of `get()`, `remove()`, and `count()` would make the API uniformly safe.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: extensions/msteams/src/pending-uploads-fs.ts
Line: 79-82

Comment:
**`isSafeUploadId` regex accepts non-UUID strings**

The pattern `^[0-9a-f-]{36}$` allows strings like 36 hyphens or `aaaa-aaaa-aaaa-aaaa-aaaa-aaaa-aaaa-aaaa` that aren't canonical UUIDs but do pass the check. Path traversal is still prevented (no `/`, `\`, or `.`), so there's no security gap. Tightening to a proper UUID shape makes the intent clearer and closes the acceptance window:

```suggestion
  return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id);
```

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "fix(msteams): handle fileConsent/invoke ..." | Re-trigger Greptile

Comment on lines +183 to +184
// just disk litter that the next prune cycle can skip).
await fs.promises.writeFile(blobPath(storeDir, id), new Uint8Array(params.buffer));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Orphan blobs from crash are never reclaimed

The comment says orphan blobs (written before the index update) are "disk litter that the next prune cycle can skip," but readAndPruneLocked only iterates over expired index entries — it never scans the filesystem for blobs with no matching index entry. A blob written here that then crashes before withFileLock updates the index will persist on disk indefinitely, accumulating across deployments.

Consider adding a periodic or startup sweep that lists *.blob files in the store dir and unlinks any whose ID is absent from the index, or acknowledge the gap in the comment.

Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/msteams/src/pending-uploads-fs.ts
Line: 183-184

Comment:
**Orphan blobs from crash are never reclaimed**

The comment says orphan blobs (written before the index update) are "disk litter that the next prune cycle can skip," but `readAndPruneLocked` only iterates over *expired index entries* — it never scans the filesystem for blobs with no matching index entry. A blob written here that then crashes before `withFileLock` updates the index will persist on disk indefinitely, accumulating across deployments.

Consider adding a periodic or startup sweep that lists `*.blob` files in the store dir and unlinks any whose ID is absent from the index, or acknowledge the gap in the comment.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +206 to +213
async get(id) {
if (!id || !isSafeUploadId(id)) {
return undefined;
}
const storeDir = getStoreDir();
const indexPath = getIndexPath();
return await withFileLock(indexPath, EMPTY_INDEX, async () => {
const current = await readAndPruneLocked(storeDir, indexPath);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 get(), remove(), count() throw instead of returning defaults when storeDir doesn't exist

withFileLock calls ensureJsonFile, which on ENOENT tries to create the index file via writeJsonFileAtomically. If the store directory hasn't been created yet (i.e., store() was never called), the parent-dir creation in writeJsonFileAtomically will fail with ENOENT, and get()/remove()/count() will throw rather than returning undefined/0.

resolvePendingUpload and removeResolvedPendingUpload both catch and swallow these errors, so the runtime is unaffected. But count() has no such guard and will throw unexpectedly for any caller that invokes it on a freshly created store before the first store() call (e.g., future monitoring/diagnostic code). Adding await ensureStoreDir(storeDir) at the top of get(), remove(), and count() would make the API uniformly safe.

Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/msteams/src/pending-uploads-fs.ts
Line: 206-213

Comment:
**`get()`, `remove()`, `count()` throw instead of returning defaults when `storeDir` doesn't exist**

`withFileLock` calls `ensureJsonFile`, which on ENOENT tries to create the index file via `writeJsonFileAtomically`. If the store directory hasn't been created yet (i.e., `store()` was never called), the parent-dir creation in `writeJsonFileAtomically` will fail with ENOENT, and `get()`/`remove()`/`count()` will throw rather than returning `undefined`/`0`.

`resolvePendingUpload` and `removeResolvedPendingUpload` both catch and swallow these errors, so the runtime is unaffected. But `count()` has no such guard and will throw unexpectedly for any caller that invokes it on a freshly created store before the first `store()` call (e.g., future monitoring/diagnostic code). Adding `await ensureStoreDir(storeDir)` at the top of `get()`, `remove()`, and `count()` would make the API uniformly safe.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +79 to +82
function isSafeUploadId(id: string): boolean {
// UUIDs are safe; reject anything that could traverse paths.
return /^[0-9a-f-]{36}$/i.test(id);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 isSafeUploadId regex accepts non-UUID strings

The pattern ^[0-9a-f-]{36}$ allows strings like 36 hyphens or aaaa-aaaa-aaaa-aaaa-aaaa-aaaa-aaaa-aaaa that aren't canonical UUIDs but do pass the check. Path traversal is still prevented (no /, \, or .), so there's no security gap. Tightening to a proper UUID shape makes the intent clearer and closes the acceptance window:

Suggested change
function isSafeUploadId(id: string): boolean {
// UUIDs are safe; reject anything that could traverse paths.
return /^[0-9a-f-]{36}$/i.test(id);
}
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id);
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/msteams/src/pending-uploads-fs.ts
Line: 79-82

Comment:
**`isSafeUploadId` regex accepts non-UUID strings**

The pattern `^[0-9a-f-]{36}$` allows strings like 36 hyphens or `aaaa-aaaa-aaaa-aaaa-aaaa-aaaa-aaaa-aaaa` that aren't canonical UUIDs but do pass the check. Path traversal is still prevented (no `/`, `\`, or `.`), so there's no security gap. Tightening to a proper UUID shape makes the intent clearer and closes the acceptance window:

```suggestion
  return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id);
```

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3f34fb7255

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// just disk litter that the next prune cycle can skip).
await fs.promises.writeFile(blobPath(storeDir, id), new Uint8Array(params.buffer));
try {
await withFileLock(indexPath, EMPTY_INDEX, async () => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Serialize index bootstrap before storing pending uploads

The new store() path calls withFileLock(indexPath, EMPTY_INDEX, ...), but withFileLock initializes index.json via an access/write check before taking the lock. On a fresh directory, two concurrent send processes can both observe "missing index"; one process can commit an entry, then the other process’s pre-lock fallback write can replace the index with EMPTY_INDEX, dropping the first upload row. That dropped row makes a valid consent callback look expired because the upload can no longer be resolved. Bootstrap/creation of the index file needs to be done under the same lock (or with atomic create semantics) to avoid this first-write clobber race.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

channel: msteams Channel integration: msteams r: too-many-prs Auto-close: author has more than twenty active PRs. size: L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[msteams] Bot-to-user file upload via message send --media fails — fileConsent callback not handled

1 participant