auggy 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +96 -0
- package/LICENSE +201 -0
- package/README.md +161 -0
- package/package.json +76 -0
- package/src/agent-card.ts +39 -0
- package/src/agent.ts +283 -0
- package/src/agentmail-client.ts +138 -0
- package/src/augments/bash/index.ts +463 -0
- package/src/augments/bash/skill/SKILL.md +156 -0
- package/src/augments/budgets/budget-store.ts +513 -0
- package/src/augments/budgets/index.ts +134 -0
- package/src/augments/budgets/preamble.ts +93 -0
- package/src/augments/budgets/types.ts +89 -0
- package/src/augments/file-memory/index.ts +71 -0
- package/src/augments/filesystem/index.ts +533 -0
- package/src/augments/filesystem/skill/SKILL.md +142 -0
- package/src/augments/filesystem/skill/references/mount-permissions.md +81 -0
- package/src/augments/layered-memory/extractor/buffer.ts +56 -0
- package/src/augments/layered-memory/extractor/frequency.ts +79 -0
- package/src/augments/layered-memory/extractor/inject-handler.ts +103 -0
- package/src/augments/layered-memory/extractor/parse.ts +75 -0
- package/src/augments/layered-memory/extractor/prompt.md +26 -0
- package/src/augments/layered-memory/index.ts +757 -0
- package/src/augments/layered-memory/skill/SKILL.md +153 -0
- package/src/augments/layered-memory/storage/migrations/README.md +16 -0
- package/src/augments/layered-memory/storage/migrations/supabase-add-fact-fields.sql +9 -0
- package/src/augments/layered-memory/storage/sqlite-store.ts +352 -0
- package/src/augments/layered-memory/storage/supabase-store.ts +263 -0
- package/src/augments/layered-memory/storage/types.ts +98 -0
- package/src/augments/link/index.ts +489 -0
- package/src/augments/link/translate.ts +261 -0
- package/src/augments/notify/adapters/agentmail.ts +70 -0
- package/src/augments/notify/adapters/telegram.ts +60 -0
- package/src/augments/notify/adapters/webhook.ts +55 -0
- package/src/augments/notify/index.ts +284 -0
- package/src/augments/notify/skill/SKILL.md +150 -0
- package/src/augments/org-context/index.ts +721 -0
- package/src/augments/org-context/skill/SKILL.md +96 -0
- package/src/augments/skills/index.ts +103 -0
- package/src/augments/supabase-memory/index.ts +151 -0
- package/src/augments/telegram-transport/index.ts +312 -0
- package/src/augments/telegram-transport/polling.ts +55 -0
- package/src/augments/telegram-transport/webhook.ts +56 -0
- package/src/augments/turn-control/index.ts +61 -0
- package/src/augments/turn-control/skill/SKILL.md +155 -0
- package/src/augments/visitor-auth/email-validation.ts +66 -0
- package/src/augments/visitor-auth/index.ts +779 -0
- package/src/augments/visitor-auth/rate-limiter.ts +90 -0
- package/src/augments/visitor-auth/skill/SKILL.md +55 -0
- package/src/augments/visitor-auth/storage/sqlite-store.ts +398 -0
- package/src/augments/visitor-auth/storage/types.ts +164 -0
- package/src/augments/visitor-auth/types.ts +123 -0
- package/src/augments/visitor-auth/verify-page.ts +179 -0
- package/src/augments/web-fetch/index.ts +331 -0
- package/src/augments/web-fetch/skill/SKILL.md +100 -0
- package/src/cli/agent-index.ts +289 -0
- package/src/cli/augment-catalog.ts +320 -0
- package/src/cli/augment-resolver.ts +597 -0
- package/src/cli/commands/add-skill.ts +194 -0
- package/src/cli/commands/add.ts +87 -0
- package/src/cli/commands/chat.ts +207 -0
- package/src/cli/commands/create.ts +462 -0
- package/src/cli/commands/dev.ts +139 -0
- package/src/cli/commands/eval.ts +180 -0
- package/src/cli/commands/ls.ts +66 -0
- package/src/cli/commands/remove.ts +95 -0
- package/src/cli/commands/restart.ts +40 -0
- package/src/cli/commands/start.ts +123 -0
- package/src/cli/commands/status.ts +104 -0
- package/src/cli/commands/stop.ts +84 -0
- package/src/cli/commands/visitors-revoke.ts +155 -0
- package/src/cli/commands/visitors.ts +101 -0
- package/src/cli/config-parser.ts +1034 -0
- package/src/cli/engine-resolver.ts +68 -0
- package/src/cli/index.ts +178 -0
- package/src/cli/model-picker.ts +89 -0
- package/src/cli/pid-registry.ts +146 -0
- package/src/cli/plist-generator.ts +117 -0
- package/src/cli/resolve-config.ts +56 -0
- package/src/cli/scaffold-skills.ts +158 -0
- package/src/cli/scaffold.ts +291 -0
- package/src/cli/skill-frontmatter.ts +51 -0
- package/src/cli/skill-validator.ts +151 -0
- package/src/cli/types.ts +228 -0
- package/src/cli/yaml-helpers.ts +66 -0
- package/src/engines/_shared/cost.ts +55 -0
- package/src/engines/_shared/schema-normalize.ts +75 -0
- package/src/engines/anthropic/pricing.ts +117 -0
- package/src/engines/anthropic.ts +483 -0
- package/src/engines/openai/pricing.ts +67 -0
- package/src/engines/openai.ts +446 -0
- package/src/engines/openrouter/pricing.ts +83 -0
- package/src/engines/openrouter.ts +185 -0
- package/src/helpers.ts +24 -0
- package/src/http.ts +387 -0
- package/src/index.ts +165 -0
- package/src/kernel/capability-table.ts +172 -0
- package/src/kernel/context-allocator.ts +161 -0
- package/src/kernel/history-manager.ts +198 -0
- package/src/kernel/lifecycle-manager.ts +106 -0
- package/src/kernel/output-validator.ts +35 -0
- package/src/kernel/preamble.ts +23 -0
- package/src/kernel/route-collector.ts +97 -0
- package/src/kernel/timeout.ts +21 -0
- package/src/kernel/tool-selector.ts +47 -0
- package/src/kernel/trace-emitter.ts +66 -0
- package/src/kernel/transport-queue.ts +147 -0
- package/src/kernel/turn-loop.ts +1148 -0
- package/src/memory/context-synthesis.ts +83 -0
- package/src/memory/memory-bus.ts +61 -0
- package/src/memory/registry.ts +80 -0
- package/src/memory/tools.ts +320 -0
- package/src/memory/types.ts +8 -0
- package/src/parts.ts +30 -0
- package/src/scaffold-templates/identity.md +31 -0
- package/src/telegram-client.ts +145 -0
- package/src/tokenizer.ts +14 -0
- package/src/transports/ag-ui-events.ts +253 -0
- package/src/transports/visitor-token.ts +82 -0
- package/src/transports/web-transport.ts +948 -0
- package/src/types.ts +1009 -0
|
@@ -0,0 +1,721 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Org-context augment — read-only manifest registry.
|
|
3
|
+
*
|
|
4
|
+
* Connects an Auggy agent to an organization's knowledge API. Two stages of
|
|
5
|
+
* progressive disclosure:
|
|
6
|
+
* 1. Manifest (always in context, ~200 tokens) — org identity + endpoint list
|
|
7
|
+
* 2. Endpoint content (on demand via org_fetch) — full docs, fetched when relevant
|
|
8
|
+
*
|
|
9
|
+
* Outbound messaging (org_escalate, rate limits) moved to the notify augment
|
|
10
|
+
* in roadmap item 6 (2026-04-28). For escalation, mount the notify augment
|
|
11
|
+
* alongside this one.
|
|
12
|
+
*
|
|
13
|
+
* Boot is graceful: if the org API is unreachable, the agent starts without
|
|
14
|
+
* org context and logs a warning. org_fetch will fail with clear errors until
|
|
15
|
+
* the API is reachable.
|
|
16
|
+
*
|
|
17
|
+
* URL schemes (per α-6 / spec §Decision 8):
|
|
18
|
+
* - http:// or https:// — fetch via shared HTTP client (existing behavior)
|
|
19
|
+
* - file:// — read from local filesystem with realpath-based
|
|
20
|
+
* traversal safety. Relative `file://./...` URLs
|
|
21
|
+
* are resolved by the augment-resolver against the
|
|
22
|
+
* agent dir BEFORE construction; the augment itself
|
|
23
|
+
* only handles absolute file:// URLs (per ADR-024:
|
|
24
|
+
* no new kernel surface, no agent-dir construction
|
|
25
|
+
* parameter).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { readFile, realpath, stat } from "node:fs/promises";
|
|
29
|
+
import { fileURLToPath } from "node:url";
|
|
30
|
+
import { resolve, normalize, relative, isAbsolute, sep } from "node:path";
|
|
31
|
+
import { z } from "zod";
|
|
32
|
+
import type { Augment, ContextBlock } from "../../types";
|
|
33
|
+
import { defineTool } from "../../helpers";
|
|
34
|
+
import { createHttpClient } from "../../http";
|
|
35
|
+
import type { HttpClient } from "../../http";
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Types
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
export interface OrgContextOptions {
|
|
42
|
+
/**
|
|
43
|
+
* Base URL of the org's knowledge source. Three schemes accepted:
|
|
44
|
+
*
|
|
45
|
+
* - `http://...` / `https://...` — manifest + endpoint content fetched
|
|
46
|
+
* over HTTP via the shared http client
|
|
47
|
+
* - `file:///<absolute-path>` — manifest + endpoint content read from
|
|
48
|
+
* the local filesystem. Path-traversal safety is enforced via realpath
|
|
49
|
+
* (any resolved path that escapes the configured base dir is rejected).
|
|
50
|
+
*
|
|
51
|
+
* Relative `file://./...` URLs MUST be resolved against the agent dir by
|
|
52
|
+
* the caller (the augment-resolver does this); the augment itself only
|
|
53
|
+
* accepts absolute file:// URLs to keep the construction surface flat.
|
|
54
|
+
*/
|
|
55
|
+
baseUrl: string;
|
|
56
|
+
/** Optional auth token for the org API (HTTP scheme only). */
|
|
57
|
+
token?: string;
|
|
58
|
+
/** Manifest cache TTL in milliseconds. Default 1 hour. */
|
|
59
|
+
cacheTtlMs?: number;
|
|
60
|
+
/** Optional pre-built HTTP client (for sharing across augments or testing). */
|
|
61
|
+
client?: HttpClient;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface ManifestEndpoint {
|
|
65
|
+
path: string;
|
|
66
|
+
description: string;
|
|
67
|
+
method?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface OrgManifest {
|
|
71
|
+
org: string;
|
|
72
|
+
purpose: string;
|
|
73
|
+
operator?: string;
|
|
74
|
+
phase?: string;
|
|
75
|
+
endpoints: ManifestEndpoint[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Validate that parsed JSON has the OrgManifest shape. Returns the manifest
|
|
80
|
+
* cast to OrgManifest if valid, null if not. Hand-rolled (not zod) to avoid
|
|
81
|
+
* a runtime-validation dependency for a single shape; the schema is small.
|
|
82
|
+
*
|
|
83
|
+
* Rationale: `JSON.parse(body) as OrgManifest` is a TypeScript cast that
|
|
84
|
+
* lies at runtime — a body of `{}` or `{"endpoints": null}` parses
|
|
85
|
+
* successfully but breaks downstream (the allowlist check throws on
|
|
86
|
+
* `undefined.endpoints`; `onBoot` crashes on `manifest.endpoints.length`).
|
|
87
|
+
* Validating at the cache boundary is the natural fail-closed point: if
|
|
88
|
+
* the manifest doesn't match the contract, treat it as "no manifest
|
|
89
|
+
* loaded" (warn + return prior cache, keeping the augment's graceful-boot
|
|
90
|
+
* contract intact).
|
|
91
|
+
*/
|
|
92
|
+
function validateManifest(raw: unknown): OrgManifest | null {
|
|
93
|
+
if (raw === null || typeof raw !== "object") return null;
|
|
94
|
+
const m = raw as Record<string, unknown>;
|
|
95
|
+
if (typeof m.org !== "string") return null;
|
|
96
|
+
if (typeof m.purpose !== "string") return null;
|
|
97
|
+
if (!Array.isArray(m.endpoints)) return null;
|
|
98
|
+
for (const ep of m.endpoints) {
|
|
99
|
+
if (ep === null || typeof ep !== "object") return null;
|
|
100
|
+
const e = ep as Record<string, unknown>;
|
|
101
|
+
if (typeof e.path !== "string") return null;
|
|
102
|
+
if (typeof e.description !== "string") return null;
|
|
103
|
+
if (e.method !== undefined && typeof e.method !== "string") return null;
|
|
104
|
+
}
|
|
105
|
+
if (m.operator !== undefined && typeof m.operator !== "string") return null;
|
|
106
|
+
if (m.phase !== undefined && typeof m.phase !== "string") return null;
|
|
107
|
+
return m as unknown as OrgManifest;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// URL scheme handling
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
const FILE_SCHEME_RE = /^file:/i;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Parse a `file://...` URL into an absolute filesystem base directory.
|
|
118
|
+
*
|
|
119
|
+
* Accepts only absolute forms:
|
|
120
|
+
* - `file:///abs/path` (POSIX, three slashes)
|
|
121
|
+
* - `file:/abs/path` (uncommon but valid)
|
|
122
|
+
* Rejects relative `file://./...` shapes — the augment-resolver is expected
|
|
123
|
+
* to resolve those against the agent dir before construction. Keeping the
|
|
124
|
+
* augment surface absolute-only avoids threading agent-dir context into the
|
|
125
|
+
* augment factory (per ADR-024 — no new kernel surface).
|
|
126
|
+
*/
|
|
127
|
+
function parseFileBaseUrl(baseUrl: string): string {
|
|
128
|
+
// Strip any trailing slash for consistent join semantics.
|
|
129
|
+
const trimmed = baseUrl.replace(/\/+$/, "");
|
|
130
|
+
let absPath: string;
|
|
131
|
+
try {
|
|
132
|
+
absPath = fileURLToPath(trimmed);
|
|
133
|
+
} catch (err) {
|
|
134
|
+
throw new Error(
|
|
135
|
+
`org-context: invalid file:// URL "${baseUrl}" — must be absolute (file:///abs/path). ` +
|
|
136
|
+
`Relative file:// URLs must be resolved by the augment-resolver against the agent dir before construction. ` +
|
|
137
|
+
`(${(err as Error).message})`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
if (!isAbsolute(absPath)) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
`org-context: file:// URL "${baseUrl}" did not resolve to an absolute path (got "${absPath}").`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
return absPath;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// Path-traversal safety
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Decode a percent-encoded path segment EXACTLY ONCE.
|
|
154
|
+
*
|
|
155
|
+
* Critical: do not loop or recurse. Double-decoding turns `%252e%252e` into
|
|
156
|
+
* `..` (after two passes), defeating the normalize-then-resolve check below.
|
|
157
|
+
* One-shot decode followed by normalize+resolve+realpath catches both
|
|
158
|
+
* single-encoded and double-encoded traversal attempts: single-encoded
|
|
159
|
+
* collapses to `..` and gets normalized away by `path.normalize`; double-
|
|
160
|
+
* encoded stays as a literal `%2e%2e` that does not match `..` and either
|
|
161
|
+
* (a) doesn't exist on disk (clean ENOENT) or (b) exists as a literal-named
|
|
162
|
+
* file under the base dir (no escape).
|
|
163
|
+
*/
|
|
164
|
+
function decodeOnce(input: string): string {
|
|
165
|
+
try {
|
|
166
|
+
return decodeURIComponent(input);
|
|
167
|
+
} catch {
|
|
168
|
+
// Malformed percent-encoding — return the raw input. The downstream
|
|
169
|
+
// null-byte / absolute-path / realpath checks will still apply.
|
|
170
|
+
return input;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Boundary check via `path.relative()` — same shape used by the filesystem
|
|
176
|
+
* augment's `isWithinMount`. Rejects targets that escape the base dir
|
|
177
|
+
* (the relative path starts with `..`) and targets on a different filesystem
|
|
178
|
+
* root (Windows cross-drive — `relative()` returns an absolute path).
|
|
179
|
+
*
|
|
180
|
+
* Chose `relative()` over `startsWith(base + sep)` because the separator-
|
|
181
|
+
* suffix form breaks when the base is itself a filesystem root (`/` on
|
|
182
|
+
* POSIX → `base + sep` becomes `//`, which never matches a real child).
|
|
183
|
+
* The relative-based check also avoids the `realBase + "-attacker"` false
|
|
184
|
+
* positive of a naive prefix check.
|
|
185
|
+
*
|
|
186
|
+
* Exported only for testability; not part of the augment's public API.
|
|
187
|
+
*/
|
|
188
|
+
export function isWithinBase(realTarget: string, realBase: string): boolean {
|
|
189
|
+
const rel = relative(realBase, realTarget);
|
|
190
|
+
if (rel === "") return true;
|
|
191
|
+
if (rel === ".." || rel.startsWith(`..${sep}`)) return false;
|
|
192
|
+
if (isAbsolute(rel)) return false;
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Resolve a model-supplied endpoint path safely under an absolute base dir.
|
|
198
|
+
*
|
|
199
|
+
* Defense layers (each adds a different attack class):
|
|
200
|
+
* 1. Reject null bytes — file APIs treat `\0` as a string terminator and
|
|
201
|
+
* can be tricked into reading a different path than the validator saw.
|
|
202
|
+
* 2. Decode percent-encoding ONCE — single-encoded `%2e%2e` collapses to
|
|
203
|
+
* `..`; double-encoded `%252e%252e` stays as a literal that won't
|
|
204
|
+
* match `..` after one decode.
|
|
205
|
+
* 3. Fail-closed traversal rejection — explicitly reject any input whose
|
|
206
|
+
* decoded form contains `..` segments, a doubled leading slash, or a
|
|
207
|
+
* surviving `%2e`/`%2E` marker (a double-encoding attempt). Spec's
|
|
208
|
+
* §fail-closed contract: traversal-shaped inputs MUST be rejected at
|
|
209
|
+
* validation time, not silently re-rooted under base. This makes
|
|
210
|
+
* attempts visible in operator logs (rejection class is in the error
|
|
211
|
+
* message) instead of disappearing into ENOENTs.
|
|
212
|
+
* 4. Normalize the requested path — defensive only; layer 3 has already
|
|
213
|
+
* caught the canonical traversal shapes. Normalize remains for any
|
|
214
|
+
* benign `./` segments that survived layer 3.
|
|
215
|
+
* 5. Strip a leading slash before joining — endpoint paths are always
|
|
216
|
+
* treated as relative-under-base. A request that looks absolute is
|
|
217
|
+
* a strong attack signal in this context.
|
|
218
|
+
* 6. realpath both base AND candidate (when candidate exists) — symlink
|
|
219
|
+
* hops are followed; a symlink inside the base pointing to /etc is
|
|
220
|
+
* caught here.
|
|
221
|
+
* 7. Confirm the realpath'd candidate is still under the realpath'd base
|
|
222
|
+
* (via `relative()` boundary check, not naive `startsWith`).
|
|
223
|
+
*
|
|
224
|
+
* If the candidate doesn't exist on disk, we still validate the resolved
|
|
225
|
+
* path (without realpath) against the realpath'd base — the file read that
|
|
226
|
+
* follows will surface a clean ENOENT, but we never read outside the base.
|
|
227
|
+
*
|
|
228
|
+
* Throws on any traversal attempt. Caller wraps the throw into a tool result
|
|
229
|
+
* envelope so the model sees a non-fatal error.
|
|
230
|
+
*/
|
|
231
|
+
async function safeResolveUnderBase(realBaseDir: string, requestedPath: string): Promise<string> {
|
|
232
|
+
// Layer 1: null-byte rejection.
|
|
233
|
+
if (requestedPath.includes("\0")) {
|
|
234
|
+
throw new Error(
|
|
235
|
+
`org-context: rejected path containing null byte: ${JSON.stringify(requestedPath)}`,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Layer 2: decode-once (no recursion / no looping).
|
|
240
|
+
const decoded = decodeOnce(requestedPath);
|
|
241
|
+
if (decoded.includes("\0")) {
|
|
242
|
+
// Re-check after decode — `%00` decodes to `\0`.
|
|
243
|
+
throw new Error(
|
|
244
|
+
`org-context: rejected path containing null byte after decode: ${JSON.stringify(requestedPath)}`,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Layer 3: fail-closed traversal rejection. Each rejection class throws a
|
|
249
|
+
// distinct message so the operator can see in logs which defense fired.
|
|
250
|
+
// 3a: `..` segment — `^..` or `/../` or trailing `/..`.
|
|
251
|
+
if (/(?:^|\/)\.\.(?:\/|$)/.test(decoded)) {
|
|
252
|
+
throw new Error(
|
|
253
|
+
`org-context: rejected traversal — path contains '..' segment: ${JSON.stringify(requestedPath)}`,
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
// 3b: doubled leading slash — `//foo` is an attempt to disturb root semantics.
|
|
257
|
+
if (decoded.startsWith("//")) {
|
|
258
|
+
throw new Error(
|
|
259
|
+
`org-context: rejected traversal — path begins with doubled slash: ${JSON.stringify(requestedPath)}`,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
// 3c: surviving encoded traversal marker. After decode-once, any literal
|
|
263
|
+
// `%2e` / `%2E` left in the string is a double-encoded `.` — a clear
|
|
264
|
+
// double-encoding attempt that must not be silently accepted.
|
|
265
|
+
if (/%2[eE]/.test(decoded)) {
|
|
266
|
+
throw new Error(
|
|
267
|
+
`org-context: rejected traversal — path contains encoded traversal marker that survived decode: ${JSON.stringify(requestedPath)}`,
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Layer 4: normalize (defensive — layer 3 has caught the canonical traversals).
|
|
272
|
+
const normalized = normalize(decoded);
|
|
273
|
+
|
|
274
|
+
// Layer 5: strip leading slashes — endpoint paths are always relative-
|
|
275
|
+
// under-base in this augment. An absolute-looking input is an attack signal.
|
|
276
|
+
// We strip rather than reject because the manifest convention is that
|
|
277
|
+
// endpoint paths begin with `/` ("/mission", "/team"); stripping the lead
|
|
278
|
+
// converts that to a relative join target.
|
|
279
|
+
const stripped = normalized.replace(/^\/+/, "");
|
|
280
|
+
|
|
281
|
+
// After stripping, an isAbsolute() check catches Windows drive letters
|
|
282
|
+
// (e.g. "C:\\Windows\\..."), UNC paths, and any other absolute shape that
|
|
283
|
+
// shouldn't escape relative-join semantics.
|
|
284
|
+
if (isAbsolute(stripped)) {
|
|
285
|
+
throw new Error(
|
|
286
|
+
`org-context: rejected absolute-looking path: ${JSON.stringify(requestedPath)}`,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Layer 6+7: resolve under base, realpath, boundary check.
|
|
291
|
+
const candidate = resolve(realBaseDir, stripped);
|
|
292
|
+
|
|
293
|
+
let realCandidate: string;
|
|
294
|
+
try {
|
|
295
|
+
realCandidate = await realpath(candidate);
|
|
296
|
+
} catch {
|
|
297
|
+
// Path doesn't exist yet — fall back to the resolved (non-realpath'd)
|
|
298
|
+
// path. We still validate the boundary; the read that follows will
|
|
299
|
+
// surface a clean ENOENT. This means: a path like `/no-such-file` under
|
|
300
|
+
// a valid base is allowed to reach the read step (which fails cleanly),
|
|
301
|
+
// but a path like `/../etc/passwd` is rejected here BEFORE the read,
|
|
302
|
+
// because resolve() already collapsed it outside the base.
|
|
303
|
+
realCandidate = candidate;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (!isWithinBase(realCandidate, realBaseDir)) {
|
|
307
|
+
throw new Error(
|
|
308
|
+
`org-context: rejected traversal — ${JSON.stringify(requestedPath)} resolves outside base ${realBaseDir}`,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return realCandidate;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ---------------------------------------------------------------------------
|
|
316
|
+
// Augment factory
|
|
317
|
+
// ---------------------------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
const DEFAULT_CACHE_TTL = 60 * 60 * 1000; // 1 hour
|
|
320
|
+
|
|
321
|
+
export function orgContext(opts: OrgContextOptions): Augment {
|
|
322
|
+
const isFile = FILE_SCHEME_RE.test(opts.baseUrl);
|
|
323
|
+
|
|
324
|
+
// For HTTP/HTTPS: keep existing behavior (trim trailing slash, init client).
|
|
325
|
+
// For file://: parse to an absolute filesystem path; the http client is
|
|
326
|
+
// unused. realBaseDir is resolved (and cached) at first manifest fetch.
|
|
327
|
+
const httpBaseUrl = isFile ? "" : opts.baseUrl.replace(/\/+$/, "");
|
|
328
|
+
const fileBasePath = isFile ? parseFileBaseUrl(opts.baseUrl) : "";
|
|
329
|
+
|
|
330
|
+
const client =
|
|
331
|
+
isFile || opts.client
|
|
332
|
+
? (opts.client ??
|
|
333
|
+
// file:// path doesn't need a real client; placeholder to keep types
|
|
334
|
+
// narrow. Never actually called when isFile is true.
|
|
335
|
+
createHttpClient({
|
|
336
|
+
timeoutMs: 10_000,
|
|
337
|
+
userAgent: "auggy-org-context/0.2",
|
|
338
|
+
defaultHeaders: opts.token ? { authorization: `Bearer ${opts.token}` } : {},
|
|
339
|
+
}))
|
|
340
|
+
: createHttpClient({
|
|
341
|
+
timeoutMs: 10_000,
|
|
342
|
+
userAgent: "auggy-org-context/0.2",
|
|
343
|
+
defaultHeaders: opts.token ? { authorization: `Bearer ${opts.token}` } : {},
|
|
344
|
+
});
|
|
345
|
+
const cacheTtl = opts.cacheTtlMs ?? DEFAULT_CACHE_TTL;
|
|
346
|
+
|
|
347
|
+
let cachedManifest: OrgManifest | null = null;
|
|
348
|
+
let cacheExpiresAt = 0;
|
|
349
|
+
// Cached realpath of the file:// base dir. Populated on first manifest
|
|
350
|
+
// fetch (or first org_fetch if the manifest hadn't been read). Stays
|
|
351
|
+
// null until then; subsequent calls reuse the cached value.
|
|
352
|
+
let cachedRealBase: string | null = null;
|
|
353
|
+
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
// file:// helpers
|
|
356
|
+
// ---------------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
async function resolveRealBase(): Promise<string> {
|
|
359
|
+
if (cachedRealBase) return cachedRealBase;
|
|
360
|
+
// Validate the base dir exists and is a directory — fail fast with a
|
|
361
|
+
// clean error if the operator pointed baseUrl at something invalid.
|
|
362
|
+
const baseStat = await stat(fileBasePath).catch((err: unknown) => {
|
|
363
|
+
throw new Error(
|
|
364
|
+
`org-context: file:// base "${fileBasePath}" not accessible: ${(err as Error).message}`,
|
|
365
|
+
);
|
|
366
|
+
});
|
|
367
|
+
if (!baseStat.isDirectory()) {
|
|
368
|
+
throw new Error(`org-context: file:// base "${fileBasePath}" is not a directory`);
|
|
369
|
+
}
|
|
370
|
+
cachedRealBase = await realpath(fileBasePath);
|
|
371
|
+
return cachedRealBase;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ---------------------------------------------------------------------------
|
|
375
|
+
// Manifest fetching (HTTP or file)
|
|
376
|
+
// ---------------------------------------------------------------------------
|
|
377
|
+
|
|
378
|
+
async function fetchManifest(force = false): Promise<OrgManifest | null> {
|
|
379
|
+
if (!force && cachedManifest && Date.now() < cacheExpiresAt) {
|
|
380
|
+
return cachedManifest;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (isFile) {
|
|
384
|
+
try {
|
|
385
|
+
const realBase = await resolveRealBase();
|
|
386
|
+
const manifestPath = await safeResolveUnderBase(realBase, "manifest");
|
|
387
|
+
const body = await readFile(manifestPath, "utf-8");
|
|
388
|
+
const parsed: unknown = JSON.parse(body);
|
|
389
|
+
const validated = validateManifest(parsed);
|
|
390
|
+
if (validated === null) {
|
|
391
|
+
console.warn(
|
|
392
|
+
`[org-context] manifest at ${fileBasePath}/manifest has invalid shape — running without org context. Will retry on next fetch.`,
|
|
393
|
+
);
|
|
394
|
+
return cachedManifest;
|
|
395
|
+
}
|
|
396
|
+
cachedManifest = validated;
|
|
397
|
+
cacheExpiresAt = Date.now() + cacheTtl;
|
|
398
|
+
return cachedManifest;
|
|
399
|
+
} catch (err) {
|
|
400
|
+
console.warn(
|
|
401
|
+
`[org-context] failed to read file:// manifest from ${fileBasePath}: ${(err as Error).message}`,
|
|
402
|
+
);
|
|
403
|
+
return cachedManifest;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
try {
|
|
408
|
+
const res = await client.get(`${httpBaseUrl}/manifest`);
|
|
409
|
+
if (res.status !== 200) {
|
|
410
|
+
console.warn(`[org-context] manifest returned ${res.status}: ${res.body.slice(0, 200)}`);
|
|
411
|
+
return cachedManifest;
|
|
412
|
+
}
|
|
413
|
+
const parsed: unknown = JSON.parse(res.body);
|
|
414
|
+
const validated = validateManifest(parsed);
|
|
415
|
+
if (validated === null) {
|
|
416
|
+
console.warn(
|
|
417
|
+
`[org-context] manifest at ${httpBaseUrl}/manifest has invalid shape — running without org context. Will retry on next fetch.`,
|
|
418
|
+
);
|
|
419
|
+
return cachedManifest;
|
|
420
|
+
}
|
|
421
|
+
cachedManifest = validated;
|
|
422
|
+
cacheExpiresAt = Date.now() + cacheTtl;
|
|
423
|
+
return cachedManifest;
|
|
424
|
+
} catch (err) {
|
|
425
|
+
console.warn(`[org-context] failed to fetch manifest: ${(err as Error).message}`);
|
|
426
|
+
return cachedManifest;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// ---------------------------------------------------------------------------
|
|
431
|
+
// Context block
|
|
432
|
+
// ---------------------------------------------------------------------------
|
|
433
|
+
|
|
434
|
+
function buildContextBlock(manifest: OrgManifest): string {
|
|
435
|
+
const lines = [`# ${manifest.org} — Organization Context`, "", manifest.purpose, ""];
|
|
436
|
+
|
|
437
|
+
if (manifest.operator) {
|
|
438
|
+
lines.push(`**Operator:** ${manifest.operator}`);
|
|
439
|
+
}
|
|
440
|
+
if (manifest.phase) {
|
|
441
|
+
lines.push(`**Current phase:** ${manifest.phase}`);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
lines.push("");
|
|
445
|
+
lines.push("## Available org knowledge");
|
|
446
|
+
lines.push("");
|
|
447
|
+
lines.push("Use `org_fetch` to retrieve any of these when relevant to the conversation:");
|
|
448
|
+
lines.push("");
|
|
449
|
+
|
|
450
|
+
for (const ep of manifest.endpoints) {
|
|
451
|
+
if (ep.method === "POST") {
|
|
452
|
+
lines.push(`- **${ep.path}** (action) — ${ep.description}`);
|
|
453
|
+
} else {
|
|
454
|
+
lines.push(`- **${ep.path}** — ${ep.description}`);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return lines.join("\n");
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// ---------------------------------------------------------------------------
|
|
462
|
+
// Manifest allowlist (Codex High-1)
|
|
463
|
+
//
|
|
464
|
+
// The manifest is the authoritative endpoint contract per spec §Decision 9.
|
|
465
|
+
// Without an allowlist, any in-base file (file://) or HTTP route could be
|
|
466
|
+
// reached regardless of whether it was advertised. Force-load the manifest
|
|
467
|
+
// before every fetch (cached call — no extra IO/HTTP after first load) and
|
|
468
|
+
// require strict equality between the requested path and one of
|
|
469
|
+
// `manifest.endpoints[].path`. Strict equality (no prefix matching) is
|
|
470
|
+
// intentional: `/mission` and `/mission/extra` are distinct endpoints and
|
|
471
|
+
// must both be advertised explicitly to be reachable.
|
|
472
|
+
// ---------------------------------------------------------------------------
|
|
473
|
+
|
|
474
|
+
async function checkManifestAllowlist(requestedPath: string): Promise<string | null> {
|
|
475
|
+
// Use cached manifest if fresh; otherwise force a reload (caller-side
|
|
476
|
+
// side-effect: also populates cachedManifest for subsequent context()).
|
|
477
|
+
const manifest = await fetchManifest();
|
|
478
|
+
if (!manifest) {
|
|
479
|
+
return JSON.stringify({
|
|
480
|
+
error:
|
|
481
|
+
"Org context refused: no manifest loaded — cannot validate endpoint allowlist. " +
|
|
482
|
+
"The manifest is the authoritative contract for advertised endpoints.",
|
|
483
|
+
hint: "Check that the org base is reachable and the manifest is present and well-formed.",
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
const allowed = manifest.endpoints.some((ep) => ep.path === requestedPath);
|
|
487
|
+
if (!allowed) {
|
|
488
|
+
return JSON.stringify({
|
|
489
|
+
error: `Org context refused: endpoint ${JSON.stringify(requestedPath)} is not in the manifest's advertised endpoints`,
|
|
490
|
+
hint: "The model may only fetch paths advertised by the manifest. Inspect the org context block for the listed paths.",
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// ---------------------------------------------------------------------------
|
|
497
|
+
// org_fetch tool — file:// branch
|
|
498
|
+
// ---------------------------------------------------------------------------
|
|
499
|
+
|
|
500
|
+
async function fetchFromFile(endpoint: string, prompt?: string): Promise<string> {
|
|
501
|
+
const path = endpoint.startsWith("/") ? endpoint : `/${endpoint}`;
|
|
502
|
+
|
|
503
|
+
// High-1: allowlist runs first (simple, single-pass per fetch). Traversal
|
|
504
|
+
// rejection lives in safeResolveUnderBase as defense-in-depth.
|
|
505
|
+
const allowlistError = await checkManifestAllowlist(path);
|
|
506
|
+
if (allowlistError) return allowlistError;
|
|
507
|
+
|
|
508
|
+
let realBase: string;
|
|
509
|
+
try {
|
|
510
|
+
realBase = await resolveRealBase();
|
|
511
|
+
} catch (err) {
|
|
512
|
+
return JSON.stringify({
|
|
513
|
+
error: `Failed to resolve org-context base: ${(err as Error).message}`,
|
|
514
|
+
hint: "Check the file:// baseUrl in agent.yaml and that the directory exists.",
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
let resolved: string;
|
|
519
|
+
try {
|
|
520
|
+
resolved = await safeResolveUnderBase(realBase, path);
|
|
521
|
+
} catch (err) {
|
|
522
|
+
// Traversal-rejection or null-byte path. Surface as a clean error
|
|
523
|
+
// envelope (NOT a thrown exception) so the model sees a recoverable
|
|
524
|
+
// tool failure rather than a crash.
|
|
525
|
+
return JSON.stringify({
|
|
526
|
+
error: (err as Error).message,
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
let body: string;
|
|
531
|
+
try {
|
|
532
|
+
// Try the literal path first; if it doesn't exist, try `<path>.md`
|
|
533
|
+
// (matches the scaffolded example dir convention where `/mission` is
|
|
534
|
+
// backed by `mission.md`). This is a convenience for file://-mode
|
|
535
|
+
// operators; HTTP-mode behavior is unchanged.
|
|
536
|
+
body = await readFile(resolved, "utf-8").catch(async (err: unknown) => {
|
|
537
|
+
const e = err as NodeJS.ErrnoException;
|
|
538
|
+
if (e.code === "ENOENT" || e.code === "EISDIR") {
|
|
539
|
+
// Try .md fallback under the same boundary check.
|
|
540
|
+
const mdResolved = await safeResolveUnderBase(realBase, `${path}.md`);
|
|
541
|
+
return await readFile(mdResolved, "utf-8");
|
|
542
|
+
}
|
|
543
|
+
throw err;
|
|
544
|
+
});
|
|
545
|
+
} catch (err) {
|
|
546
|
+
const e = err as NodeJS.ErrnoException;
|
|
547
|
+
if (e.code === "ENOENT") {
|
|
548
|
+
return JSON.stringify({
|
|
549
|
+
error: `Org content for ${path} not found under ${fileBasePath}`,
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
if (e.code === "EISDIR") {
|
|
553
|
+
return JSON.stringify({
|
|
554
|
+
error: `Org content for ${path} is a directory, not a file`,
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
return JSON.stringify({
|
|
558
|
+
error: `Failed to read ${path}: ${(err as Error).message}`,
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const maxChars = 20_000;
|
|
563
|
+
const truncated =
|
|
564
|
+
body.length > maxChars
|
|
565
|
+
? `${body.slice(0, maxChars)}\n\n[truncated — ${body.length} total chars]`
|
|
566
|
+
: body;
|
|
567
|
+
|
|
568
|
+
return JSON.stringify({
|
|
569
|
+
endpoint: path,
|
|
570
|
+
content: truncated,
|
|
571
|
+
...(prompt ? { prompt } : {}),
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// ---------------------------------------------------------------------------
|
|
576
|
+
// org_fetch tool — HTTP branch (unchanged)
|
|
577
|
+
// ---------------------------------------------------------------------------
|
|
578
|
+
|
|
579
|
+
async function fetchFromHttp(endpoint: string, prompt?: string): Promise<string> {
|
|
580
|
+
const path = endpoint.startsWith("/") ? endpoint : `/${endpoint}`;
|
|
581
|
+
|
|
582
|
+
// High-1: allowlist runs first. Same shape as the file:// branch — manifest
|
|
583
|
+
// is the authoritative endpoint contract regardless of transport.
|
|
584
|
+
const allowlistError = await checkManifestAllowlist(path);
|
|
585
|
+
if (allowlistError) return allowlistError;
|
|
586
|
+
|
|
587
|
+
try {
|
|
588
|
+
const res = await client.get(`${httpBaseUrl}${path}`);
|
|
589
|
+
if (res.status !== 200) {
|
|
590
|
+
return JSON.stringify({
|
|
591
|
+
error: `Org API returned ${res.status} for ${path}`,
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
try {
|
|
596
|
+
const data = JSON.parse(res.body) as { files?: Array<{ name: string; content: string }> };
|
|
597
|
+
if (data.files && Array.isArray(data.files)) {
|
|
598
|
+
const content = data.files.map((f) => `## ${f.name}\n\n${f.content}`).join("\n\n---\n\n");
|
|
599
|
+
|
|
600
|
+
const maxChars = 20_000;
|
|
601
|
+
const truncated =
|
|
602
|
+
content.length > maxChars
|
|
603
|
+
? `${content.slice(0, maxChars)}\n\n[truncated — ${content.length} total chars]`
|
|
604
|
+
: content;
|
|
605
|
+
|
|
606
|
+
return JSON.stringify({
|
|
607
|
+
endpoint: path,
|
|
608
|
+
fileCount: data.files.length,
|
|
609
|
+
content: truncated,
|
|
610
|
+
...(prompt ? { prompt } : {}),
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
} catch {
|
|
614
|
+
// Not JSON or not the expected format — return raw body.
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
return JSON.stringify({
|
|
618
|
+
endpoint: path,
|
|
619
|
+
content: res.body.slice(0, 20_000),
|
|
620
|
+
});
|
|
621
|
+
} catch (err) {
|
|
622
|
+
return JSON.stringify({
|
|
623
|
+
error: `Failed to fetch ${path}: ${(err as Error).message}`,
|
|
624
|
+
hint: "The org API may be temporarily unreachable.",
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// ---------------------------------------------------------------------------
|
|
630
|
+
// org_fetch tool
|
|
631
|
+
// ---------------------------------------------------------------------------
|
|
632
|
+
|
|
633
|
+
const orgFetchTool = defineTool({
|
|
634
|
+
name: "org_fetch",
|
|
635
|
+
description:
|
|
636
|
+
"Fetch knowledge from the organization's API. Use the endpoint paths from the org context manifest.",
|
|
637
|
+
category: "search",
|
|
638
|
+
input: z.object({
|
|
639
|
+
endpoint: z
|
|
640
|
+
.string()
|
|
641
|
+
.describe("The endpoint path (e.g. '/vision', '/initiatives', '/solutions/architecture')"),
|
|
642
|
+
prompt: z.string().optional().describe("Optional: what you want to know from the content"),
|
|
643
|
+
}),
|
|
644
|
+
execute: async ({ endpoint, prompt }) => {
|
|
645
|
+
if (isFile) {
|
|
646
|
+
return fetchFromFile(endpoint, prompt);
|
|
647
|
+
}
|
|
648
|
+
return fetchFromHttp(endpoint, prompt);
|
|
649
|
+
},
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
// ---------------------------------------------------------------------------
|
|
653
|
+
// Augment
|
|
654
|
+
// ---------------------------------------------------------------------------
|
|
655
|
+
|
|
656
|
+
return {
|
|
657
|
+
name: "org-context",
|
|
658
|
+
capabilities: ["context", "tools"],
|
|
659
|
+
tools: [orgFetchTool],
|
|
660
|
+
|
|
661
|
+
context: async () => {
|
|
662
|
+
const manifest = await fetchManifest();
|
|
663
|
+
if (!manifest) return [];
|
|
664
|
+
|
|
665
|
+
const block: ContextBlock = {
|
|
666
|
+
source: "org-context",
|
|
667
|
+
content: buildContextBlock(manifest),
|
|
668
|
+
placement: "system",
|
|
669
|
+
priority: "required",
|
|
670
|
+
eviction: "never",
|
|
671
|
+
origin: "operator",
|
|
672
|
+
provenance: "augment",
|
|
673
|
+
ttl: "persistent",
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
return [block];
|
|
677
|
+
},
|
|
678
|
+
|
|
679
|
+
onBoot: async () => {
|
|
680
|
+
// file:// scheme: single attempt — no retry, the disk doesn't need
|
|
681
|
+
// network warmup. HTTP scheme: existing 0/2/5 second retry.
|
|
682
|
+
if (isFile) {
|
|
683
|
+
const manifest = await fetchManifest(true);
|
|
684
|
+
if (manifest) {
|
|
685
|
+
console.log(
|
|
686
|
+
`[org-context] loaded file:// manifest for ${manifest.org} (${manifest.endpoints.length} endpoints)`,
|
|
687
|
+
);
|
|
688
|
+
} else {
|
|
689
|
+
console.warn(
|
|
690
|
+
`[org-context] file:// manifest at ${fileBasePath}/manifest unreadable — running without org context. Will retry on first org_fetch call.`,
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const delays = [0, 2000, 5000];
|
|
697
|
+
let manifest: OrgManifest | null = null;
|
|
698
|
+
|
|
699
|
+
for (let i = 0; i < delays.length; i++) {
|
|
700
|
+
if (delays[i]! > 0) await new Promise((r) => setTimeout(r, delays[i]!));
|
|
701
|
+
manifest = await fetchManifest(true);
|
|
702
|
+
if (manifest) break;
|
|
703
|
+
if (i < delays.length - 1) {
|
|
704
|
+
console.warn(
|
|
705
|
+
`[org-context] manifest fetch failed, retrying in ${delays[i + 1]! / 1000}s...`,
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
if (manifest) {
|
|
711
|
+
console.log(
|
|
712
|
+
`[org-context] loaded manifest for ${manifest.org} (${manifest.endpoints.length} endpoints)`,
|
|
713
|
+
);
|
|
714
|
+
} else {
|
|
715
|
+
console.warn(
|
|
716
|
+
"[org-context] org API unreachable — running without org context. Will retry on first org_fetch call.",
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
},
|
|
720
|
+
};
|
|
721
|
+
}
|