← back to Norma
instagram-agent: make reel skill account-aware (close cross-brand IG-token leak)
68552cedebfeed4f6ffd1efdbeccc89a3b9b97ea · 2026-07-13 10:24:27 -0700 · Steve Abrams
- resolveAccount(account): 'dw' → IG_USER_ID/IG_ACCESS_TOKEN (unchanged);
other id X → IG_USER_ID_<X> / IG_ACCESS_TOKEN_<X> (X=upper, dashes→underscores),
mirroring dw-marketing-reels publish-social.mjs IG_AGENT_AUTH_<ID> naming.
- non-dw account with NO token now returns { status:'no-creds-for-account',
account, note } instead of silently posting from the DW account (the bug).
- echoes resolved account in response + logs; dw single-account simulation/live
path 100% intact. node --check passes; verified via in-process skill calls.
Files touched
M agents/instagram-agent/skills/reel.js
Diff
commit 68552cedebfeed4f6ffd1efdbeccc89a3b9b97ea
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Jul 13 10:24:27 2026 -0700
instagram-agent: make reel skill account-aware (close cross-brand IG-token leak)
- resolveAccount(account): 'dw' → IG_USER_ID/IG_ACCESS_TOKEN (unchanged);
other id X → IG_USER_ID_<X> / IG_ACCESS_TOKEN_<X> (X=upper, dashes→underscores),
mirroring dw-marketing-reels publish-social.mjs IG_AGENT_AUTH_<ID> naming.
- non-dw account with NO token now returns { status:'no-creds-for-account',
account, note } instead of silently posting from the DW account (the bug).
- echoes resolved account in response + logs; dw single-account simulation/live
path 100% intact. node --check passes; verified via in-process skill calls.
---
agents/instagram-agent/skills/reel.js | 69 +++++++++++++++++++++++++++++++----
1 file changed, 62 insertions(+), 7 deletions(-)
diff --git a/agents/instagram-agent/skills/reel.js b/agents/instagram-agent/skills/reel.js
index 0e0153c..5053d20 100644
--- a/agents/instagram-agent/skills/reel.js
+++ b/agents/instagram-agent/skills/reel.js
@@ -14,6 +14,34 @@ const ig = require('./_ig-api');
const AGENT = 'instagram-agent';
const PLATFORM = 'instagram';
+/**
+ * Resolve the IG credential pair for a given account id.
+ *
+ * The dw-marketing-reels publisher (scripts/publish-social.mjs) tags every
+ * reel with an `account` id ('dw', 'phillipe-romano', …) and names its own
+ * per-account auth header env as IG_AGENT_AUTH_<ID> where
+ * <ID> = account.toUpperCase().replace(/-/g, '_'). We MIRROR that exact
+ * naming here for the IG token pair so the two sides agree:
+ *
+ * account 'dw' → IG_USER_ID + IG_ACCESS_TOKEN (existing)
+ * account 'phillipe-romano' → IG_USER_ID_PHILLIPE_ROMANO + IG_ACCESS_TOKEN_PHILLIPE_ROMANO
+ *
+ * Returns { account, userId, accessToken } — userId/accessToken are undefined
+ * when that account has no token configured (caller returns no-creds-for-account).
+ */
+function resolveAccount(account) {
+ const acct = (account || 'dw').toString().trim() || 'dw';
+ if (acct === 'dw') {
+ return { account: 'dw', userId: process.env.IG_USER_ID, accessToken: process.env.IG_ACCESS_TOKEN };
+ }
+ const suffix = acct.toUpperCase().replace(/-/g, '_');
+ return {
+ account: acct,
+ userId: process.env[`IG_USER_ID_${suffix}`],
+ accessToken: process.env[`IG_ACCESS_TOKEN_${suffix}`],
+ };
+}
+
/**
* @param {Object} params - Request body
* @param {string} params.video_url - Public URL of the video file
@@ -21,28 +49,56 @@ const PLATFORM = 'instagram';
* @param {boolean} [params.share_to_feed=true] - Also share to main feed
* @param {string} [params.cover_url] - Cover image URL (optional)
* @param {string} [params.pipeline_id] - Pipeline entry ID to link
+ * @param {string} [params.account='dw'] - dw-marketing-reels account id; selects which IG token pair to use
* @returns {Promise<Object>}
*/
module.exports = async function reel(params) {
const videoUrl = params.video_url || '';
const caption = params.caption || params.text || '';
const shareToFeed = params.share_to_feed !== false;
- const simulated = !ig.hasCredentials();
- console.log(`[${AGENT}] Reel skill invoked — simulation=${simulated}`);
+ // Resolve WHICH account's credentials to use (default 'dw' = legacy behavior).
+ const resolved = resolveAccount(params.account);
+ const account = resolved.account;
+ const igUserId = resolved.userId;
+ const accessToken = resolved.accessToken;
+ const hasCreds = !!(igUserId && accessToken);
+ const simulated = !hasCreds;
+
+ console.log(`[${AGENT}] Reel skill invoked — account=${account} simulation=${simulated}`);
console.log(`[${AGENT}] Caption: ${caption.substring(0, 100)}${caption.length > 100 ? '...' : ''}`);
if (videoUrl) console.log(`[${AGENT}] Video URL: ${videoUrl}`);
+ // CORRECTNESS GATE: a NON-'dw' account that has no token of its own must NOT
+ // silently fall through to DW's token — posting brand X's reel from DW's
+ // account is a real cross-brand leak. Return a clear structured result.
+ if (account !== 'dw' && !hasCreds) {
+ const suffix = account.toUpperCase().replace(/-/g, '_');
+ console.log(`[${AGENT}] No credentials for account '${account}' — refusing to fall through to DW. (need IG_USER_ID_${suffix} + IG_ACCESS_TOKEN_${suffix})`);
+ return {
+ status: 'no-creds-for-account',
+ account,
+ note: `account ${account} has no IG_ACCESS_TOKEN_${suffix} configured`,
+ posted: false,
+ simulated: false,
+ platform: PLATFORM,
+ video_url: videoUrl || null,
+ pipeline_id: params.pipeline_id || null,
+ created_at: new Date().toISOString(),
+ };
+ }
+
if (simulated) {
// --- Simulation mode ---
const simulatedContainerId = `ig_reel_container_${Date.now()}`;
const simulatedMediaId = `ig_reel_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`;
- console.log(`[${AGENT}] SIMULATED reel created: container=${simulatedContainerId}, media=${simulatedMediaId}`);
+ console.log(`[${AGENT}] SIMULATED reel created (account=${account}): container=${simulatedContainerId}, media=${simulatedMediaId}`);
return {
media_id: simulatedMediaId,
container_id: simulatedContainerId,
+ account,
caption,
video_url: videoUrl || null,
share_to_feed: shareToFeed,
@@ -57,11 +113,9 @@ module.exports = async function reel(params) {
}
// --- Real API call (Instagram API with Instagram Login) ---
+ // igUserId/accessToken were resolved above for `account` (creds guaranteed present here).
if (!videoUrl) throw new Error('video_url is required to post a reel');
- const igUserId = process.env.IG_USER_ID;
- const accessToken = process.env.IG_ACCESS_TOKEN;
-
// Step 1: create the reel container
const containerBody = {
media_type: 'REELS',
@@ -80,11 +134,12 @@ module.exports = async function reel(params) {
// Step 3: publish
const mediaId = await ig.publishContainer(igUserId, accessToken, containerId);
const permalink = await ig.permalinkOf(mediaId, accessToken);
- console.log(`[${AGENT}] Reel published: media=${mediaId} ${permalink || ''}`);
+ console.log(`[${AGENT}] Reel published (account=${account}): media=${mediaId} ${permalink || ''}`);
return {
media_id: mediaId,
container_id: containerId,
+ account,
caption,
video_url: videoUrl,
share_to_feed: shareToFeed,
← 5d84a60 chore: v0.1.3 (dashboard CSS refactor)
·
back to Norma
·
instagram-agent: env-configurable Graph host (IG_GRAPH_HOST) 05b28cd →