opencode-self-improvement 0.3.2 → 0.3.5
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/package.json +1 -1
- package/src/logfmt.ts +39 -12
- package/src/memory.ts +4 -2
- package/src/viewer/server.ts +22 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-self-improvement",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
4
4
|
"description": "Durable memory for opencode: remember facts, decisions, and commands across sessions, plus a one-command per-repo setup (/repo-init).",
|
|
5
5
|
"main": "index.ts",
|
|
6
6
|
"exports": {
|
package/src/logfmt.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Minimal logfmt reader/writer shared by the durable store and the evidence
|
|
2
2
|
// log. Field order is preserved on write so chains are diffable.
|
|
3
3
|
import { dirname } from "node:path";
|
|
4
|
-
import { mkdir } from "node:fs/promises";
|
|
4
|
+
import { appendFile, mkdir } from "node:fs/promises";
|
|
5
5
|
|
|
6
6
|
export type Fields = Record<string, unknown>;
|
|
7
7
|
|
|
@@ -12,6 +12,16 @@ export type Fields = Record<string, unknown>;
|
|
|
12
12
|
const ESCAPE: Record<string, string> = { "\\": "\\\\", '"': '\\"', "\n": "\\n", "\r": "\\r", "\t": "\\t" };
|
|
13
13
|
const UNESCAPE: Record<string, string> = { "\\": "\\", '"': '"', n: "\n", r: "\r", t: "\t" };
|
|
14
14
|
|
|
15
|
+
// Lines written before these escapes existed stored backslashes raw, so in an
|
|
16
|
+
// old line `C:\new` is a backslash and an `n`, not a newline. A line whose
|
|
17
|
+
// values use the escapes ends with this marker, and only fields before it are
|
|
18
|
+
// unescaped; everything else is read the old way, where only `\"` is an escape.
|
|
19
|
+
// Fields after the marker were appended by an older version (supersede adds
|
|
20
|
+
// fields to the end of an existing line). Lines without backslashes, newlines
|
|
21
|
+
// or tabs read the same either way, so they don't carry the marker.
|
|
22
|
+
const MARKER = "esc";
|
|
23
|
+
const MARKER_VALUE = "1";
|
|
24
|
+
|
|
15
25
|
export function quote(value: unknown): string {
|
|
16
26
|
const s = String(value);
|
|
17
27
|
if (s === "") return '""';
|
|
@@ -20,34 +30,47 @@ export function quote(value: unknown): string {
|
|
|
20
30
|
|
|
21
31
|
export function formatLine(fields: Fields): string {
|
|
22
32
|
const parts: string[] = [];
|
|
33
|
+
let escaped = false;
|
|
23
34
|
for (const [k, v] of Object.entries(fields)) {
|
|
24
|
-
if (v === undefined || v === null) continue;
|
|
25
|
-
|
|
35
|
+
if (v === undefined || v === null || k === MARKER) continue;
|
|
36
|
+
const q = quote(v);
|
|
37
|
+
if (q.startsWith('"') && /[\\\n\r\t]/.test(String(v))) escaped = true;
|
|
38
|
+
parts.push(`${k}=${q}`);
|
|
26
39
|
}
|
|
40
|
+
if (escaped) parts.push(`${MARKER}=${MARKER_VALUE}`);
|
|
27
41
|
return parts.join(" ");
|
|
28
42
|
}
|
|
29
43
|
|
|
30
44
|
// Unescape a quoted logfmt value and read it back. Unknown sequences are kept
|
|
31
|
-
// as written
|
|
45
|
+
// as written.
|
|
32
46
|
function unquote(s: string): string {
|
|
33
47
|
return s.replace(/\\(.)/g, (seq, c: string) => UNESCAPE[c] ?? seq);
|
|
34
48
|
}
|
|
35
49
|
|
|
50
|
+
function unquoteLegacy(s: string): string {
|
|
51
|
+
return s.replace(/\\"/g, '"');
|
|
52
|
+
}
|
|
53
|
+
|
|
36
54
|
export function parseLine(line: string): Fields | null {
|
|
37
55
|
const trimmed = line.trim();
|
|
38
56
|
if (!trimmed || !trimmed.includes("=")) return null;
|
|
39
|
-
const
|
|
57
|
+
const tokens: [string, string][] = [];
|
|
40
58
|
const re = /([A-Za-z0-9_.-]+)=("(?:[^"\\]|\\.)*"|[^\s]*)/g;
|
|
41
59
|
for (;;) {
|
|
42
60
|
const m = re.exec(trimmed);
|
|
43
61
|
if (!m) break;
|
|
44
|
-
|
|
45
|
-
|
|
62
|
+
tokens.push([m[1], m[2]]);
|
|
63
|
+
}
|
|
64
|
+
const marker = tokens.findIndex(([k, v]) => k === MARKER && v === MARKER_VALUE);
|
|
65
|
+
const fields: Fields = {};
|
|
66
|
+
tokens.forEach(([key, value], i) => {
|
|
67
|
+
if (key === MARKER) return;
|
|
68
|
+
let raw = value;
|
|
46
69
|
if (raw.startsWith('"') && raw.endsWith('"')) {
|
|
47
|
-
raw = unquote(raw.slice(1, -1));
|
|
70
|
+
raw = (i < marker ? unquote : unquoteLegacy)(raw.slice(1, -1));
|
|
48
71
|
}
|
|
49
72
|
fields[key] = raw;
|
|
50
|
-
}
|
|
73
|
+
});
|
|
51
74
|
// parse booleans/numbers as strings is fine; callers coerce.
|
|
52
75
|
return fields;
|
|
53
76
|
}
|
|
@@ -59,9 +82,13 @@ export async function ensureDir(path: string): Promise<void> {
|
|
|
59
82
|
export async function appendLog(file: string, line: string): Promise<void> {
|
|
60
83
|
await ensureDir(dirname(file));
|
|
61
84
|
const next = line.endsWith("\n") ? line : line + "\n";
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
85
|
+
// One O_APPEND write (issue #23). The previous read-modify-write lost rows:
|
|
86
|
+
// 20 concurrent appends left 2 on disk. appendFile's "a" flag is
|
|
87
|
+
// O_APPEND|O_CREAT — atomic small writes on local POSIX filesystems,
|
|
88
|
+
// covering interleaved async tasks in the service AND a second process
|
|
89
|
+
// (the viewer) appending the same day file. Do NOT "simplify" this to
|
|
90
|
+
// Bun.write with an options object: on Bun 1.4.3 that silently TRUNCATES.
|
|
91
|
+
await appendFile(file, next);
|
|
65
92
|
}
|
|
66
93
|
|
|
67
94
|
export async function readLines(file: string): Promise<string[]> {
|
package/src/memory.ts
CHANGED
|
@@ -179,7 +179,10 @@ export async function markSuperseded(ts: string, fields: SupersedeFields): Promi
|
|
|
179
179
|
if (idx === -1) continue;
|
|
180
180
|
const parsed = parseLine(lines[idx]);
|
|
181
181
|
if (parsed && typeof parsed.superseded_by === "string") return false;
|
|
182
|
-
|
|
182
|
+
// Rewrite the whole line rather than appending to it, so an old-format line
|
|
183
|
+
// comes out in the current format with its values unchanged.
|
|
184
|
+
lines[idx] = formatLine({
|
|
185
|
+
...parsed,
|
|
183
186
|
superseded_by: fields.superseded_by,
|
|
184
187
|
reason: fields.reason,
|
|
185
188
|
symbol: fields.symbol,
|
|
@@ -187,7 +190,6 @@ export async function markSuperseded(ts: string, fields: SupersedeFields): Promi
|
|
|
187
190
|
to: fields.to,
|
|
188
191
|
entity: fields.entity,
|
|
189
192
|
});
|
|
190
|
-
lines[idx] = `${lines[idx]} ${extra}`;
|
|
191
193
|
await snapshotBefore(file);
|
|
192
194
|
await Bun.write(file, lines.join("\n") + "\n");
|
|
193
195
|
return true;
|
package/src/viewer/server.ts
CHANGED
|
@@ -17,10 +17,20 @@ const PAGE = join(import.meta.dir, "page.html");
|
|
|
17
17
|
const HOST = process.env.OPENCODE_VIEWER_HOST || "127.0.0.1";
|
|
18
18
|
const PORT = Number(process.env.OPENCODE_VIEWER_PORT || 8787);
|
|
19
19
|
|
|
20
|
+
// Defense-in-depth headers on every response. CSP: the page uses only inline
|
|
21
|
+
// script/style and same-origin fetch, so everything else is off — a stored-XSS
|
|
22
|
+
// payload (see issue #11) gains no exfil channel and no framing.
|
|
23
|
+
const SECURITY_HEADERS: Record<string, string> = {
|
|
24
|
+
"x-content-type-options": "nosniff",
|
|
25
|
+
"content-security-policy":
|
|
26
|
+
"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; " +
|
|
27
|
+
"connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
|
|
28
|
+
};
|
|
29
|
+
|
|
20
30
|
function json(body: unknown, status = 200): Response {
|
|
21
31
|
return new Response(JSON.stringify(body), {
|
|
22
32
|
status,
|
|
23
|
-
headers: { "content-type": "application/json" },
|
|
33
|
+
headers: { ...SECURITY_HEADERS, "content-type": "application/json" },
|
|
24
34
|
});
|
|
25
35
|
}
|
|
26
36
|
|
|
@@ -116,13 +126,21 @@ export async function startViewer(port: number = PORT): Promise<ViewerServer> {
|
|
|
116
126
|
hostname: HOST,
|
|
117
127
|
port,
|
|
118
128
|
fetch(req, srv) {
|
|
119
|
-
// Bun builds req.url from the request's Host header.
|
|
120
|
-
|
|
129
|
+
// Bun builds req.url from the request's Host header. A malformed Host
|
|
130
|
+
// makes the URL parse THROW — before rejectRequest could answer. Letting
|
|
131
|
+
// that escape hands the caller Bun's dev error page (stack + absolute
|
|
132
|
+
// paths), so the parse itself is the first guarded step (issue #12).
|
|
133
|
+
let url: URL;
|
|
134
|
+
try {
|
|
135
|
+
url = new URL(req.url);
|
|
136
|
+
} catch {
|
|
137
|
+
return error("malformed request URL (bad Host header?)", 400);
|
|
138
|
+
}
|
|
121
139
|
const rejected = rejectRequest(req, url, server.port ?? port);
|
|
122
140
|
if (rejected) return rejected;
|
|
123
141
|
if (url.pathname === "/") {
|
|
124
142
|
return new Response(page, {
|
|
125
|
-
headers: { "content-type": "text/html; charset=utf-8" },
|
|
143
|
+
headers: { ...SECURITY_HEADERS, "content-type": "text/html; charset=utf-8" },
|
|
126
144
|
});
|
|
127
145
|
}
|
|
128
146
|
if (req.method === "GET" && url.pathname === "/api/data") {
|