fix(plugin): refresh guild token per dispatch (fixes attachment 401)

Guild access tokens are short-lived (~15 min); the inbound socket
survives via socket.io reconnect but the token captured at connect
time goes stale, so attachment downloads (and reply posts) start
401ing on long-lived agents. Re-login with the agent's Fabric API key
on a short TTL and use the fresh token for fetch + post.

Verified live: 'fabric: fetched 1 attachment(s)' now succeeds where it
previously logged 'attachment fetch 401'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
h z
2026-05-16 00:03:31 +01:00
parent cc655ffcc3
commit d79a04b8a3
2 changed files with 65 additions and 2 deletions

View File

@@ -12,6 +12,35 @@ export class FabricInbound {
accounts; accounts;
sockets = []; sockets = [];
seen = new Set(); seen = new Set();
// Guild access tokens are short-lived (~15 min). The socket survives via
// socket.io reconnect, but the token captured at connect time goes stale,
// so HTTP calls (attachment download, posting the reply) start 401ing.
// Re-login per agent on a short TTL to keep a fresh token.
tokenCache = new Map();
static TOKEN_TTL_MS = 8 * 60 * 1000;
// Return a fresh guild access token for the agent, re-authenticating with
// the agent's Fabric API key when the cached session is stale. Falls back
// to the connect-time session token if re-login fails.
async freshGuildToken(agentId, guildNodeId, fallback) {
const pick = (s) => s.guildAccessTokens.find((t) => t.guildNodeId === guildNodeId)?.token;
const now = Date.now();
const cached = this.tokenCache.get(agentId);
if (cached && now - cached.at < FabricInbound.TOKEN_TTL_MS) {
return pick(cached.session) ?? pick(fallback);
}
const apiKey = this.identity.findByAgentId(agentId)?.fabricApiKey;
if (apiKey) {
try {
const s = await this.client.agentLogin(apiKey);
this.tokenCache.set(agentId, { session: s, at: now });
return pick(s) ?? pick(fallback);
}
catch (err) {
this.log.warn(`fabric: token refresh failed agent=${agentId}: ${String(err)}`);
}
}
return pick(fallback);
}
constructor(core, // PluginRuntime constructor(core, // PluginRuntime
cfg, // OpenClawConfig cfg, // OpenClawConfig
client, identity, log, accounts = []) { client, identity, log, accounts = []) {
@@ -151,7 +180,7 @@ export class FabricInbound {
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
agentId: route.agentId, agentId: route.agentId,
}); });
const gt = session.guildAccessTokens.find((t) => t.guildNodeId === guild.nodeId)?.token; const gt = await this.freshGuildToken(agentId, guild.nodeId, session);
// Fetch any uploaded files for the agent: download to a temp dir and // Fetch any uploaded files for the agent: download to a temp dir and
// hand openclaw local MediaPaths (+types) so the model receives them. // hand openclaw local MediaPaths (+types) so the model receives them.
const media = await this.fetchAttachments(agentId, guild.endpoint, gt, m); const media = await this.fetchAttachments(agentId, guild.endpoint, gt, m);

View File

@@ -38,6 +38,40 @@ type FabricMessage = {
export class FabricInbound { export class FabricInbound {
private sockets: Socket[] = []; private sockets: Socket[] = [];
private seen = new Set<string>(); private seen = new Set<string>();
// Guild access tokens are short-lived (~15 min). The socket survives via
// socket.io reconnect, but the token captured at connect time goes stale,
// so HTTP calls (attachment download, posting the reply) start 401ing.
// Re-login per agent on a short TTL to keep a fresh token.
private tokenCache = new Map<string, { session: FabricSession; at: number }>();
private static readonly TOKEN_TTL_MS = 8 * 60 * 1000;
// Return a fresh guild access token for the agent, re-authenticating with
// the agent's Fabric API key when the cached session is stale. Falls back
// to the connect-time session token if re-login fails.
private async freshGuildToken(
agentId: string,
guildNodeId: string,
fallback: FabricSession,
): Promise<string | undefined> {
const pick = (s: FabricSession) =>
s.guildAccessTokens.find((t) => t.guildNodeId === guildNodeId)?.token;
const now = Date.now();
const cached = this.tokenCache.get(agentId);
if (cached && now - cached.at < FabricInbound.TOKEN_TTL_MS) {
return pick(cached.session) ?? pick(fallback);
}
const apiKey = this.identity.findByAgentId(agentId)?.fabricApiKey;
if (apiKey) {
try {
const s = await this.client.agentLogin(apiKey);
this.tokenCache.set(agentId, { session: s, at: now });
return pick(s) ?? pick(fallback);
} catch (err) {
this.log.warn(`fabric: token refresh failed agent=${agentId}: ${String(err)}`);
}
}
return pick(fallback);
}
constructor( constructor(
private readonly core: unknown, // PluginRuntime private readonly core: unknown, // PluginRuntime
@@ -188,7 +222,7 @@ export class FabricInbound {
agentId: route.agentId, agentId: route.agentId,
}); });
const gt = session.guildAccessTokens.find((t) => t.guildNodeId === guild.nodeId)?.token; const gt = await this.freshGuildToken(agentId, guild.nodeId, session);
// Fetch any uploaded files for the agent: download to a temp dir and // Fetch any uploaded files for the agent: download to a temp dir and
// hand openclaw local MediaPaths (+types) so the model receives them. // hand openclaw local MediaPaths (+types) so the model receives them.