mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-24 00:43:29 +10:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user