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.
Files changed (121) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/LICENSE +201 -0
  3. package/README.md +161 -0
  4. package/package.json +76 -0
  5. package/src/agent-card.ts +39 -0
  6. package/src/agent.ts +283 -0
  7. package/src/agentmail-client.ts +138 -0
  8. package/src/augments/bash/index.ts +463 -0
  9. package/src/augments/bash/skill/SKILL.md +156 -0
  10. package/src/augments/budgets/budget-store.ts +513 -0
  11. package/src/augments/budgets/index.ts +134 -0
  12. package/src/augments/budgets/preamble.ts +93 -0
  13. package/src/augments/budgets/types.ts +89 -0
  14. package/src/augments/file-memory/index.ts +71 -0
  15. package/src/augments/filesystem/index.ts +533 -0
  16. package/src/augments/filesystem/skill/SKILL.md +142 -0
  17. package/src/augments/filesystem/skill/references/mount-permissions.md +81 -0
  18. package/src/augments/layered-memory/extractor/buffer.ts +56 -0
  19. package/src/augments/layered-memory/extractor/frequency.ts +79 -0
  20. package/src/augments/layered-memory/extractor/inject-handler.ts +103 -0
  21. package/src/augments/layered-memory/extractor/parse.ts +75 -0
  22. package/src/augments/layered-memory/extractor/prompt.md +26 -0
  23. package/src/augments/layered-memory/index.ts +757 -0
  24. package/src/augments/layered-memory/skill/SKILL.md +153 -0
  25. package/src/augments/layered-memory/storage/migrations/README.md +16 -0
  26. package/src/augments/layered-memory/storage/migrations/supabase-add-fact-fields.sql +9 -0
  27. package/src/augments/layered-memory/storage/sqlite-store.ts +352 -0
  28. package/src/augments/layered-memory/storage/supabase-store.ts +263 -0
  29. package/src/augments/layered-memory/storage/types.ts +98 -0
  30. package/src/augments/link/index.ts +489 -0
  31. package/src/augments/link/translate.ts +261 -0
  32. package/src/augments/notify/adapters/agentmail.ts +70 -0
  33. package/src/augments/notify/adapters/telegram.ts +60 -0
  34. package/src/augments/notify/adapters/webhook.ts +55 -0
  35. package/src/augments/notify/index.ts +284 -0
  36. package/src/augments/notify/skill/SKILL.md +150 -0
  37. package/src/augments/org-context/index.ts +721 -0
  38. package/src/augments/org-context/skill/SKILL.md +96 -0
  39. package/src/augments/skills/index.ts +103 -0
  40. package/src/augments/supabase-memory/index.ts +151 -0
  41. package/src/augments/telegram-transport/index.ts +312 -0
  42. package/src/augments/telegram-transport/polling.ts +55 -0
  43. package/src/augments/telegram-transport/webhook.ts +56 -0
  44. package/src/augments/turn-control/index.ts +61 -0
  45. package/src/augments/turn-control/skill/SKILL.md +155 -0
  46. package/src/augments/visitor-auth/email-validation.ts +66 -0
  47. package/src/augments/visitor-auth/index.ts +779 -0
  48. package/src/augments/visitor-auth/rate-limiter.ts +90 -0
  49. package/src/augments/visitor-auth/skill/SKILL.md +55 -0
  50. package/src/augments/visitor-auth/storage/sqlite-store.ts +398 -0
  51. package/src/augments/visitor-auth/storage/types.ts +164 -0
  52. package/src/augments/visitor-auth/types.ts +123 -0
  53. package/src/augments/visitor-auth/verify-page.ts +179 -0
  54. package/src/augments/web-fetch/index.ts +331 -0
  55. package/src/augments/web-fetch/skill/SKILL.md +100 -0
  56. package/src/cli/agent-index.ts +289 -0
  57. package/src/cli/augment-catalog.ts +320 -0
  58. package/src/cli/augment-resolver.ts +597 -0
  59. package/src/cli/commands/add-skill.ts +194 -0
  60. package/src/cli/commands/add.ts +87 -0
  61. package/src/cli/commands/chat.ts +207 -0
  62. package/src/cli/commands/create.ts +462 -0
  63. package/src/cli/commands/dev.ts +139 -0
  64. package/src/cli/commands/eval.ts +180 -0
  65. package/src/cli/commands/ls.ts +66 -0
  66. package/src/cli/commands/remove.ts +95 -0
  67. package/src/cli/commands/restart.ts +40 -0
  68. package/src/cli/commands/start.ts +123 -0
  69. package/src/cli/commands/status.ts +104 -0
  70. package/src/cli/commands/stop.ts +84 -0
  71. package/src/cli/commands/visitors-revoke.ts +155 -0
  72. package/src/cli/commands/visitors.ts +101 -0
  73. package/src/cli/config-parser.ts +1034 -0
  74. package/src/cli/engine-resolver.ts +68 -0
  75. package/src/cli/index.ts +178 -0
  76. package/src/cli/model-picker.ts +89 -0
  77. package/src/cli/pid-registry.ts +146 -0
  78. package/src/cli/plist-generator.ts +117 -0
  79. package/src/cli/resolve-config.ts +56 -0
  80. package/src/cli/scaffold-skills.ts +158 -0
  81. package/src/cli/scaffold.ts +291 -0
  82. package/src/cli/skill-frontmatter.ts +51 -0
  83. package/src/cli/skill-validator.ts +151 -0
  84. package/src/cli/types.ts +228 -0
  85. package/src/cli/yaml-helpers.ts +66 -0
  86. package/src/engines/_shared/cost.ts +55 -0
  87. package/src/engines/_shared/schema-normalize.ts +75 -0
  88. package/src/engines/anthropic/pricing.ts +117 -0
  89. package/src/engines/anthropic.ts +483 -0
  90. package/src/engines/openai/pricing.ts +67 -0
  91. package/src/engines/openai.ts +446 -0
  92. package/src/engines/openrouter/pricing.ts +83 -0
  93. package/src/engines/openrouter.ts +185 -0
  94. package/src/helpers.ts +24 -0
  95. package/src/http.ts +387 -0
  96. package/src/index.ts +165 -0
  97. package/src/kernel/capability-table.ts +172 -0
  98. package/src/kernel/context-allocator.ts +161 -0
  99. package/src/kernel/history-manager.ts +198 -0
  100. package/src/kernel/lifecycle-manager.ts +106 -0
  101. package/src/kernel/output-validator.ts +35 -0
  102. package/src/kernel/preamble.ts +23 -0
  103. package/src/kernel/route-collector.ts +97 -0
  104. package/src/kernel/timeout.ts +21 -0
  105. package/src/kernel/tool-selector.ts +47 -0
  106. package/src/kernel/trace-emitter.ts +66 -0
  107. package/src/kernel/transport-queue.ts +147 -0
  108. package/src/kernel/turn-loop.ts +1148 -0
  109. package/src/memory/context-synthesis.ts +83 -0
  110. package/src/memory/memory-bus.ts +61 -0
  111. package/src/memory/registry.ts +80 -0
  112. package/src/memory/tools.ts +320 -0
  113. package/src/memory/types.ts +8 -0
  114. package/src/parts.ts +30 -0
  115. package/src/scaffold-templates/identity.md +31 -0
  116. package/src/telegram-client.ts +145 -0
  117. package/src/tokenizer.ts +14 -0
  118. package/src/transports/ag-ui-events.ts +253 -0
  119. package/src/transports/visitor-token.ts +82 -0
  120. package/src/transports/web-transport.ts +948 -0
  121. 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
+ }