Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions src/__tests__/utils/sha256.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'bun:test'
import { sha256Hex } from '@core/utils/sha256'

// Reference digest via the platform's native WebCrypto (always available in
// Bun / the test runner), so the pure-JS implementation is checked against the
// real algorithm rather than a hand-copied constant.
async function nativeSha256Hex(input: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input))
return Array.from(new Uint8Array(digest))
.map((byte) => byte.toString(16).padStart(2, '0'))
.join('')
}

describe('sha256Hex', () => {
it('matches the published empty-string and "abc" vectors', () => {
expect(sha256Hex('')).toBe(
'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
)
expect(sha256Hex('abc')).toBe(
'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad',
)
})

it('always returns 64 lowercase hex chars', () => {
for (const input of ['', 'a', 'hello world', 'x'.repeat(1000)]) {
expect(sha256Hex(input)).toMatch(/^[0-9a-f]{64}$/)
}
})

it('agrees with native WebCrypto across sizes and block boundaries', async () => {
const inputs = [
'',
'a',
'The quick brown fox jumps over the lazy dog',
// Multi-byte UTF-8 (emoji + accents) — exercises the byte encoding.
'héllo — 世界 🚀',
// Exactly 55, 56, 63, 64, 65 bytes: the padding edge cases.
'x'.repeat(55),
'x'.repeat(56),
'x'.repeat(63),
'x'.repeat(64),
'x'.repeat(65),
// A realistic code-asset payload spanning several blocks.
`body { color: var(--text); }\n`.repeat(400),
]
for (const input of inputs) {
expect(sha256Hex(input)).toBe(await nativeSha256Hex(input))
}
})

it('is deterministic and sensitive to single-character changes', () => {
expect(sha256Hex('const x = 1')).toBe(sha256Hex('const x = 1'))
expect(sha256Hex('const x = 1')).not.toBe(sha256Hex('const x = 2'))
})
})
27 changes: 15 additions & 12 deletions src/admin/pages/site/agent/codeAssetTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
normalizeStyleRuntimeConfig,
} from '@core/site-runtime'
import { isSafePackageName } from '@core/site-dependencies/packageNames'
import { sha256Hex } from '@core/utils/sha256'
import type { EditorStore } from '@site/store/types'
import { activeRenderPage } from './documentTools'
import { getAgentStoreApi } from './storeRef'
Expand All @@ -46,11 +47,13 @@ function contentForCodeAsset(file: CodeAssetFile): string {
return file.content ?? ''
}

async function hashCodeAssetContent(content: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', textEncoder.encode(content))
return Array.from(new Uint8Array(digest))
.map((byte) => byte.toString(16).padStart(2, '0'))
.join('')
// SHA-256 content fingerprint used for read→patch optimistic concurrency. The
// agent's tools run in the browser, and the CMS is often reached over Tailscale
// on plain http:// (a non-secure context where `crypto.subtle` is undefined),
// so this must NOT depend on Web Crypto. `sha256Hex` produces the identical
// 64-char digest in any context.
function hashCodeAssetContent(content: string): string {
return sha256Hex(content)
}

function normalizeCodeAssetPath(path: string): string | null {
Expand All @@ -65,15 +68,15 @@ function codeAssetRuntime(store: EditorStore, file: CodeAssetFile) {
: (runtime.styles[file.id] ?? { ...DEFAULT_STYLE_RUNTIME_CONFIG })
}

async function describeCodeAsset(store: EditorStore, file: CodeAssetFile) {
function describeCodeAsset(store: EditorStore, file: CodeAssetFile) {
const content = contentForCodeAsset(file)
return {
fileId: file.id,
path: file.path,
type: file.type,
contentChars: content.length,
bytes: textEncoder.encode(content).byteLength,
hash: await hashCodeAssetContent(content),
hash: hashCodeAssetContent(content),
createdAt: file.createdAt,
updatedAt: file.updatedAt,
generated: file.generated === true,
Expand Down Expand Up @@ -209,7 +212,7 @@ export async function runListCodeAssets(input: ListCodeAssetsInput): Promise<AiT

const assets = []
for (const file of files) {
assets.push(await describeCodeAsset(store, file))
assets.push(describeCodeAsset(store, file))
}
return aiToolOk({ assets })
}
Expand All @@ -234,7 +237,7 @@ export async function runReadCodeAsset(input: ReadCodeAssetInput): Promise<AiToo
path: resolved.file.path,
type: resolved.file.type,
content: content.slice(start, end),
hash: await hashCodeAssetContent(content),
hash: hashCodeAssetContent(content),
runtime: codeAssetRuntime(store, resolved.file),
pageInfo: {
part,
Expand Down Expand Up @@ -293,7 +296,7 @@ export async function runWriteCodeAsset(input: WriteCodeAssetInput): Promise<AiT
return aiToolError(`Code asset write failed for ${path}.`)
}
return aiToolOk({
...(await describeCodeAsset(afterRuntimeStore, finalFile)),
...describeCodeAsset(afterRuntimeStore, finalFile),
action,
dependencies: requestedDependencies.dependencies,
})
Expand All @@ -305,7 +308,7 @@ export async function runPatchCodeAsset(input: PatchCodeAssetInput): Promise<AiT
if (!resolved.ok) return aiToolError(resolved.error)

const currentContent = contentForCodeAsset(resolved.file)
const currentHash = await hashCodeAssetContent(currentContent)
const currentHash = hashCodeAssetContent(currentContent)
if (currentHash !== input.expectedHash) {
return aiToolError(
`Code asset hash mismatch for ${resolved.file.path}; read_code_asset again before patching.`,
Expand Down Expand Up @@ -342,7 +345,7 @@ export async function runPatchCodeAsset(input: PatchCodeAssetInput): Promise<AiT
return aiToolError(`Code asset patch failed for ${resolved.file.path}.`)
}
return aiToolOk({
...(await describeCodeAsset(afterStore, file)),
...describeCodeAsset(afterStore, file),
replacements: replacementCount,
})
}
Expand Down
108 changes: 108 additions & 0 deletions src/core/utils/sha256.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* Dependency-free SHA-256 → lowercase hex, usable in ANY execution context.
*
* The admin app runs the AI agent's tool calls in the browser, and this CMS is
* routinely reached over a private network (Tailscale / LAN) on plain
* `http://` — a **non-secure context**, where the browser leaves
* `window.crypto.subtle` `undefined`. A tool that hashed content via WebCrypto
* therefore threw "crypto.subtle is undefined" on its first call and hung the
* agent. This implementation computes the identical digest with no Web Crypto
* dependency, so it behaves the same on `http://tailscale-host`,
* `http://localhost`, and `https://` alike.
*
* Scope: short in-editor content fingerprints (change detection / optimistic
* concurrency). It is NOT for security tokens — those hash server-side with
* Bun's native `crypto.subtle`, which is always available there.
*/

// SHA-256 round constants (first 32 bits of the fractional parts of the cube
// roots of the first 64 primes).
const K = new Uint32Array([
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
])

function rotr(value: number, bits: number): number {
return (value >>> bits) | (value << (32 - bits))
}

export function sha256Hex(input: string): string {
const bytes = new TextEncoder().encode(input)
const bitLength = bytes.length * 8

// Pad: append 0x80, then zeros, so the total length (incl. the trailing
// 64-bit big-endian bit count) is a multiple of 64 bytes.
const withMarker = bytes.length + 1
const totalLength = withMarker + ((56 - (withMarker % 64) + 64) % 64) + 8
const padded = new Uint8Array(totalLength)
padded.set(bytes)
padded[bytes.length] = 0x80
const view = new DataView(padded.buffer)
view.setUint32(totalLength - 8, Math.floor(bitLength / 0x100000000), false)
view.setUint32(totalLength - 4, bitLength >>> 0, false)

// Initial hash values (fractional parts of the square roots of the first 8
// primes).
const h = new Uint32Array([
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
])
const w = new Uint32Array(64)

for (let offset = 0; offset < totalLength; offset += 64) {
for (let i = 0; i < 16; i++) {
w[i] = view.getUint32(offset + i * 4, false)
}
for (let i = 16; i < 64; i++) {
const s0 = rotr(w[i - 15]!, 7) ^ rotr(w[i - 15]!, 18) ^ (w[i - 15]! >>> 3)
const s1 = rotr(w[i - 2]!, 17) ^ rotr(w[i - 2]!, 19) ^ (w[i - 2]! >>> 10)
w[i] = (w[i - 16]! + s0 + w[i - 7]! + s1) >>> 0
}

let a = h[0]!
let b = h[1]!
let c = h[2]!
let d = h[3]!
let e = h[4]!
let f = h[5]!
let g = h[6]!
let hh = h[7]!

for (let i = 0; i < 64; i++) {
const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)
const ch = (e & f) ^ (~e & g)
const t1 = (hh + S1 + ch + K[i]! + w[i]!) >>> 0
const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)
const maj = (a & b) ^ (a & c) ^ (b & c)
const t2 = (S0 + maj) >>> 0
hh = g
g = f
f = e
e = (d + t1) >>> 0
d = c
c = b
b = a
a = (t1 + t2) >>> 0
}

h[0] = (h[0]! + a) >>> 0
h[1] = (h[1]! + b) >>> 0
h[2] = (h[2]! + c) >>> 0
h[3] = (h[3]! + d) >>> 0
h[4] = (h[4]! + e) >>> 0
h[5] = (h[5]! + f) >>> 0
h[6] = (h[6]! + g) >>> 0
h[7] = (h[7]! + hh) >>> 0
}

let hex = ''
for (let i = 0; i < 8; i++) {
hex += h[i]!.toString(16).padStart(8, '0')
}
return hex
}