Add the openagents trace namespace: local discovery, show, redact, and an honest upload refusal

0a622a2f3d5c · AtlantisPleb · · parent 06d16d9b3272

Add the openagents trace namespace: local discovery, show, redact, and an honest upload refusal

The LOCAL half of issue #14. `trace list` discovers session exports across
the OpenAgents export store and the Claude and Codex session directories --
read-only, bounded by depth, entry budget, and listing cap, and symlink-safe
in both directions. `trace show` summarizes one ATIF document (steps,
sources, models, tool calls, token totals) without printing payloads.
`trace redact` writes a conservative .redacted.json sibling from a rule list
that is data, reporting by count and never echoing what it matched.

`trace upload` refuses with a typed TraceUploadUnsupported error because the
forge has no ingest route yet: the server half it names is POST /api/v3/traces
accepting an ATIF v1.7 document with owner_only default visibility.

The family registers through a factory so the cli.ts hunk stays one import,
one construction, and one list entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GoYpb8FEmdxVErsv7ABCYi
Co-Authored-By
Claude Fable 5 <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified packages/openagents-cli/src/cli.ts
  • modified packages/openagents-cli/src/errors.ts
  • modified packages/openagents-cli/src/main.ts
  • added packages/openagents-cli/src/trace-command.ts
  • added packages/openagents-cli/src/trace-store.ts
  • added packages/openagents-cli/test/trace-command.test.ts
  • added packages/openagents-cli/test/trace-store.test.ts

Diff

7 files changed, +1244 -1

packages/openagents-cli/src/cli.ts modified +4

@@ -115,6 +115,7 @@ import { parseRepositoryTarget, RepositoryClient } from "./repository-client.js"

115 115
import { RequestBodyInput } from "./request-body-input.js";
116 116
import { SecretInput } from "./secret-input.js";
117 117
import { findToken, resolveApiEndpoint, resolveApiSession } from "./session.js";
118
import { makeTraceCommand } from "./trace-command.js";
118 119
import { TerminalSession } from "./terminal-session.js";
119 120
120 121
// The version lives in `package.json`; see `version.ts`.

@@ -3536,6 +3537,8 @@ const projectCommand = Command.make("project").pipe(

3536 3537
  ]),
3537 3538
);
3538 3539
3540
const traceCommand = makeTraceCommand(rootCommand);
3541
3539 3542
export const openagentsCommand = rootCommand.pipe(
3540 3543
  Command.withSubcommands([
3541 3544
    apiCommand,

@@ -3547,6 +3550,7 @@ export const openagentsCommand = rootCommand.pipe(

3547 3550
    issueCommand,
3548 3551
    projectCommand,
3549 3552
    repoCommand,
3553
    traceCommand,
3550 3554
  ]),
3551 3555
);
3552 3556
packages/openagents-cli/src/errors.ts modified +9 -1

@@ -171,6 +171,11 @@ export class ComputerReconnectExhausted extends Schema.TaggedErrorClass<Computer

171 171
  { message: Schema.String },
172 172
) {}
173 173
174
export class TraceUploadUnsupported extends Schema.TaggedErrorClass<TraceUploadUnsupported>()(
175
  "OpenAgentsCli.TraceUploadUnsupported",
176
  { message: Schema.String },
177
) {}
178
174 179
export type CliError =
175 180
  | InputError
176 181
  | ConfigurationError

@@ -196,7 +201,8 @@ export type CliError =

196 201
  | ComputerStatusNetworkFailure
197 202
  | ComputerMachineUnavailable
198 203
  | ComputerMachineMismatch
199
  | ComputerReconnectExhausted;
204
  | ComputerReconnectExhausted
205
  | TraceUploadUnsupported;
200 206
201 207
export const exitCodeFor = (error: CliError): number => {
202 208
  switch (error._tag) {

@@ -222,6 +228,8 @@ export const exitCodeFor = (error: CliError): number => {

222 228
      return 14;
223 229
    case "OpenAgentsCli.ComputerReconnectExhausted":
224 230
      return 15;
231
    case "OpenAgentsCli.TraceUploadUnsupported":
232
      return 16;
225 233
    case "OpenAgentsCli.AuthenticationRequired":
226 234
    case "OpenAgentsCli.CredentialPersistenceUnavailable":
227 235
    case "OpenAgentsCli.CredentialStoreError":
packages/openagents-cli/src/main.ts modified +1

@@ -34,6 +34,7 @@ const cliErrorTags = new Set([

34 34
  "OpenAgentsCli.ComputerMachineUnavailable",
35 35
  "OpenAgentsCli.ComputerMachineMismatch",
36 36
  "OpenAgentsCli.ComputerReconnectExhausted",
37
  "OpenAgentsCli.TraceUploadUnsupported",
37 38
]);
38 39
39 40
const isCliError = (value: unknown): value is CliError =>
packages/openagents-cli/src/trace-command.ts added +273

@@ -0,0 +1,273 @@

1
/**
2
 * The `openagents trace` command family.
3
 *
4
 * This is the LOCAL half of the trace pipeline: list what session exports
5
 * exist on this machine, summarize one, and produce a redacted sibling copy.
6
 * The upload half needs a forge ingest route that does not exist yet, so
7
 * `trace upload` refuses with a typed error that names the missing route
8
 * instead of pretending.
9
 *
10
 * The family is defined through a factory taking the root command, so the
11
 * registration hunk in `cli.ts` stays a single import and a single list entry.
12
 */
13
14
import { existsSync } from "node:fs";
15
import { homedir } from "node:os";
16
import { isAbsolute, join, resolve } from "node:path";
17
18
import { Effect } from "effect";
19
import { Argument, Command, Flag } from "effect/unstable/cli";
20
21
import { InputError, TraceUploadUnsupported } from "./errors.js";
22
import { Output, type OutputMode } from "./output.js";
23
import {
24
  defaultDiscoveryBounds,
25
  defaultTraceStores,
26
  pathTraceStore,
27
  redactTraceFile,
28
  scanTraceStore,
29
  summarizeTraceFile,
30
  type TraceCandidate,
31
  type TraceStoreSpec,
32
  type TraceSummary,
33
} from "./trace-store.js";
34
35
/** The shared flags a trace handler reads back off the root command. */
36
interface SharedFlags {
37
  readonly json: boolean;
38
}
39
40
const outputMode = (json: boolean): OutputMode => (json ? "json" : "human");
41
42
/** The server half `trace upload` is waiting for. One place, one sentence. */
43
export const TRACE_INGEST_ROUTE_GAP =
44
  "openagents.com has no trace ingest route yet. Upload needs the server half first: " +
45
  "POST /api/v3/traces accepting an ATIF v1.7 document with owner_only default visibility. " +
46
  "Until that route exists, this command refuses rather than pretending to upload.";
47
48
const listPathFlag = Flag.string("path").pipe(
49
  Flag.atLeast(0),
50
  Flag.withDescription(
51
    "Scan only this directory for trace documents; repeatable. Omit to scan the default stores.",
52
  ),
53
);
54
const listLimitFlag = Flag.integer("limit").pipe(
55
  Flag.withDefault(20),
56
  Flag.withDescription("Most files listed per store, newest first"),
57
);
58
59
const extraPathStores = (): ReadonlyArray<TraceStoreSpec> =>
60
  (process.env["OPENAGENTS_TRACE_PATHS"] ?? "")
61
    .split(":")
62
    .map((entry) => entry.trim())
63
    .filter((entry) => entry.length > 0)
64
    .map((entry) => pathTraceStore(resolve(entry)));
65
66
const candidateLine = (candidate: TraceCandidate): string =>
67
  `${candidate.kind}  ${candidate.modified_at}  ${candidate.bytes}B  ${candidate.path}`;
68
69
const summaryHuman = (summary: TraceSummary): ReadonlyArray<string> => {
70
  if (summary.format !== "atif") {
71
    return [
72
      `File: ${summary.path}`,
73
      `Format: ${summary.format === "jsonl" ? "line-delimited session log (not ATIF)" : "unknown"}`,
74
      `Size: ${summary.bytes} bytes`,
75
      ...(summary.lines === undefined ? [] : [`Lines: ${summary.lines}`]),
76
      "This slice summarizes ATIF documents only; foreign logs get metadata.",
77
    ];
78
  }
79
  const bySource = summary.steps_by_source ?? {};
80
  const tokens =
81
    summary.total_prompt_tokens === undefined && summary.total_completion_tokens === undefined
82
      ? "not recorded"
83
      : `${summary.total_prompt_tokens ?? 0} prompt, ${summary.total_completion_tokens ?? 0} completion`;
84
  return [
85
    `File: ${summary.path}`,
86
    `Schema: ${summary.schema_version ?? "(missing schema_version)"}`,
87
    ...(summary.session_id === undefined ? [] : [`Session: ${summary.session_id}`]),
88
    ...(summary.agent === undefined
89
      ? []
90
      : [`Agent: ${summary.agent.name ?? "unknown"} (${summary.agent.model ?? "unknown model"})`]),
91
    `Steps: ${summary.steps ?? 0} (${Object.entries(bySource)
92
      .map(([source, count]) => `${source} ${count}`)
93
      .join(", ")})`,
94
    `Models: ${(summary.models ?? []).join(", ") || "(none recorded)"}`,
95
    `Tool calls: ${summary.tool_calls ?? 0}`,
96
    `Tokens: ${tokens}`,
97
    ...(summary.first_timestamp === undefined || summary.last_timestamp === undefined
98
      ? []
99
      : [`Span: ${summary.first_timestamp} to ${summary.last_timestamp}`]),
100
  ];
101
};
102
103
/**
104
 * Resolve a `<path|id>` argument. A bare name that is not a file on disk is
105
 * tried as a file in the local export store, which is where `/export` writes.
106
 */
107
const resolveTraceArgument = Effect.fn("Trace.resolveTraceArgument")(function* (value: string) {
108
  const direct = isAbsolute(value) ? value : resolve(value);
109
  if (existsSync(direct)) return direct;
110
  const inExports = join(homedir(), ".openagents", "exports", value);
111
  if (!value.includes("/") && existsSync(inExports)) return inExports;
112
  return yield* new InputError({
113
    message: `No trace file exists at ${value}, and ~/.openagents/exports has no file by that name. Run openagents trace list to see what is discoverable.`,
114
  });
115
});
116
117
export const makeTraceCommand = <R>(root: Effect.Effect<SharedFlags, never, R>) => {
118
  const traceListCommand = Command.make(
119
    "list",
120
    { path: listPathFlag, limit: listLimitFlag },
121
    ({ limit, path }) =>
122
      Effect.gen(function* () {
123
        if (limit <= 0) {
124
          return yield* new InputError({ message: "--limit must be greater than zero." });
125
        }
126
        const flags = yield* root;
127
        const output = yield* Output;
128
        const stores =
129
          path.length > 0
130
            ? path.map((entry) => pathTraceStore(resolve(entry)))
131
            : [...defaultTraceStores(homedir()), ...extraPathStores()];
132
        const bounds = { ...defaultDiscoveryBounds, maxFilesPerStore: limit };
133
        const results = yield* Effect.sync(() =>
134
          stores.map((store) => scanTraceStore(store, bounds)),
135
        );
136
        const scans = results.map((result) => result.scan);
137
        const traces = results
138
          .flatMap((result) => result.candidates)
139
          .sort((a, b) => b.modified_at.localeCompare(a.modified_at));
140
        yield* output.write(
141
          {
142
            value: { schema: "openagents.trace_list.v1", stores: scans, traces },
143
            human: [
144
              ...scans.map(
145
                (scan) =>
146
                  `${scan.kind}: ${scan.root} ${
147
                    scan.present
148
                      ? `(${scan.matched} matched, ${scan.listed} listed${
149
                          scan.skipped_symlinks > 0
150
                            ? `, ${scan.skipped_symlinks} symlinks skipped`
151
                            : ""
152
                        }${scan.truncated ? ", scan truncated at its entry budget" : ""})`
153
                      : "(not present)"
154
                  }`,
155
              ),
156
              ...(traces.length === 0 ? ["No trace files found."] : traces.map(candidateLine)),
157
            ],
158
          },
159
          outputMode(flags.json),
160
        );
161
      }),
162
  ).pipe(
163
    Command.withDescription(
164
      "Discover local coding-agent session exports: the OpenAgents export store plus known Claude and Codex session directories. Read-only, bounded, and symlink-safe; foreign stores are listed as metadata only.",
165
    ),
166
  );
167
168
  const traceArgument = Argument.string("trace").pipe(
169
    Argument.withDescription("A trace file path, or a file name inside ~/.openagents/exports"),
170
  );
171
172
  const traceShowCommand = Command.make("show", { trace: traceArgument }, ({ trace }) =>
173
    Effect.gen(function* () {
174
      const flags = yield* root;
175
      const output = yield* Output;
176
      const path = yield* resolveTraceArgument(trace);
177
      const summary = yield* Effect.try({
178
        try: () => summarizeTraceFile(path),
179
        catch: () => new InputError({ message: `The trace file at ${path} could not be read.` }),
180
      });
181
      yield* output.write(
182
        {
183
          value: { schema: "openagents.trace_summary.v1", ...summary },
184
          human: summaryHuman(summary),
185
        },
186
        outputMode(flags.json),
187
      );
188
    }),
189
  ).pipe(
190
    Command.withDescription(
191
      "Summarize one trace: steps, sources, models, tool calls, and token totals, without printing any payload.",
192
    ),
193
  );
194
195
  const traceRedactCommand = Command.make("redact", { trace: traceArgument }, ({ trace }) =>
196
    Effect.gen(function* () {
197
      const flags = yield* root;
198
      const output = yield* Output;
199
      const path = yield* resolveTraceArgument(trace);
200
      if (path.endsWith(".redacted.json") || path.endsWith(".redacted.jsonl")) {
201
        return yield* new InputError({
202
          message: `${path} is already a redacted copy; redact the original instead.`,
203
        });
204
      }
205
      const result = yield* Effect.try({
206
        try: () => redactTraceFile(path, homedir()),
207
        catch: () =>
208
          new InputError({ message: `The trace file at ${path} could not be redacted.` }),
209
      });
210
      yield* output.write(
211
        {
212
          value: { schema: "openagents.trace_redaction.v1", ...result },
213
          human: [
214
            `Wrote ${result.output}`,
215
            result.total === 0
216
              ? "Nothing matched the redaction rules."
217
              : `Redacted ${result.total} match${result.total === 1 ? "" : "es"}: ${Object.entries(
218
                  result.counts,
219
                )
220
                  .map(([category, count]) => `${category} ${count}`)
221
                  .join(", ")}`,
222
            ...(result.valid_json === false
223
              ? ["Warning: the redacted copy no longer parses as JSON; review it before sharing."]
224
              : []),
225
          ],
226
        },
227
        outputMode(flags.json),
228
      );
229
    }),
230
  ).pipe(
231
    Command.withDescription(
232
      "Write a conservatively redacted sibling copy (.redacted.json): bearer and API tokens, JWTs, secret-named fields, environment-variable values, and home paths are removed. Only counts are reported; the matched text is never echoed.",
233
    ),
234
  );
235
236
  const uploadPublicFlag = Flag.boolean("public").pipe(
237
    Flag.withDescription("Ask for public visibility instead of the owner_only default"),
238
  );
239
  const uploadUnlistedFlag = Flag.boolean("unlisted").pipe(
240
    Flag.withDescription("Ask for unlisted visibility instead of the owner_only default"),
241
  );
242
243
  const traceUploadCommand = Command.make(
244
    "upload",
245
    { trace: traceArgument, public: uploadPublicFlag, unlisted: uploadUnlistedFlag },
246
    ({ public: isPublic, trace, unlisted }) =>
247
      Effect.gen(function* () {
248
        if (isPublic && unlisted) {
249
          return yield* new InputError({ message: "Use either --public or --unlisted, not both." });
250
        }
251
        // Validate the local half first so the refusal is about the real gap,
252
        // not about a path typo the reader would rather hear about now.
253
        yield* resolveTraceArgument(trace);
254
        return yield* new TraceUploadUnsupported({ message: TRACE_INGEST_ROUTE_GAP });
255
      }),
256
  ).pipe(
257
    Command.withDescription(
258
      "Upload a redacted trace to openagents.com with owner_only visibility by default. The forge ingest route does not exist yet, so this refuses and names the missing server half.",
259
    ),
260
  );
261
262
  return Command.make("trace").pipe(
263
    Command.withDescription(
264
      "Discover, inspect, and redact local coding-agent traces (ATIF). Discovery is read-only; nothing here rewrites or deletes a source session record.",
265
    ),
266
    Command.withSubcommands([
267
      traceListCommand,
268
      traceShowCommand,
269
      traceRedactCommand,
270
      traceUploadCommand,
271
    ]),
272
  );
273
};
packages/openagents-cli/src/trace-store.ts added +455

@@ -0,0 +1,455 @@

1
/**
2
 * Local trace discovery, summarization, and redaction.
3
 *
4
 * A trace is the public-safe ATIF projection of a coding-agent session. This
5
 * module owns the LOCAL half of `openagents trace`: finding candidate session
6
 * exports on this machine, summarizing one without dumping its payloads, and
7
 * producing a conservatively redacted sibling copy.
8
 *
9
 * Discovery is read-only and bounded by construction. Every directory walk
10
 * uses `lstat`, never follows a symlink, visits a capped number of entries to
11
 * a capped depth, and lists a capped number of files. Foreign stores -- the
12
 * Claude and Codex session directories -- are metadata-only: path, size,
13
 * mtime, kind. Nothing here ever writes into a foreign store.
14
 */
15
16
import { lstatSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
17
import { join } from "node:path";
18
19
/** Where a candidate trace came from. */
20
export type TraceSourceKind =
21
  | "openagents_export"
22
  | "claude_session"
23
  | "codex_session"
24
  | "trace_path";
25
26
/** One discovered file, metadata only. */
27
export interface TraceCandidate {
28
  readonly path: string;
29
  readonly kind: TraceSourceKind;
30
  readonly bytes: number;
31
  readonly modified_at: string;
32
}
33
34
/** What a single store scan did, so bounds are visible in the output. */
35
export interface TraceStoreScan {
36
  readonly root: string;
37
  readonly kind: TraceSourceKind;
38
  readonly present: boolean;
39
  /** Files that matched the store's extensions, before the listing cap. */
40
  readonly matched: number;
41
  /** Files included in the listing after the cap. */
42
  readonly listed: number;
43
  /** Symlinks seen and refused. Discovery never follows one. */
44
  readonly skipped_symlinks: number;
45
  /** Whether the walk stopped at its entry budget rather than the store's end. */
46
  readonly truncated: boolean;
47
}
48
49
/** A directory to scan and how to read it. */
50
export interface TraceStoreSpec {
51
  readonly root: string;
52
  readonly kind: TraceSourceKind;
53
  readonly extensions: ReadonlyArray<string>;
54
}
55
56
export interface DiscoveryBounds {
57
  /** Directory depth below the root; 0 scans only the root's own entries. */
58
  readonly maxDepth: number;
59
  /** Most files listed per store, newest first. */
60
  readonly maxFilesPerStore: number;
61
  /** Hard budget of directory entries visited per store. */
62
  readonly maxScanEntries: number;
63
}
64
65
export const defaultDiscoveryBounds: DiscoveryBounds = {
66
  maxDepth: 4,
67
  maxFilesPerStore: 20,
68
  maxScanEntries: 5000,
69
};
70
71
/** The stores `trace list` scans when no explicit path is given. */
72
export const defaultTraceStores = (home: string): ReadonlyArray<TraceStoreSpec> => [
73
  {
74
    root: join(home, ".openagents", "exports"),
75
    kind: "openagents_export",
76
    extensions: [".json"],
77
  },
78
  { root: join(home, ".claude", "projects"), kind: "claude_session", extensions: [".jsonl"] },
79
  { root: join(home, ".codex", "sessions"), kind: "codex_session", extensions: [".jsonl"] },
80
];
81
82
/** A store spec for a user-supplied directory of ATIF documents. */
83
export const pathTraceStore = (root: string): TraceStoreSpec => ({
84
  root,
85
  kind: "trace_path",
86
  extensions: [".json", ".jsonl"],
87
});
88
89
const matchesExtension = (name: string, extensions: ReadonlyArray<string>): boolean =>
90
  extensions.some((extension) => name.endsWith(extension));
91
92
/**
93
 * Scan one store within the given bounds.
94
 *
95
 * Iterative breadth-first walk. Symlinks -- file or directory -- are counted
96
 * and skipped, so a link planted in a session store can neither escape it nor
97
 * loop it. The entry budget bounds the walk on stores of any size.
98
 */
99
export const scanTraceStore = (
100
  spec: TraceStoreSpec,
101
  bounds: DiscoveryBounds = defaultDiscoveryBounds,
102
): { readonly scan: TraceStoreScan; readonly candidates: ReadonlyArray<TraceCandidate> } => {
103
  const rootStat = (() => {
104
    try {
105
      return lstatSync(spec.root);
106
    } catch {
107
      return undefined;
108
    }
109
  })();
110
  if (rootStat === undefined || rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
111
    return {
112
      scan: {
113
        root: spec.root,
114
        kind: spec.kind,
115
        present: false,
116
        matched: 0,
117
        listed: 0,
118
        skipped_symlinks: rootStat?.isSymbolicLink() === true ? 1 : 0,
119
        truncated: false,
120
      },
121
      candidates: [],
122
    };
123
  }
124
125
  const found: Array<{ path: string; bytes: number; mtimeMs: number }> = [];
126
  let skippedSymlinks = 0;
127
  let visited = 0;
128
  let truncated = false;
129
  const queue: Array<{ directory: string; depth: number }> = [{ directory: spec.root, depth: 0 }];
130
131
  while (queue.length > 0) {
132
    const next = queue.shift();
133
    if (next === undefined) break;
134
    const names = (() => {
135
      try {
136
        return readdirSync(next.directory);
137
      } catch {
138
        return [] as ReadonlyArray<string>;
139
      }
140
    })();
141
    for (const name of names) {
142
      if (visited >= bounds.maxScanEntries) {
143
        truncated = true;
144
        break;
145
      }
146
      visited += 1;
147
      const path = join(next.directory, name);
148
      const stat = (() => {
149
        try {
150
          return lstatSync(path);
151
        } catch {
152
          return undefined;
153
        }
154
      })();
155
      if (stat === undefined) continue;
156
      if (stat.isSymbolicLink()) {
157
        skippedSymlinks += 1;
158
        continue;
159
      }
160
      if (stat.isDirectory()) {
161
        if (next.depth < bounds.maxDepth) queue.push({ directory: path, depth: next.depth + 1 });
162
        continue;
163
      }
164
      if (stat.isFile() && matchesExtension(name, spec.extensions)) {
165
        found.push({ path, bytes: stat.size, mtimeMs: stat.mtimeMs });
166
      }
167
    }
168
    if (truncated) break;
169
  }
170
171
  const newestFirst = [...found].sort((a, b) => b.mtimeMs - a.mtimeMs);
172
  const listed = newestFirst.slice(0, bounds.maxFilesPerStore);
173
  return {
174
    scan: {
175
      root: spec.root,
176
      kind: spec.kind,
177
      present: true,
178
      matched: found.length,
179
      listed: listed.length,
180
      skipped_symlinks: skippedSymlinks,
181
      truncated,
182
    },
183
    candidates: listed.map((file) => ({
184
      path: file.path,
185
      kind: spec.kind,
186
      bytes: file.bytes,
187
      modified_at: new Date(file.mtimeMs).toISOString(),
188
    })),
189
  };
190
};
191
192
/** What `trace show` reports about one document. Payloads stay in the file. */
193
export interface TraceSummary {
194
  readonly path: string;
195
  readonly format: "atif" | "jsonl" | "unknown";
196
  readonly bytes: number;
197
  readonly schema_version?: string;
198
  readonly session_id?: string;
199
  readonly agent?: { readonly name?: string; readonly model?: string };
200
  readonly steps?: number;
201
  readonly steps_by_source?: Readonly<Record<string, number>>;
202
  readonly models?: ReadonlyArray<string>;
203
  readonly tool_calls?: number;
204
  readonly total_prompt_tokens?: number;
205
  readonly total_completion_tokens?: number;
206
  readonly first_timestamp?: string;
207
  readonly last_timestamp?: string;
208
  readonly lines?: number;
209
}
210
211
const asRecord = (value: unknown): Record<string, unknown> | undefined =>
212
  typeof value === "object" && value !== null && !Array.isArray(value)
213
    ? (value as Record<string, unknown>)
214
    : undefined;
215
216
const asNumber = (value: unknown): number | undefined =>
217
  typeof value === "number" && Number.isFinite(value) ? value : undefined;
218
219
/**
220
 * Summarize one trace file: counts, models, and token totals, never payloads.
221
 *
222
 * An ATIF document gets the full summary. A line-delimited session log -- the
223
 * shape the foreign stores hold -- gets bytes and line count only, because
224
 * this slice does not parse foreign formats.
225
 */
226
export const summarizeTraceFile = (path: string): TraceSummary => {
227
  const text = readFileSync(path, "utf8");
228
  const bytes = Buffer.byteLength(text, "utf8");
229
230
  const document = (() => {
231
    try {
232
      return asRecord(JSON.parse(text));
233
    } catch {
234
      return undefined;
235
    }
236
  })();
237
238
  if (document !== undefined && Array.isArray(document["steps"])) {
239
    const steps = document["steps"].map(asRecord).filter((step) => step !== undefined);
240
    const bySource: Record<string, number> = {};
241
    const models = new Set<string>();
242
    let toolCalls = 0;
243
    let promptTokens = 0;
244
    let completionTokens = 0;
245
    let sawTokens = false;
246
    for (const step of steps) {
247
      const source = typeof step["source"] === "string" ? step["source"] : "unknown";
248
      bySource[source] = (bySource[source] ?? 0) + 1;
249
      if (typeof step["model_name"] === "string") models.add(step["model_name"]);
250
      if (Array.isArray(step["tool_calls"])) toolCalls += step["tool_calls"].length;
251
      const metrics = asRecord(step["metrics"]);
252
      if (metrics !== undefined) {
253
        const prompt = asNumber(metrics["prompt_tokens"]);
254
        const completion = asNumber(metrics["completion_tokens"]);
255
        if (prompt !== undefined || completion !== undefined) sawTokens = true;
256
        promptTokens += prompt ?? 0;
257
        completionTokens += completion ?? 0;
258
      }
259
    }
260
    const agent = asRecord(document["agent"]);
261
    const finalMetrics = asRecord(document["final_metrics"]);
262
    const totalPrompt = asNumber(finalMetrics?.["total_prompt_tokens"]);
263
    const totalCompletion = asNumber(finalMetrics?.["total_completion_tokens"]);
264
    const first = steps[0];
265
    const last = steps[steps.length - 1];
266
    return {
267
      path,
268
      format: "atif",
269
      bytes,
270
      ...(typeof document["schema_version"] === "string"
271
        ? { schema_version: document["schema_version"] }
272
        : {}),
273
      ...(typeof document["session_id"] === "string" ? { session_id: document["session_id"] } : {}),
274
      ...(agent === undefined
275
        ? {}
276
        : {
277
            agent: {
278
              ...(typeof agent["name"] === "string" ? { name: agent["name"] } : {}),
279
              ...(typeof agent["model_name"] === "string" ? { model: agent["model_name"] } : {}),
280
            },
281
          }),
282
      steps: steps.length,
283
      steps_by_source: bySource,
284
      models: [...models],
285
      tool_calls: toolCalls,
286
      ...(totalPrompt !== undefined
287
        ? { total_prompt_tokens: totalPrompt }
288
        : sawTokens
289
          ? { total_prompt_tokens: promptTokens }
290
          : {}),
291
      ...(totalCompletion !== undefined
292
        ? { total_completion_tokens: totalCompletion }
293
        : sawTokens
294
          ? { total_completion_tokens: completionTokens }
295
          : {}),
296
      ...(first !== undefined && typeof first["timestamp"] === "string"
297
        ? { first_timestamp: first["timestamp"] }
298
        : {}),
299
      ...(last !== undefined && typeof last["timestamp"] === "string"
300
        ? { last_timestamp: last["timestamp"] }
301
        : {}),
302
    };
303
  }
304
305
  if (path.endsWith(".jsonl")) {
306
    const lines = text.split("\n").filter((line) => line.trim().length > 0).length;
307
    return { path, format: "jsonl", bytes, lines };
308
  }
309
310
  return { path, format: "unknown", bytes };
311
};
312
313
/**
314
 * One redaction rule: a category name, a global pattern, and its replacement.
315
 *
316
 * The list is data so a test can plant a fake secret per category and assert
317
 * both that the secret is gone and that the count names the category. Order
318
 * matters: specific shapes run before broad ones so a bearer token is counted
319
 * as a bearer token, not as an environment value.
320
 */
321
export interface RedactionRule {
322
  readonly category: string;
323
  readonly pattern: RegExp;
324
  readonly replacement: string;
325
}
326
327
const escapeForRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
328
329
/** The conservative rule set. `home` scopes the path rules to this machine. */
330
export const redactionRules = (home: string): ReadonlyArray<RedactionRule> => [
331
  {
332
    category: "bearer_token",
333
    pattern: /\b[Bb]earer\s+[A-Za-z0-9._~+/=-]{8,}/g,
334
    replacement: "Bearer [REDACTED:bearer_token]",
335
  },
336
  {
337
    category: "api_key",
338
    pattern:
339
      /\b(?:sk-[A-Za-z0-9_-]{16,}|ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|gho_[A-Za-z0-9]{20,}|glpat-[A-Za-z0-9_-]{16,}|xox[baprs]-[A-Za-z0-9-]{10,}|AKIA[A-Z0-9]{16}|AIza[A-Za-z0-9_-]{30,})\b/g,
340
    replacement: "[REDACTED:api_key]",
341
  },
342
  {
343
    category: "jwt",
344
    pattern: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g,
345
    replacement: "[REDACTED:jwt]",
346
  },
347
  {
348
    category: "secret_field",
349
    pattern:
350
      /("[\w.-]*(?:token|secret|password|passwd|api[_-]?key|credential|private[_-]?key)[\w.-]*"\s*:\s*)"(?:[^"\\]|\\.)*"/gi,
351
    replacement: '$1"[REDACTED:secret_field]"',
352
  },
353
  {
354
    category: "env_value",
355
    pattern: /\b([A-Z][A-Z0-9_]{2,})=(["'])(?:(?!\2)[^\n]){4,}?\2/g,
356
    replacement: "$1=[REDACTED:env_value]",
357
  },
358
  {
359
    category: "env_value",
360
    pattern: /\b([A-Z][A-Z0-9_]{2,})=(?!\[REDACTED)[^\s"'`\\,}]{4,}/g,
361
    replacement: "$1=[REDACTED:env_value]",
362
  },
363
  {
364
    category: "home_path",
365
    pattern: new RegExp(escapeForRegExp(home), "g"),
366
    replacement: "~",
367
  },
368
  {
369
    category: "home_path",
370
    pattern: /(?:\/Users|\/home)\/[A-Za-z0-9._-]+/g,
371
    replacement: "~",
372
  },
373
];
374
375
export interface RedactionResult {
376
  readonly text: string;
377
  /** Matches per category. Counts only; the matched text is never returned. */
378
  readonly counts: Readonly<Record<string, number>>;
379
  readonly total: number;
380
}
381
382
/** Apply the rules to a text and count what each category removed. */
383
export const redactText = (text: string, rules: ReadonlyArray<RedactionRule>): RedactionResult => {
384
  const counts: Record<string, number> = {};
385
  let output = text;
386
  let total = 0;
387
  for (const rule of rules) {
388
    let matched = 0;
389
    output = output.replace(rule.pattern, (...args) => {
390
      matched += 1;
391
      // Rebuild the replacement's capture references by hand: the replacement
392
      // string is data, and String.replace only expands `$1` for literal
393
      // second arguments.
394
      return rule.replacement.replace(/\$(\d)/g, (_, index: string) => {
395
        const capture = args[Number(index)] as unknown;
396
        return typeof capture === "string" ? capture : "";
397
      });
398
    });
399
    if (matched > 0) {
400
      counts[rule.category] = (counts[rule.category] ?? 0) + matched;
401
      total += matched;
402
    }
403
  }
404
  return { text: output, counts, total };
405
};
406
407
export interface RedactedFile {
408
  readonly input: string;
409
  readonly output: string;
410
  readonly counts: Readonly<Record<string, number>>;
411
  readonly total: number;
412
  /** Whether the redacted output still parses, when the input parsed. */
413
  readonly valid_json: boolean | null;
414
}
415
416
/** The sibling path a redaction writes: `foo.json` becomes `foo.redacted.json`. */
417
export const redactedPathFor = (path: string): string =>
418
  path.endsWith(".jsonl")
419
    ? `${path.slice(0, -".jsonl".length)}.redacted.jsonl`
420
    : path.endsWith(".json")
421
      ? `${path.slice(0, -".json".length)}.redacted.json`
422
      : `${path}.redacted.json`;
423
424
/** Redact one file into its sibling and report by count only. */
425
export const redactTraceFile = (path: string, home: string): RedactedFile => {
426
  const text = readFileSync(path, "utf8");
427
  const parsedBefore = (() => {
428
    try {
429
      JSON.parse(text);
430
      return true;
431
    } catch {
432
      return false;
433
    }
434
  })();
435
  const result = redactText(text, redactionRules(home));
436
  const output = redactedPathFor(path);
437
  writeFileSync(output, result.text, "utf8");
438
  const validJson = parsedBefore
439
    ? (() => {
440
        try {
441
          JSON.parse(result.text);
442
          return true;
443
        } catch {
444
          return false;
445
        }
446
      })()
447
    : null;
448
  return {
449
    input: path,
450
    output,
451
    counts: result.counts,
452
    total: result.total,
453
    valid_json: validJson,
454
  };
455
};
packages/openagents-cli/test/trace-command.test.ts added +197

@@ -0,0 +1,197 @@

1
import { mkdtempSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
2
import { tmpdir } from "node:os";
3
import { join } from "node:path";
4
5
import * as NodeServices from "@effect/platform-node/NodeServices";
6
import { Effect, Layer } from "effect";
7
import { describe, expect, it } from "vitest";
8
9
import { runCliWith } from "../src/cli.js";
10
import { credentialStoreUnavailableLayer } from "../src/credential-store.js";
11
import { environmentLayerFromValues } from "../src/environment.js";
12
import { outputTestLayer, type OutputDocument, type OutputMode } from "../src/output.js";
13
import { persistedConfigurationTestLayer } from "../src/persisted-configuration.js";
14
import { terminalSessionTestLayer } from "../src/terminal-session.js";
15
16
interface Written {
17
  readonly document: OutputDocument;
18
  readonly mode: OutputMode;
19
}
20
21
const harness = () => {
22
  const written: Array<Written> = [];
23
  const layer = Layer.mergeAll(
24
    NodeServices.layer,
25
    environmentLayerFromValues({}),
26
    persistedConfigurationTestLayer({}),
27
    terminalSessionTestLayer(false),
28
    credentialStoreUnavailableLayer,
29
    outputTestLayer((document, mode) =>
30
      Effect.sync(() => {
31
        written.push({ document, mode });
32
      }),
33
    ),
34
  );
35
  const run = (argv: ReadonlyArray<string>) =>
36
    Effect.runPromise(
37
      runCliWith([...argv]).pipe(Effect.provide(layer)) as Effect.Effect<void, unknown>,
38
    );
39
  const fail = (argv: ReadonlyArray<string>) =>
40
    Effect.runPromise(
41
      runCliWith([...argv]).pipe(Effect.provide(layer), Effect.flip) as Effect.Effect<
42
        unknown,
43
        unknown
44
      >,
45
    );
46
  return { run, fail, written };
47
};
48
49
const atifDocument = (message: string) => ({
50
  schema_version: "ATIF-v1.7",
51
  session_id: "octavia/project-2026-08-24T14:00:00.000Z",
52
  agent: { name: "openagents-coder", version: "0.3.5", model_name: "Ollama qwen" },
53
  steps: [
54
    { step_id: 1, timestamp: "2026-08-24T14:00:00.000Z", source: "user", message },
55
    {
56
      step_id: 2,
57
      timestamp: "2026-08-24T14:00:05.000Z",
58
      source: "agent",
59
      message: "done",
60
      model_name: "Ollama qwen",
61
      metrics: { prompt_tokens: 5, completion_tokens: 2 },
62
    },
63
  ],
64
  final_metrics: { total_prompt_tokens: 5, total_completion_tokens: 2, total_steps: 2 },
65
});
66
67
const scratchStore = (message = "hello") => {
68
  const root = mkdtempSync(join(tmpdir(), "trace-command-"));
69
  const path = join(root, "session-atif.json");
70
  writeFileSync(path, JSON.stringify(atifDocument(message)), "utf8");
71
  return { root, path };
72
};
73
74
describe("openagents trace", () => {
75
  it("lists an explicit store as JSON with scan bounds visible", async () => {
76
    const { root, path } = scratchStore();
77
    const { run, written } = harness();
78
    await run(["--json", "trace", "list", "--path", root]);
79
80
    expect(written).toHaveLength(1);
81
    const value = written[0]?.document.value as {
82
      schema: string;
83
      stores: ReadonlyArray<Record<string, unknown>>;
84
      traces: ReadonlyArray<Record<string, unknown>>;
85
    };
86
    expect(value.schema).toBe("openagents.trace_list.v1");
87
    expect(value.stores).toHaveLength(1);
88
    expect(value.stores[0]).toMatchObject({
89
      kind: "trace_path",
90
      present: true,
91
      matched: 1,
92
      listed: 1,
93
      skipped_symlinks: 0,
94
      truncated: false,
95
    });
96
    expect(value.traces).toHaveLength(1);
97
    expect(value.traces[0]).toMatchObject({ path, kind: "trace_path" });
98
  });
99
100
  it("skips a planted symlink and says so in the scan", async () => {
101
    const outside = mkdtempSync(join(tmpdir(), "trace-outside-"));
102
    writeFileSync(join(outside, "foreign.json"), "{}", "utf8");
103
    const { root } = scratchStore();
104
    symlinkSync(join(outside, "foreign.json"), join(root, "linked.json"));
105
106
    const { run, written } = harness();
107
    await run(["--json", "trace", "list", "--path", root]);
108
    const value = written[0]?.document.value as {
109
      stores: ReadonlyArray<Record<string, unknown>>;
110
      traces: ReadonlyArray<{ path: string }>;
111
    };
112
    expect(value.stores[0]).toMatchObject({ skipped_symlinks: 1 });
113
    expect(value.traces.some((trace) => trace.path.includes("linked.json"))).toBe(false);
114
  });
115
116
  it("refuses a non-positive list limit", async () => {
117
    const { fail } = harness();
118
    const error = await fail(["trace", "list", "--limit", "0"]);
119
    expect(error).toMatchObject({ _tag: "OpenAgentsCli.InputError" });
120
  });
121
122
  it("shows a trace summary without printing payloads", async () => {
123
    const { path } = scratchStore("the payload text that must not leak");
124
    const { run, written } = harness();
125
    await run(["--json", "trace", "show", path]);
126
127
    const value = written[0]?.document.value as Record<string, unknown>;
128
    expect(value).toMatchObject({
129
      schema: "openagents.trace_summary.v1",
130
      format: "atif",
131
      schema_version: "ATIF-v1.7",
132
      steps: 2,
133
      steps_by_source: { user: 1, agent: 1 },
134
      models: ["Ollama qwen"],
135
      total_prompt_tokens: 5,
136
      total_completion_tokens: 2,
137
    });
138
    expect(JSON.stringify(value)).not.toContain("the payload text that must not leak");
139
  });
140
141
  it("refuses to show a trace that does not exist anywhere", async () => {
142
    const { fail } = harness();
143
    const error = await fail(["trace", "show", "no-such-trace-file-atif.json"]);
144
    expect(error).toMatchObject({ _tag: "OpenAgentsCli.InputError" });
145
  });
146
147
  it("redacts into a sibling file and reports counts, not secrets", async () => {
148
    const { root, path } = scratchStore("token OPENAGENTS_TOKEN=super-secret-value here");
149
    const { run, written } = harness();
150
    await run(["--json", "trace", "redact", path]);
151
152
    const value = written[0]?.document.value as {
153
      schema: string;
154
      output: string;
155
      counts: Record<string, number>;
156
      total: number;
157
      valid_json: boolean | null;
158
    };
159
    expect(value.schema).toBe("openagents.trace_redaction.v1");
160
    expect(value.output).toBe(join(root, "session-atif.redacted.json"));
161
    expect(value.counts["env_value"]).toBe(1);
162
    expect(value.valid_json).toBe(true);
163
    expect(JSON.stringify(value)).not.toContain("super-secret-value");
164
    expect(readFileSync(value.output, "utf8")).not.toContain("super-secret-value");
165
    expect(readFileSync(path, "utf8")).toContain("super-secret-value");
166
  });
167
168
  it("refuses to redact an already-redacted copy", async () => {
169
    const { root } = scratchStore();
170
    const redacted = join(root, "session-atif.redacted.json");
171
    writeFileSync(redacted, "{}", "utf8");
172
    const { fail } = harness();
173
    const error = await fail(["trace", "redact", redacted]);
174
    expect(error).toMatchObject({ _tag: "OpenAgentsCli.InputError" });
175
  });
176
177
  it("refuses upload with a typed error naming the missing server route", async () => {
178
    const { path } = scratchStore();
179
    const { fail } = harness();
180
    const error = await fail(["trace", "upload", path]);
181
    expect(error).toMatchObject({ _tag: "OpenAgentsCli.TraceUploadUnsupported" });
182
    expect(String((error as { message: string }).message)).toContain("POST /api/v3/traces");
183
  });
184
185
  it("still validates the local path before refusing an upload", async () => {
186
    const { fail } = harness();
187
    const error = await fail(["trace", "upload", "no-such-trace-file-atif.json"]);
188
    expect(error).toMatchObject({ _tag: "OpenAgentsCli.InputError" });
189
  });
190
191
  it("refuses --public together with --unlisted", async () => {
192
    const { path } = scratchStore();
193
    const { fail } = harness();
194
    const error = await fail(["trace", "upload", path, "--public", "--unlisted"]);
195
    expect(error).toMatchObject({ _tag: "OpenAgentsCli.InputError" });
196
  });
197
});
packages/openagents-cli/test/trace-store.test.ts added +305

@@ -0,0 +1,305 @@

1
import {
2
  mkdirSync,
3
  mkdtempSync,
4
  readFileSync,
5
  symlinkSync,
6
  utimesSync,
7
  writeFileSync,
8
} from "node:fs";
9
import { tmpdir } from "node:os";
10
import { join } from "node:path";
11
import { describe, expect, it } from "vitest";
12
13
import {
14
  defaultDiscoveryBounds,
15
  defaultTraceStores,
16
  pathTraceStore,
17
  redactedPathFor,
18
  redactionRules,
19
  redactText,
20
  redactTraceFile,
21
  scanTraceStore,
22
  summarizeTraceFile,
23
} from "../src/trace-store.js";
24
25
const scratch = () => mkdtempSync(join(tmpdir(), "trace-store-"));
26
27
const writeAt = (path: string, content: string, at: Date) => {
28
  writeFileSync(path, content, "utf8");
29
  utimesSync(path, at, at);
30
};
31
32
describe("trace discovery", () => {
33
  it("names the three default stores under the home directory", () => {
34
    const stores = defaultTraceStores("/home/octavia");
35
    expect(stores.map((store) => store.kind)).toEqual([
36
      "openagents_export",
37
      "claude_session",
38
      "codex_session",
39
    ]);
40
    expect(stores[0]?.root).toBe("/home/octavia/.openagents/exports");
41
    expect(stores[1]?.root).toBe("/home/octavia/.claude/projects");
42
    expect(stores[2]?.root).toBe("/home/octavia/.codex/sessions");
43
  });
44
45
  it("reports a missing store as absent instead of failing", () => {
46
    const { scan, candidates } = scanTraceStore(pathTraceStore(join(scratch(), "nope")));
47
    expect(scan.present).toBe(false);
48
    expect(candidates).toEqual([]);
49
  });
50
51
  it("lists matching files newest first with metadata only", () => {
52
    const root = scratch();
53
    writeAt(join(root, "old.json"), "{}", new Date("2026-08-01T00:00:00Z"));
54
    writeAt(join(root, "new.json"), "{}", new Date("2026-08-20T00:00:00Z"));
55
    writeAt(join(root, "ignored.txt"), "not a trace", new Date("2026-08-21T00:00:00Z"));
56
57
    const { scan, candidates } = scanTraceStore(pathTraceStore(root));
58
    expect(scan).toMatchObject({ present: true, matched: 2, listed: 2, skipped_symlinks: 0 });
59
    expect(candidates.map((candidate) => candidate.path)).toEqual([
60
      join(root, "new.json"),
61
      join(root, "old.json"),
62
    ]);
63
    expect(candidates[0]).toMatchObject({
64
      kind: "trace_path",
65
      bytes: 2,
66
      modified_at: "2026-08-20T00:00:00.000Z",
67
    });
68
  });
69
70
  it("caps the listing while still counting every match", () => {
71
    const root = scratch();
72
    for (let index = 0; index < 5; index += 1) {
73
      writeAt(join(root, `t${index}.json`), "{}", new Date(2026, 0, index + 1));
74
    }
75
    const { scan, candidates } = scanTraceStore(pathTraceStore(root), {
76
      ...defaultDiscoveryBounds,
77
      maxFilesPerStore: 2,
78
    });
79
    expect(scan.matched).toBe(5);
80
    expect(scan.listed).toBe(2);
81
    expect(candidates).toHaveLength(2);
82
  });
83
84
  it("stops at the depth bound", () => {
85
    const root = scratch();
86
    const shallow = join(root, "a");
87
    const deep = join(root, "a", "b");
88
    mkdirSync(deep, { recursive: true });
89
    writeFileSync(join(shallow, "shallow.json"), "{}", "utf8");
90
    writeFileSync(join(deep, "deep.json"), "{}", "utf8");
91
92
    const { candidates } = scanTraceStore(pathTraceStore(root), {
93
      ...defaultDiscoveryBounds,
94
      maxDepth: 1,
95
    });
96
    expect(candidates.map((candidate) => candidate.path)).toEqual([join(shallow, "shallow.json")]);
97
  });
98
99
  it("stops at the entry budget and says the scan was truncated", () => {
100
    const root = scratch();
101
    for (let index = 0; index < 10; index += 1) {
102
      writeFileSync(join(root, `t${index}.json`), "{}", "utf8");
103
    }
104
    const { scan } = scanTraceStore(pathTraceStore(root), {
105
      ...defaultDiscoveryBounds,
106
      maxScanEntries: 3,
107
    });
108
    expect(scan.truncated).toBe(true);
109
    expect(scan.matched).toBeLessThanOrEqual(3);
110
  });
111
112
  it("never follows a symlink, whether file or directory", () => {
113
    const outside = scratch();
114
    writeFileSync(join(outside, "secret.json"), "{}", "utf8");
115
    const root = scratch();
116
    writeFileSync(join(root, "real.json"), "{}", "utf8");
117
    symlinkSync(join(outside, "secret.json"), join(root, "linked-file.json"));
118
    symlinkSync(outside, join(root, "linked-dir"));
119
120
    const { scan, candidates } = scanTraceStore(pathTraceStore(root));
121
    expect(candidates.map((candidate) => candidate.path)).toEqual([join(root, "real.json")]);
122
    expect(scan.skipped_symlinks).toBe(2);
123
  });
124
125
  it("refuses a store whose root is itself a symlink", () => {
126
    const target = scratch();
127
    writeFileSync(join(target, "trace.json"), "{}", "utf8");
128
    const holder = scratch();
129
    const link = join(holder, "linked-root");
130
    symlinkSync(target, link);
131
132
    const { scan, candidates } = scanTraceStore(pathTraceStore(link));
133
    expect(scan.present).toBe(false);
134
    expect(scan.skipped_symlinks).toBe(1);
135
    expect(candidates).toEqual([]);
136
  });
137
});
138
139
const atifDocument = {
140
  schema_version: "ATIF-v1.7",
141
  session_id: "openagents.com-2026-08-24T14:00:00.000Z",
142
  agent: { name: "openagents-coder", version: "0.3.5", model_name: "Ollama qwen" },
143
  steps: [
144
    { step_id: 1, timestamp: "2026-08-24T14:00:00.000Z", source: "user", message: "hello" },
145
    {
146
      step_id: 2,
147
      timestamp: "2026-08-24T14:00:05.000Z",
148
      source: "agent",
149
      message: "",
150
      model_name: "Ollama qwen",
151
      metrics: { prompt_tokens: 11, completion_tokens: 7 },
152
      tool_calls: [
153
        { tool_call_id: "call-1", function_name: "shell", arguments: { command: "ls" } },
154
      ],
155
      observation: { results: [{ source_call_id: "call-1", content: "README.md" }] },
156
    },
157
    {
158
      step_id: 3,
159
      timestamp: "2026-08-24T14:00:09.000Z",
160
      source: "agent",
161
      message: "done",
162
      model_name: "Ollama qwen",
163
      metrics: { prompt_tokens: 9, completion_tokens: 3 },
164
    },
165
  ],
166
  final_metrics: { total_prompt_tokens: 20, total_completion_tokens: 10, total_steps: 3 },
167
};
168
169
describe("trace summarization", () => {
170
  it("summarizes an ATIF document without dumping payloads", () => {
171
    const root = scratch();
172
    const path = join(root, "session-atif.json");
173
    writeFileSync(path, JSON.stringify(atifDocument), "utf8");
174
175
    const summary = summarizeTraceFile(path);
176
    expect(summary).toMatchObject({
177
      format: "atif",
178
      schema_version: "ATIF-v1.7",
179
      session_id: "openagents.com-2026-08-24T14:00:00.000Z",
180
      agent: { name: "openagents-coder", model: "Ollama qwen" },
181
      steps: 3,
182
      steps_by_source: { user: 1, agent: 2 },
183
      models: ["Ollama qwen"],
184
      tool_calls: 1,
185
      total_prompt_tokens: 20,
186
      total_completion_tokens: 10,
187
      first_timestamp: "2026-08-24T14:00:00.000Z",
188
      last_timestamp: "2026-08-24T14:00:09.000Z",
189
    });
190
    expect(JSON.stringify(summary)).not.toContain("README.md");
191
  });
192
193
  it("gives a foreign line-delimited log metadata only", () => {
194
    const root = scratch();
195
    const path = join(root, "rollout.jsonl");
196
    writeFileSync(path, '{"type":"message"}\n{"type":"message"}\n', "utf8");
197
198
    const summary = summarizeTraceFile(path);
199
    expect(summary).toMatchObject({ format: "jsonl", lines: 2 });
200
    expect(summary.steps).toBeUndefined();
201
  });
202
});
203
204
describe("trace redaction", () => {
205
  const home = "/Users/octavia";
206
  const rules = redactionRules(home);
207
208
  const plantedSecrets: ReadonlyArray<{ category: string; text: string; secret: string }> = [
209
    {
210
      category: "bearer_token",
211
      text: "authorization: Bearer sec.ret-token.value-12345",
212
      secret: "sec.ret-token.value-12345",
213
    },
214
    {
215
      category: "api_key",
216
      text: "used sk-abcdefghijklmnop1234 to call",
217
      secret: "sk-abcdefghijklmnop1234",
218
    },
219
    {
220
      category: "api_key",
221
      text: "pushed with ghp_abcdefghijklmnopqrst123456",
222
      secret: "ghp_abcdefghijklmnopqrst123456",
223
    },
224
    {
225
      category: "jwt",
226
      text: "session eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMifQ.abc123def456 expired",
227
      secret: "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMifQ.abc123def456",
228
    },
229
    {
230
      category: "env_value",
231
      text: "ran with DATABASE_URL=postgres://user:pw@host/db",
232
      secret: "postgres://user:pw@host/db",
233
    },
234
    {
235
      category: "env_value",
236
      text: `exported OPENAI_API_KEY="quoted-secret-value"`,
237
      secret: "quoted-secret-value",
238
    },
239
  ];
240
241
  it.each(plantedSecrets)("removes a planted $category", ({ category, secret, text }) => {
242
    const result = redactText(text, rules);
243
    expect(result.text).not.toContain(secret);
244
    expect(result.counts[category]).toBeGreaterThanOrEqual(1);
245
  });
246
247
  it("redacts secret-named JSON fields by name", () => {
248
    const result = redactText(
249
      JSON.stringify({ api_token: "abc", password: "hunter2", message: "keep me" }),
250
      rules,
251
    );
252
    expect(result.text).not.toContain("hunter2");
253
    expect(result.text).toContain("keep me");
254
    expect(result.counts["secret_field"]).toBe(2);
255
  });
256
257
  it("rewrites home paths to a tilde", () => {
258
    const result = redactText(`read ${home}/notes.txt and /home/friend/file`, rules);
259
    expect(result.text).toContain("read ~/notes.txt");
260
    expect(result.text).not.toContain("/home/friend");
261
    expect(result.counts["home_path"]).toBe(2);
262
  });
263
264
  it("leaves ordinary prose alone", () => {
265
    const result = redactText("The agent listed files and wrote a summary.", rules);
266
    expect(result.total).toBe(0);
267
    expect(result.text).toBe("The agent listed files and wrote a summary.");
268
  });
269
270
  it("shapes the sibling path for json, jsonl, and other names", () => {
271
    expect(redactedPathFor("/a/trace.json")).toBe("/a/trace.redacted.json");
272
    expect(redactedPathFor("/a/rollout.jsonl")).toBe("/a/rollout.redacted.jsonl");
273
    expect(redactedPathFor("/a/notes.txt")).toBe("/a/notes.txt.redacted.json");
274
  });
275
276
  it("writes a redacted sibling that still parses, and reports counts only", () => {
277
    const root = scratch();
278
    const path = join(root, "session-atif.json");
279
    const document = {
280
      ...atifDocument,
281
      steps: [
282
        {
283
          step_id: 1,
284
          timestamp: "2026-08-24T14:00:00.000Z",
285
          source: "user",
286
          message: `run with OPENAI_API_KEY=sk-abcdefghijklmnop1234 in ${home}/work`,
287
        },
288
      ],
289
    };
290
    writeFileSync(path, JSON.stringify(document), "utf8");
291
292
    const result = redactTraceFile(path, home);
293
    expect(result.output).toBe(join(root, "session-atif.redacted.json"));
294
    expect(result.valid_json).toBe(true);
295
    expect(result.total).toBeGreaterThanOrEqual(2);
296
297
    const written = readFileSync(result.output, "utf8");
298
    expect(written).not.toContain("sk-abcdefghijklmnop1234");
299
    expect(written).not.toContain(home);
300
    // The source file is never rewritten.
301
    expect(readFileSync(path, "utf8")).toContain("sk-abcdefghijklmnop1234");
302
    // The report carries counts, never the matched text.
303
    expect(JSON.stringify(result)).not.toContain("sk-abcdefghijklmnop1234");
304
  });
305
});

This page updates live while a promote is in flight · changelog