Publish Claude Code source snapshot from a single baseline commit
Squash the imported source snapshot and follow-up documentation history into one root commit so the archive starts from a single coherent state. Constraint: Repository intentionally tracks an analyzed Claude Code source snapshot Constraint: Author and committer must be instructkr <no-contact@instruct.kr> Rejected: Preserve the four-step import/docs history | user explicitly requested one squashed commit Confidence: high Scope-risk: broad Reversibility: clean Directive: Keep future analysis and refactor commits separate from this archive baseline Tested: git status clean; local history rewritten to one commit; force-pushed main to origin and instructkr Not-tested: Fresh clone verification after push
This commit is contained in:
175
src/bridge/inboundAttachments.ts
Normal file
175
src/bridge/inboundAttachments.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Resolve file_uuid attachments on inbound bridge user messages.
|
||||
*
|
||||
* Web composer uploads via cookie-authed /api/{org}/upload, sends file_uuid
|
||||
* alongside the message. Here we fetch each via GET /api/oauth/files/{uuid}/content
|
||||
* (oauth-authed, same store), write to ~/.claude/uploads/{sessionId}/, and
|
||||
* return @path refs to prepend. Claude's Read tool takes it from there.
|
||||
*
|
||||
* Best-effort: any failure (no token, network, non-2xx, disk) logs debug and
|
||||
* skips that attachment. The message still reaches Claude, just without @path.
|
||||
*/
|
||||
|
||||
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs'
|
||||
import axios from 'axios'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { mkdir, writeFile } from 'fs/promises'
|
||||
import { basename, join } from 'path'
|
||||
import { z } from 'zod/v4'
|
||||
import { getSessionId } from '../bootstrap/state.js'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import { getClaudeConfigHomeDir } from '../utils/envUtils.js'
|
||||
import { lazySchema } from '../utils/lazySchema.js'
|
||||
import { getBridgeAccessToken, getBridgeBaseUrl } from './bridgeConfig.js'
|
||||
|
||||
const DOWNLOAD_TIMEOUT_MS = 30_000
|
||||
|
||||
function debug(msg: string): void {
|
||||
logForDebugging(`[bridge:inbound-attach] ${msg}`)
|
||||
}
|
||||
|
||||
const attachmentSchema = lazySchema(() =>
|
||||
z.object({
|
||||
file_uuid: z.string(),
|
||||
file_name: z.string(),
|
||||
}),
|
||||
)
|
||||
const attachmentsArraySchema = lazySchema(() => z.array(attachmentSchema()))
|
||||
|
||||
export type InboundAttachment = z.infer<ReturnType<typeof attachmentSchema>>
|
||||
|
||||
/** Pull file_attachments off a loosely-typed inbound message. */
|
||||
export function extractInboundAttachments(msg: unknown): InboundAttachment[] {
|
||||
if (typeof msg !== 'object' || msg === null || !('file_attachments' in msg)) {
|
||||
return []
|
||||
}
|
||||
const parsed = attachmentsArraySchema().safeParse(msg.file_attachments)
|
||||
return parsed.success ? parsed.data : []
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip path components and keep only filename-safe chars. file_name comes
|
||||
* from the network (web composer), so treat it as untrusted even though the
|
||||
* composer controls it.
|
||||
*/
|
||||
function sanitizeFileName(name: string): string {
|
||||
const base = basename(name).replace(/[^a-zA-Z0-9._-]/g, '_')
|
||||
return base || 'attachment'
|
||||
}
|
||||
|
||||
function uploadsDir(): string {
|
||||
return join(getClaudeConfigHomeDir(), 'uploads', getSessionId())
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch + write one attachment. Returns the absolute path on success,
|
||||
* undefined on any failure.
|
||||
*/
|
||||
async function resolveOne(att: InboundAttachment): Promise<string | undefined> {
|
||||
const token = getBridgeAccessToken()
|
||||
if (!token) {
|
||||
debug('skip: no oauth token')
|
||||
return undefined
|
||||
}
|
||||
|
||||
let data: Buffer
|
||||
try {
|
||||
// getOauthConfig() (via getBridgeBaseUrl) throws on a non-allowlisted
|
||||
// CLAUDE_CODE_CUSTOM_OAUTH_URL — keep it inside the try so a bad
|
||||
// FedStart URL degrades to "no @path" instead of crashing print.ts's
|
||||
// reader loop (which has no catch around the await).
|
||||
const url = `${getBridgeBaseUrl()}/api/oauth/files/${encodeURIComponent(att.file_uuid)}/content`
|
||||
const response = await axios.get(url, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
responseType: 'arraybuffer',
|
||||
timeout: DOWNLOAD_TIMEOUT_MS,
|
||||
validateStatus: () => true,
|
||||
})
|
||||
if (response.status !== 200) {
|
||||
debug(`fetch ${att.file_uuid} failed: status=${response.status}`)
|
||||
return undefined
|
||||
}
|
||||
data = Buffer.from(response.data)
|
||||
} catch (e) {
|
||||
debug(`fetch ${att.file_uuid} threw: ${e}`)
|
||||
return undefined
|
||||
}
|
||||
|
||||
// uuid-prefix makes collisions impossible across messages and within one
|
||||
// (same filename, different files). 8 chars is enough — this isn't security.
|
||||
const safeName = sanitizeFileName(att.file_name)
|
||||
const prefix = (
|
||||
att.file_uuid.slice(0, 8) || randomUUID().slice(0, 8)
|
||||
).replace(/[^a-zA-Z0-9_-]/g, '_')
|
||||
const dir = uploadsDir()
|
||||
const outPath = join(dir, `${prefix}-${safeName}`)
|
||||
|
||||
try {
|
||||
await mkdir(dir, { recursive: true })
|
||||
await writeFile(outPath, data)
|
||||
} catch (e) {
|
||||
debug(`write ${outPath} failed: ${e}`)
|
||||
return undefined
|
||||
}
|
||||
|
||||
debug(`resolved ${att.file_uuid} → ${outPath} (${data.length} bytes)`)
|
||||
return outPath
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve all attachments on an inbound message to a prefix string of
|
||||
* @path refs. Empty string if none resolved.
|
||||
*/
|
||||
export async function resolveInboundAttachments(
|
||||
attachments: InboundAttachment[],
|
||||
): Promise<string> {
|
||||
if (attachments.length === 0) return ''
|
||||
debug(`resolving ${attachments.length} attachment(s)`)
|
||||
const paths = await Promise.all(attachments.map(resolveOne))
|
||||
const ok = paths.filter((p): p is string => p !== undefined)
|
||||
if (ok.length === 0) return ''
|
||||
// Quoted form — extractAtMentionedFiles truncates unquoted @refs at the
|
||||
// first space, which breaks any home dir with spaces (/Users/John Smith/).
|
||||
return ok.map(p => `@"${p}"`).join(' ') + ' '
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepend @path refs to content, whichever form it's in.
|
||||
* Targets the LAST text block — processUserInputBase reads inputString
|
||||
* from processedBlocks[processedBlocks.length - 1], so putting refs in
|
||||
* block[0] means they're silently ignored for [text, image] content.
|
||||
*/
|
||||
export function prependPathRefs(
|
||||
content: string | Array<ContentBlockParam>,
|
||||
prefix: string,
|
||||
): string | Array<ContentBlockParam> {
|
||||
if (!prefix) return content
|
||||
if (typeof content === 'string') return prefix + content
|
||||
const i = content.findLastIndex(b => b.type === 'text')
|
||||
if (i !== -1) {
|
||||
const b = content[i]!
|
||||
if (b.type === 'text') {
|
||||
return [
|
||||
...content.slice(0, i),
|
||||
{ ...b, text: prefix + b.text },
|
||||
...content.slice(i + 1),
|
||||
]
|
||||
}
|
||||
}
|
||||
// No text block — append one at the end so it's last.
|
||||
return [...content, { type: 'text', text: prefix.trimEnd() }]
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: extract + resolve + prepend. No-op when the message has no
|
||||
* file_attachments field (fast path — no network, returns same reference).
|
||||
*/
|
||||
export async function resolveAndPrepend(
|
||||
msg: unknown,
|
||||
content: string | Array<ContentBlockParam>,
|
||||
): Promise<string | Array<ContentBlockParam>> {
|
||||
const attachments = extractInboundAttachments(msg)
|
||||
if (attachments.length === 0) return content
|
||||
const prefix = await resolveInboundAttachments(attachments)
|
||||
return prependPathRefs(content, prefix)
|
||||
}
|
||||
Reference in New Issue
Block a user