feat: add AI agent workspace (#3062)

* chore(ai): remove local AI store now that providers live server-side

The Zustand-based useAIStore has been replaced by the server-side
aiProviders oRPC router (encrypted credentials persisted in DB).
Delete the dead store + tests, drop the ./store export, and remove
zustand/immer deps which are no longer referenced anywhere in
packages/ai/src/.

* feat(agent): archive/delete actions and read-only state for agent threads

- Backend: mark archived threads as read-only in threads.get and reject
  messages.send with CONFLICT when the thread is archived.
- Frontend: render archived threads in the sidebar with muted styling and
  an Archived badge; add a per-thread dropdown menu in the chat header
  with Archive (non-destructive) and Delete (with confirmation); show a
  read-only banner above the message list that disambiguates archived
  vs. missing-resource causes; suppress the Retry and Stop buttons in
  read-only mode.
- Tests: new packages/api/src/services/agent.test.ts covering the
  archived-thread isReadOnly flag and the archived-thread send refusal.

* fix(agent): abort run on archive and verify ownership before deleting thread

- threads.archive: before flipping status, abort any in-flight run controller
  and clear the active-run state on the thread; cleanup failures are logged
  but do not block the status update.
- threads.delete: assert thread ownership via getThread before destructive
  work so an authenticated user cannot wipe another user's attachment rows
  by passing a foreign threadId.

Adds focused tests for both behaviors.

* feat(agent): display patch diffs and surface revert conflicts

Render apply_resume_patch tool messages with a status-aware card (applied/
reverted/conflicted), expandable operation list, and a Revert button that
correctly handles RESUME_VERSION_CONFLICT responses. Adds unit tests for
the inverse-patch builder and the agentService.actions.revert flow.

* chore(agent): remove out-of-scope attachment tests accidentally added in Task 6

The Task 6 commit (73ef1acca) accidentally re-introduced three attachment-
related tests that belong to a separate task:

- `buildAttachmentModelParts > converts text, image, supported binary, and
  unsupported attachments into model parts`
- `agentService.messages.send > persists the user message with file UI parts
  and links selected attachments to it` (was failing — the `ToolLoopAgent`
  mock is not callable as a constructor)
- `agentService.messages.send > rejects attachments that are missing, foreign,
  or already linked before persisting a message`

These were likely re-added during a stash recovery and were not requested
for Task 6, whose scope was limited to the `agentService.actions.revert`
flow. Remove them along with the helpers/fixtures (`buildAttachment`,
`buildActiveThread`, `selectWhereResult`, `selectOrderByResult`) that they
were the only consumers of. `selectLimitResult` is preserved because it is
used by the revert tests.

* chore(agent): configure runtime dependencies

* feat(db): add agent workspace schema

* feat(api): add agent backend services

* feat(web): add agent workspace UI

* chore(agent): remove legacy builder assistant

* test(agent): make agent stream mocks constructible

* chore(web): remove unused resume replacement hook

* feat(api): add unsafe AI base URL flag

* chore(dev): expose local services in compose

* fix(web): normalize resume preview gaps

* feat(api): improve agent tool handling

* feat(web): polish agent workspace UI

* chore: update dependencies

* fix(api,web): address PR review feedback for agent workspace

Security/correctness:
- Restrict AI provider URLs to http/https even in unsafe mode
- Stop exposing Redis on host network by default
- Make .env.local optional and drop app profile in compose.dev.yml
- Store agent attachments with private ACL on S3
- Reset provider test status when provider/model/baseURL changes
- Decouple non-agent AI endpoints from REDIS_URL requirement
- Fix JSON Patch add inverse for existing object members
- Wrap resume patch + agent action insert in db transaction
- Validate partialMessage at runtime and rate-limit attachment uploads
- Add unique index on agent_messages (thread_id, sequence)

UX/bugs:
- Mark agent thread route as ssr: false and guard SSE chunk parsing
- Show config-specific banner only on known configuration error
- Gate AI provider checks behind loading state in resume import
- Fix relative-time formatter blank gap between 45-59 seconds
- Clarify thread delete confirmation message

Polish:
- Raise ENCRYPTION_SECRET minimum to 32 characters
- Bucket AI rate limits by resumeId/threadId/messageId
- Trim form values before submitting AI provider config
- Use single key identifier and nullish-coalesce baseURL display

* fix: address ai agent review feedback

* fix: preserve mobile agent chat state

* docs: add ai agent workspace guides

* feat: introduce design system for Reactive Resume
This commit is contained in:
Amruth Pillai
2026-05-14 15:00:04 +02:00
committed by GitHub
parent 22c60c64b6
commit 6d8d8f6e55
115 changed files with 15623 additions and 2123 deletions
+134 -1
View File
@@ -8,6 +8,57 @@ import {
} from "./url-security.node";
describe("isPrivateOrLoopbackHost", () => {
it.each([
"0.0.0.0",
"10.0.0.1",
"100.64.0.1",
"100.127.255.255",
"127.0.0.1",
"169.254.1.1",
"172.16.0.1",
"172.31.255.255",
"192.0.0.1",
"192.0.2.1",
"192.88.99.1",
"192.168.0.1",
"198.18.0.1",
"198.19.255.255",
"198.51.100.1",
"203.0.113.1",
"224.0.0.1",
"240.0.0.1",
"255.255.255.255",
])("matches non-public/special-use IPv4 address %s", (address) => {
expect(isPrivateOrLoopbackHost(address)).toBe(true);
});
it.each([
"::",
"::1",
"::ffff:8.8.8.8",
"::ffff:0808:0808",
"64:ff9b::1",
"64:ff9b:1::1",
"100::1",
"100:0:0:1::1",
"2001::1",
"2001:2::1",
"2001:10::1",
"2001:db8::1",
"2002::1",
"3fff::1",
"5f00::1",
"fc00::1",
"fd12::1",
"fe80::1",
"fe81::1",
"febf::1",
"ff00::1",
"ff02::1",
])("matches non-public/special-use IPv6 address %s", (address) => {
expect(isPrivateOrLoopbackHost(address)).toBe(true);
});
describe("loopback hostnames", () => {
it("matches localhost", () => {
expect(isPrivateOrLoopbackHost("localhost")).toBe(true);
@@ -63,9 +114,43 @@ describe("isPrivateOrLoopbackHost", () => {
expect(isPrivateOrLoopbackHost("0.0.0.0")).toBe(true);
});
it("matches 192.0.0.0/24", () => {
expect(isPrivateOrLoopbackHost("192.0.0.1")).toBe(true);
});
it("matches documentation IPv4 ranges", () => {
expect(isPrivateOrLoopbackHost("192.0.2.1")).toBe(true);
expect(isPrivateOrLoopbackHost("198.51.100.1")).toBe(true);
expect(isPrivateOrLoopbackHost("203.0.113.1")).toBe(true);
});
it("matches deprecated 6to4 relay anycast", () => {
expect(isPrivateOrLoopbackHost("192.88.99.1")).toBe(true);
});
it("matches 100.64.0.0/10 (CGNAT)", () => {
expect(isPrivateOrLoopbackHost("100.64.0.1")).toBe(true);
expect(isPrivateOrLoopbackHost("100.127.255.255")).toBe(true);
});
it("matches 198.18.0.0/15 (benchmarking)", () => {
expect(isPrivateOrLoopbackHost("198.18.0.1")).toBe(true);
expect(isPrivateOrLoopbackHost("198.19.255.255")).toBe(true);
});
it("matches multicast, reserved, and broadcast IPv4 ranges", () => {
expect(isPrivateOrLoopbackHost("224.0.0.1")).toBe(true);
expect(isPrivateOrLoopbackHost("240.0.0.1")).toBe(true);
expect(isPrivateOrLoopbackHost("255.255.255.255")).toBe(true);
});
it("does NOT match public IPs", () => {
expect(isPrivateOrLoopbackHost("8.8.8.8")).toBe(false);
expect(isPrivateOrLoopbackHost("1.1.1.1")).toBe(false);
expect(isPrivateOrLoopbackHost("93.184.216.34")).toBe(false);
expect(isPrivateOrLoopbackHost("192.31.196.1")).toBe(false);
expect(isPrivateOrLoopbackHost("192.52.193.1")).toBe(false);
expect(isPrivateOrLoopbackHost("192.175.48.1")).toBe(false);
});
});
@@ -77,10 +162,58 @@ describe("isPrivateOrLoopbackHost", () => {
it("matches link-local fe80::/10", () => {
expect(isPrivateOrLoopbackHost("fe80::1")).toBe(true);
expect(isPrivateOrLoopbackHost("fe81::1")).toBe(true);
expect(isPrivateOrLoopbackHost("febf::1")).toBe(true);
});
it("matches unspecified and multicast IPv6 ranges", () => {
expect(isPrivateOrLoopbackHost("::")).toBe(true);
expect(isPrivateOrLoopbackHost("ff00::1")).toBe(true);
expect(isPrivateOrLoopbackHost("ff02::1")).toBe(true);
});
it("matches NAT64 discard-only, benchmarking, and 6to4 IPv6 ranges", () => {
expect(isPrivateOrLoopbackHost("64:ff9b::1")).toBe(true);
expect(isPrivateOrLoopbackHost("64:ff9b:1::1")).toBe(true);
expect(isPrivateOrLoopbackHost("100::1")).toBe(true);
expect(isPrivateOrLoopbackHost("100:0:0:1::1")).toBe(true);
expect(isPrivateOrLoopbackHost("2001::1")).toBe(true);
expect(isPrivateOrLoopbackHost("2001:2::1")).toBe(true);
expect(isPrivateOrLoopbackHost("2001:10::1")).toBe(true);
expect(isPrivateOrLoopbackHost("2002::1")).toBe(true);
expect(isPrivateOrLoopbackHost("3fff::1")).toBe(true);
expect(isPrivateOrLoopbackHost("5f00::1")).toBe(true);
});
it("matches documentation IPv6 2001:db8::/32", () => {
expect(isPrivateOrLoopbackHost("2001:db8::1")).toBe(true);
});
it("does NOT match IPv6 addresses outside fe80::/10", () => {
expect(isPrivateOrLoopbackHost("fec0::1")).toBe(false);
});
it("does NOT match global IPv6", () => {
expect(isPrivateOrLoopbackHost("2001:db8::1")).toBe(false);
expect(isPrivateOrLoopbackHost("2606:4700:4700::1111")).toBe(false);
});
it("matches IPv4-mapped IPv6 private and loopback addresses", () => {
expect(isPrivateOrLoopbackHost("::ffff:10.0.0.1")).toBe(true);
expect(isPrivateOrLoopbackHost("::ffff:127.0.0.1")).toBe(true);
expect(isPrivateOrLoopbackHost("::ffff:169.254.169.254")).toBe(true);
expect(isPrivateOrLoopbackHost("[::ffff:192.168.1.1]")).toBe(true);
expect(isPrivateOrLoopbackHost("::ffff:7f00:1")).toBe(true);
expect(isPrivateOrLoopbackHost("::ffff:0a00:1")).toBe(true);
expect(isPrivateOrLoopbackHost("[::ffff:7f00:1]")).toBe(true);
});
it("matches IPv4-mapped IPv6 public addresses because the mapped range is special-purpose", () => {
expect(isPrivateOrLoopbackHost("::ffff:8.8.8.8")).toBe(true);
expect(isPrivateOrLoopbackHost("::ffff:0808:0808")).toBe(true);
});
it("matches the broad 2001::/23 IETF protocol assignments range", () => {
expect(isPrivateOrLoopbackHost("2001:100::1")).toBe(true);
});
});
+135 -15
View File
@@ -8,6 +8,131 @@ function stripIpv6Brackets(hostname: string): string {
return hostname.replace(/^\[/, "").replace(/\]$/, "");
}
function normalizeIpv4MappedIpv6(hostname: string) {
const normalized = stripIpv6Brackets(normalizeHostname(hostname));
const mapped = normalized.match(/^::ffff:(?<address>.+)$/)?.groups?.address;
if (!mapped) return normalized;
if (isIP(mapped) === 4) return mapped;
const hexMatch = mapped.match(/^(?<high>[0-9a-f]{1,4}):(?<low>[0-9a-f]{1,4})$/);
if (!hexMatch?.groups) return normalized;
const { high: highHex, low: lowHex } = hexMatch.groups;
if (!highHex || !lowHex) return normalized;
const high = Number.parseInt(highHex, 16);
const low = Number.parseInt(lowHex, 16);
if (Number.isNaN(high) || Number.isNaN(low) || high > 0xffff || low > 0xffff) return normalized;
return [high >> 8, high & 0xff, low >> 8, low & 0xff].join(".");
}
function isIpv4MappedIpv6(hostname: string) {
const normalized = stripIpv6Brackets(normalizeHostname(hostname));
return normalized.startsWith("::ffff:");
}
const blockedIpv4Cidrs: Array<[number, number]> = [
[knownIpv4ToNumber("0.0.0.0"), 8],
[knownIpv4ToNumber("10.0.0.0"), 8],
[knownIpv4ToNumber("100.64.0.0"), 10],
[knownIpv4ToNumber("127.0.0.0"), 8],
[knownIpv4ToNumber("169.254.0.0"), 16],
[knownIpv4ToNumber("172.16.0.0"), 12],
[knownIpv4ToNumber("192.0.0.0"), 24],
[knownIpv4ToNumber("192.0.2.0"), 24],
[knownIpv4ToNumber("192.88.99.0"), 24],
[knownIpv4ToNumber("192.168.0.0"), 16],
[knownIpv4ToNumber("198.18.0.0"), 15],
[knownIpv4ToNumber("198.51.100.0"), 24],
[knownIpv4ToNumber("203.0.113.0"), 24],
[knownIpv4ToNumber("224.0.0.0"), 4],
[knownIpv4ToNumber("240.0.0.0"), 4],
];
const blockedIpv6Cidrs: Array<[bigint, number]> = [
[knownIpv6ToBigInt("::"), 128],
[knownIpv6ToBigInt("::1"), 128],
[knownIpv6ToBigInt("::ffff:0:0"), 96],
[knownIpv6ToBigInt("64:ff9b::"), 96],
[knownIpv6ToBigInt("64:ff9b:1::"), 48],
[knownIpv6ToBigInt("100::"), 64],
[knownIpv6ToBigInt("100:0:0:1::"), 64],
[knownIpv6ToBigInt("2001::"), 23],
[knownIpv6ToBigInt("2001:2::"), 48],
[knownIpv6ToBigInt("2001:10::"), 28],
[knownIpv6ToBigInt("2001:db8::"), 32],
[knownIpv6ToBigInt("2002::"), 16],
[knownIpv6ToBigInt("3fff::"), 20],
[knownIpv6ToBigInt("5f00::"), 16],
[knownIpv6ToBigInt("fc00::"), 7],
[knownIpv6ToBigInt("fe80::"), 10],
[knownIpv6ToBigInt("ff00::"), 8],
];
function ipv4ToNumber(hostname: string) {
const octets = hostname.split(".").map((part) => Number.parseInt(part, 10));
if (octets.length !== 4 || octets.some((octet) => Number.isNaN(octet) || octet < 0 || octet > 255)) return null;
return (((octets[0] ?? 0) << 24) | ((octets[1] ?? 0) << 16) | ((octets[2] ?? 0) << 8) | (octets[3] ?? 0)) >>> 0;
}
function knownIpv4ToNumber(hostname: string) {
const value = ipv4ToNumber(hostname);
if (value === null) throw new Error(`Invalid IPv4 CIDR base: ${hostname}`);
return value;
}
function isIpv4InCidr(address: number, base: number, prefix: number) {
const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
return (address & mask) === (base & mask);
}
function expandIpv6(hostname: string) {
const normalized = stripIpv6Brackets(normalizeHostname(hostname));
const [head = "", tail = ""] = normalized.split("::", 2);
const headParts = head ? head.split(":") : [];
const tailParts = tail ? tail.split(":") : [];
const missing = 8 - headParts.length - tailParts.length;
if (missing < 0) return null;
const parts = [...headParts, ...Array.from({ length: missing }, () => "0"), ...tailParts];
if (parts.length !== 8) return null;
const hextets = parts.map((part) => {
if (!/^[0-9a-f]{1,4}$/.test(part)) return null;
return Number.parseInt(part, 16);
});
return hextets.every((part) => part !== null && part >= 0 && part <= 0xffff) ? (hextets as number[]) : null;
}
function ipv6ToBigInt(hostname: string) {
const hextets = expandIpv6(hostname);
if (!hextets) return null;
return hextets.reduce((value, hextet) => (value << 16n) | BigInt(hextet), 0n);
}
function knownIpv6ToBigInt(hostname: string) {
const value = ipv6ToBigInt(hostname);
if (value === null) throw new Error(`Invalid IPv6 CIDR base: ${hostname}`);
return value;
}
function isIpv6InCidr(address: bigint, base: bigint, prefix: number) {
const bits = 128n;
const hostBits = bits - BigInt(prefix);
const mask = prefix === 0 ? 0n : ((1n << bits) - 1n) ^ ((1n << hostBits) - 1n);
return (address & mask) === (base & mask);
}
function isLoopbackOrLocalHostname(hostname: string) {
const normalized = normalizeHostname(hostname);
return (
@@ -16,28 +141,23 @@ function isLoopbackOrLocalHostname(hostname: string) {
}
function isPrivateIPv4(hostname: string) {
const [first = 0, second = 0] = hostname.split(".").map((part) => Number.parseInt(part, 10));
if (Number.isNaN(first) || Number.isNaN(second)) return false;
const address = ipv4ToNumber(hostname);
if (address === null) return false;
if (first === 10) return true;
if (first === 127) return true;
if (first === 169 && second === 254) return true;
if (first === 172 && second >= 16 && second <= 31) return true;
if (first === 192 && second === 168) return true;
if (first === 0) return true;
return false;
return blockedIpv4Cidrs.some(([base, prefix]) => isIpv4InCidr(address, base ?? 0, prefix));
}
function isPrivateIPv6(hostname: string) {
const normalized = stripIpv6Brackets(normalizeHostname(hostname));
return (
normalized === "::1" || normalized.startsWith("fc") || normalized.startsWith("fd") || normalized.startsWith("fe80:")
);
const address = ipv6ToBigInt(hostname);
if (address === null) return false;
return blockedIpv6Cidrs.some(([base, prefix]) => isIpv6InCidr(address, base, prefix));
}
export function isPrivateOrLoopbackHost(hostname: string) {
const normalized = stripIpv6Brackets(normalizeHostname(hostname));
if (isIpv4MappedIpv6(hostname)) return true;
const normalized = normalizeIpv4MappedIpv6(hostname);
if (isLoopbackOrLocalHostname(normalized)) return true;
const ipVersion = isIP(normalized);