create-agent-rig 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +316 -11
- package/README.md +58 -19
- package/package.json +2 -1
- package/packages/cli/dist/commands/create.js +8 -3
- package/packages/cli/dist/commands/init.js +85 -34
- package/packages/cli/dist/commands/upgrade.js +112 -30
- package/packages/cli/dist/index.js +81 -19
- package/packages/cli/dist/lib/copy-tree.js +35 -6
- package/packages/cli/dist/lib/init-settings.js +12 -0
- package/packages/cli/dist/lib/install-set.js +6 -8
- package/packages/cli/dist/lib/manifest.js +31 -9
- package/packages/cli/dist/lib/safe-path.js +30 -0
- package/scripts/prepare.mjs +1 -1
- package/templates/agent-os/init/AGENTS.md +199 -0
- package/templates/agent-os/init/CLAUDE.md +69 -9
- package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +105 -0
- package/templates/agent-os/stack/aws-cdk/.agents/skills/ro-debug/SKILL.md +117 -0
- package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +41 -3
- package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +1 -1
- package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +41 -0
- package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +4 -0
- package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +29 -0
- package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +236 -0
- package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +958 -0
- package/templates/agent-os/universal/.agents/skills/new-invariant/SKILL.md +102 -0
- package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.mjs +78 -0
- package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.test.mjs +89 -0
- package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +396 -0
- package/templates/agent-os/universal/.agents/skills/worktree-task/SKILL.md +73 -0
- package/templates/agent-os/universal/.claude/agents/code-reviewer.md +40 -1
- package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +71 -1
- package/templates/agent-os/universal/.claude/agents/security-scanner.md +40 -0
- package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +301 -26
- package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +43 -5
- package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +25 -11
- package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +127 -0
- package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +180 -0
- package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +32 -13
- package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +175 -7
- package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +503 -0
- package/templates/agent-os/universal/.claude/rules/autonomy.md +69 -1
- package/templates/agent-os/universal/.claude/rules/invariants.md +104 -19
- package/templates/agent-os/universal/.claude/rules/workflow.md +55 -5
- package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1260 -0
- package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +65 -2
- package/templates/agent-os/universal/.claude/scripts/doctor.mjs +351 -0
- package/templates/agent-os/universal/.claude/scripts/git-env.mjs +49 -0
- package/templates/agent-os/universal/.claude/scripts/lib/gate-coverage.mjs +306 -0
- package/templates/agent-os/universal/.claude/scripts/lib/revalidation-points.mjs +28 -0
- package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +490 -0
- package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +462 -0
- package/templates/agent-os/universal/.claude/scripts/preflight.mjs +33 -17
- package/templates/agent-os/universal/.claude/scripts/queue/as-of.mjs +51 -0
- package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +149 -0
- package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +809 -28
- package/templates/agent-os/universal/.claude/scripts/queue/gate-rounds.mjs +160 -0
- package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +95 -16
- package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +543 -15
- package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +457 -47
- package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +289 -25
- package/templates/agent-os/universal/.claude/scripts/queue/state.mjs +197 -0
- package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +316 -0
- package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +180 -0
- package/templates/agent-os/universal/.claude/scripts/run-journal.mjs +435 -0
- package/templates/agent-os/universal/.claude/scripts/run-state.mjs +539 -0
- package/templates/agent-os/universal/.claude/scripts/stop-flag.mjs +15 -8
- package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +239 -0
- package/templates/agent-os/universal/.claude/scripts/verdict.mjs +198 -0
- package/templates/agent-os/universal/.claude/settings.json +11 -2
- package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +118 -7
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +667 -38
- package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +359 -26
- package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +4 -0
- package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +4 -0
- package/templates/agent-os/universal/.codex/agents/security-scanner.toml +4 -0
- package/templates/agent-os/universal/.codex/agents/test-writer.toml +4 -0
- package/templates/agent-os/universal/.codex/hooks.json +70 -0
- package/templates/agent-os/universal/AGENTS.md +166 -0
- package/templates/agent-os/universal/CLAUDE.md +49 -14
- package/templates/agent-os/universal/PLAN.md +7 -40
- package/templates/agent-os/universal/docs/decisions/closing-a-task.md +98 -0
- package/templates/agent-os/universal/docs/decisions/codex-adapter.md +108 -0
- package/templates/agent-os/universal/docs/decisions/fail-open-guards.md +43 -0
- package/templates/agent-os/universal/docs/decisions/gate-coverage.md +83 -0
- package/templates/agent-os/universal/docs/decisions/review-lanes.md +72 -0
- package/templates/agent-os/universal/docs/decisions/run-directory.md +44 -0
- package/templates/agent-os/universal/docs/decisions/spacing-rations-mechanisms.md +125 -0
- package/templates/agent-os/universal/docs/decisions/stop-conditions-in-a-file.md +46 -0
- package/templates/agent-os/universal/docs/decisions/two-empty-endings.md +74 -0
- package/templates/agent-os/universal/journal/README.md +101 -0
- package/templates/agent-os/universal/layers.json +45 -2
- package/templates/hash-history.json +310 -49
- package/templates/release-ledger.json +9 -0
- package/templates/skeleton/aws-serverless/.github/workflows/deploy.yml +34 -1
- package/templates/skeleton/aws-serverless/README.md +91 -9
- package/templates/skeleton/aws-serverless/apps/web/src/lib/api.ts +9 -2
- package/templates/skeleton/aws-serverless/gitignore +37 -0
- package/templates/skeleton/aws-serverless/infra/bin/app.ts +73 -13
- package/templates/skeleton/aws-serverless/infra/lib/app-stack.ts +136 -4
- package/templates/skeleton/aws-serverless/infra/lib/web-stack.ts +14 -1
- package/templates/skeleton/aws-serverless/infra/test/allowed-origins.test.ts +301 -0
- package/templates/skeleton/aws-serverless/infra/test/app-composition.test.ts +137 -0
- package/templates/skeleton/aws-serverless/infra/test/app-stack.test.ts +36 -20
- package/templates/skeleton/aws-serverless/infra/test/web-stack.test.ts +15 -12
- package/templates/skeleton/aws-serverless/services/api/src/handlers/create-note.ts +33 -6
- package/templates/skeleton/aws-serverless/services/api/src/handlers/list-notes.ts +18 -5
- package/templates/skeleton/aws-serverless/services/api/src/usecases/create-note.ts +11 -0
- package/templates/skeleton/aws-serverless/services/api/test/create-note.handler.test.ts +120 -1
- package/templates/skeleton/aws-serverless/services/api/test/list-notes.test.ts +72 -1
- package/templates/skeleton/node-service/README.md +11 -1
- package/templates/skeleton/node-service/gitignore +34 -0
- package/templates/skeleton/node-service/packages/db/src/note-store.ts +47 -10
- package/templates/skeleton/node-service/packages/db/test/note-store.test.ts +20 -0
- package/templates/skeleton/node-service/services/api/src/main.ts +2 -9
- package/templates/skeleton/node-service/services/api/src/server.ts +93 -10
- package/templates/skeleton/node-service/services/api/src/static-dir.ts +20 -0
- package/templates/skeleton/node-service/services/api/test/server.test.ts +98 -13
- package/templates/skeleton/node-service/services/api/test/static-dir.test.ts +28 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// configured) serving the built web bundle. No second runtime for the
|
|
3
3
|
// frontend — the same process serves the static export.
|
|
4
4
|
import { createReadStream } from 'node:fs';
|
|
5
|
-
import { stat } from 'node:fs/promises';
|
|
5
|
+
import { realpath, stat } from 'node:fs/promises';
|
|
6
6
|
import { createServer, type Server } from 'node:http';
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import type { CreateNoteDeps } from './usecases/create-note.js';
|
|
@@ -18,6 +18,14 @@ export interface ServerOptions {
|
|
|
18
18
|
staticDir?: string;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Largest request body `POST /notes` will buffer — that route and no other:
|
|
23
|
+
* the cap is applied where the body is read, so a second route that reads one
|
|
24
|
+
* brings its own. A note is a title and some tags; anything past this is a
|
|
25
|
+
* mistake or an attack, and either way the process must not hold it in memory.
|
|
26
|
+
*/
|
|
27
|
+
const MAX_BODY_BYTES = 1024 * 1024;
|
|
28
|
+
|
|
21
29
|
const CONTENT_TYPES: Record<string, string> = {
|
|
22
30
|
'.html': 'text/html; charset=utf-8',
|
|
23
31
|
'.css': 'text/css',
|
|
@@ -39,11 +47,34 @@ export function makeServer(deps: ServerDeps, options: ServerOptions = {}): Serve
|
|
|
39
47
|
response.end(result.body);
|
|
40
48
|
};
|
|
41
49
|
const notFound = () => respond({ statusCode: 404, body: JSON.stringify({ error: 'not found' }) });
|
|
50
|
+
const badRequest = () =>
|
|
51
|
+
respond({ statusCode: 400, body: JSON.stringify({ error: 'bad request' }) });
|
|
42
52
|
|
|
43
53
|
if (request.method === 'POST' && request.url === '/notes') {
|
|
44
54
|
const chunks: Buffer[] = [];
|
|
45
|
-
|
|
55
|
+
let size = 0;
|
|
56
|
+
let refused = false;
|
|
57
|
+
// A socket that dies mid-upload emits 'error'; unhandled, it takes the
|
|
58
|
+
// process with it.
|
|
59
|
+
request.on('error', () => {
|
|
60
|
+
refused = true;
|
|
61
|
+
});
|
|
62
|
+
request.on('data', (chunk: Buffer) => {
|
|
63
|
+
if (refused) return;
|
|
64
|
+
size += chunk.length;
|
|
65
|
+
if (size > MAX_BODY_BYTES) {
|
|
66
|
+
refused = true;
|
|
67
|
+
chunks.length = 0;
|
|
68
|
+
respond({ statusCode: 413, body: JSON.stringify({ error: 'payload too large' }) });
|
|
69
|
+
// Keep draining rather than destroying the socket: a client that is
|
|
70
|
+
// still uploading has to stay connected to read the refusal.
|
|
71
|
+
request.resume();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
chunks.push(chunk);
|
|
75
|
+
});
|
|
46
76
|
request.on('end', () => {
|
|
77
|
+
if (refused) return;
|
|
47
78
|
void createNote(Buffer.concat(chunks).toString('utf8')).then(respond);
|
|
48
79
|
});
|
|
49
80
|
return;
|
|
@@ -55,7 +86,21 @@ export function makeServer(deps: ServerDeps, options: ServerOptions = {}): Serve
|
|
|
55
86
|
}
|
|
56
87
|
|
|
57
88
|
if (request.method === 'GET' && options.staticDir) {
|
|
58
|
-
|
|
89
|
+
// The rejection handler is the point: without it a throw in here is an
|
|
90
|
+
// unhandled rejection, which ends the process on one malformed request.
|
|
91
|
+
void serveStatic(options.staticDir, request.url ?? '/', response, notFound, badRequest).catch(
|
|
92
|
+
(error: unknown) => {
|
|
93
|
+
// Say nothing to the client beyond the code, but never swallow it —
|
|
94
|
+
// a static path that fails silently is a blind spot in every
|
|
95
|
+
// generated project.
|
|
96
|
+
deps.log.error('static serve failed', { url: request.url, error: String(error) });
|
|
97
|
+
if (response.headersSent) {
|
|
98
|
+
response.destroy();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
respond({ statusCode: 500, body: JSON.stringify({ error: 'internal error' }) });
|
|
102
|
+
},
|
|
103
|
+
);
|
|
59
104
|
return;
|
|
60
105
|
}
|
|
61
106
|
|
|
@@ -68,22 +113,60 @@ async function serveStatic(
|
|
|
68
113
|
rawUrl: string,
|
|
69
114
|
response: import('node:http').ServerResponse,
|
|
70
115
|
notFound: () => void,
|
|
116
|
+
badRequest: () => void,
|
|
71
117
|
): Promise<void> {
|
|
72
|
-
|
|
118
|
+
let pathname: string;
|
|
119
|
+
try {
|
|
120
|
+
pathname = decodeURIComponent(new URL(rawUrl, 'http://local').pathname);
|
|
121
|
+
} catch {
|
|
122
|
+
// A malformed escape (`GET /%`) is the client's error, not ours.
|
|
123
|
+
return badRequest();
|
|
124
|
+
}
|
|
73
125
|
const relative = pathname.endsWith('/') ? `${pathname}index.html` : pathname;
|
|
74
|
-
|
|
126
|
+
// Resolve the root first: a configured dir with a trailing separator would
|
|
127
|
+
// otherwise build a `//` prefix that no resolved path can start with, and
|
|
128
|
+
// every request would 404.
|
|
129
|
+
const root = path.resolve(staticDir);
|
|
130
|
+
const resolved = path.resolve(root, `.${path.sep}${relative}`);
|
|
75
131
|
// Path-traversal guard: whatever the URL said, we never leave staticDir.
|
|
76
|
-
|
|
132
|
+
// The string test is only half of it — it says where the path points, not
|
|
133
|
+
// what the filesystem will open, and stat/open both follow symlinks.
|
|
134
|
+
if (!within(root, resolved)) {
|
|
77
135
|
return notFound();
|
|
78
136
|
}
|
|
137
|
+
let file: string;
|
|
79
138
|
try {
|
|
80
|
-
|
|
139
|
+
// …so ask the filesystem too, and test the answer the same way. A missing
|
|
140
|
+
// file throws here, which is the 404 we would give it anyway.
|
|
141
|
+
file = await realpath(resolved);
|
|
142
|
+
if (!within(await realpath(root), file)) return notFound();
|
|
143
|
+
const stats = await stat(file);
|
|
81
144
|
if (!stats.isFile()) return notFound();
|
|
82
145
|
} catch {
|
|
83
146
|
return notFound();
|
|
84
147
|
}
|
|
85
|
-
|
|
86
|
-
|
|
148
|
+
// Open before the headers go out: a file that passes stat can still fail to
|
|
149
|
+
// open (EACCES, or it is gone by now), and after writeHead(200) there is no
|
|
150
|
+
// way left to say so — which is one GET away from an unhandled stream error.
|
|
151
|
+
const stream = createReadStream(file);
|
|
152
|
+
await new Promise<void>((resolve, reject) => {
|
|
153
|
+
stream.once('error', reject);
|
|
154
|
+
stream.once('open', () => {
|
|
155
|
+
stream.removeListener('error', reject);
|
|
156
|
+
response.writeHead(200, {
|
|
157
|
+
'content-type': CONTENT_TYPES[path.extname(file)] ?? 'application/octet-stream',
|
|
158
|
+
});
|
|
159
|
+
// Past the headers the only honest answer to a read failure is to cut
|
|
160
|
+
// the response short, so the client sees a truncated body rather than a
|
|
161
|
+
// complete one.
|
|
162
|
+
stream.once('error', () => response.destroy());
|
|
163
|
+
stream.pipe(response);
|
|
164
|
+
resolve();
|
|
165
|
+
});
|
|
87
166
|
});
|
|
88
|
-
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Whether `candidate` is `root` itself or sits underneath it. */
|
|
170
|
+
function within(root: string, candidate: string): boolean {
|
|
171
|
+
return candidate === root || candidate.startsWith(root + path.sep);
|
|
89
172
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Where the built web bundle lives, resolved relative to the composition root.
|
|
2
|
+
// A pure function rather than an inline expression in main.ts: main.ts is wiring
|
|
3
|
+
// that tests never import, and this resolution has a bug worth pinning —
|
|
4
|
+
// `new URL(url).pathname` leaves percent-escapes in place (a checkout under
|
|
5
|
+
// "/Users/a b/" resolves to "/Users/a%20b/") and prefixes a slash on Windows.
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
/** `apps/web/out`, three levels up from a module in `services/api/src`. */
|
|
10
|
+
export function defaultStaticDirFor(moduleUrl: string): string {
|
|
11
|
+
return path.resolve(
|
|
12
|
+
path.dirname(fileURLToPath(moduleUrl)),
|
|
13
|
+
'..',
|
|
14
|
+
'..',
|
|
15
|
+
'..',
|
|
16
|
+
'apps',
|
|
17
|
+
'web',
|
|
18
|
+
'out',
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Integration: the real HTTP server over a real socket, with real file
|
|
2
2
|
// storage and a real spool directory — the whole request path at once.
|
|
3
|
-
import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { chmod, mkdir, mkdtemp, readdir, rm, symlink, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import type { AddressInfo } from 'node:net';
|
|
6
6
|
import path from 'node:path';
|
|
@@ -75,8 +75,43 @@ describe('the service end to end', () => {
|
|
|
75
75
|
const { notes } = (await response.json()) as { notes: Array<{ title: string }> };
|
|
76
76
|
expect(notes).toHaveLength(2);
|
|
77
77
|
});
|
|
78
|
+
|
|
79
|
+
it('refuses a request body larger than the cap with 413', async () => {
|
|
80
|
+
// Well-formed JSON on purpose: what is rejected is the size, not the shape.
|
|
81
|
+
// The server must answer before it has buffered the whole upload, and must
|
|
82
|
+
// stay readable long enough for the client to see that answer.
|
|
83
|
+
const body = JSON.stringify({ title: 'x'.repeat(1024 * 1024 + 1024) });
|
|
84
|
+
const response = await fetch(`${baseUrl}/notes`, {
|
|
85
|
+
method: 'POST',
|
|
86
|
+
headers: { 'content-type': 'application/json' },
|
|
87
|
+
body,
|
|
88
|
+
});
|
|
89
|
+
expect(response.status).toBe(413);
|
|
90
|
+
});
|
|
78
91
|
});
|
|
79
92
|
|
|
93
|
+
/** A listening server serving `staticDir`, plus its base URL. */
|
|
94
|
+
async function startStaticServer(staticDir: string): Promise<{ server: Server; base: string }> {
|
|
95
|
+
const server = makeServer(
|
|
96
|
+
{
|
|
97
|
+
notes: new JsonFileNoteStore(path.join(dir, 'data', 'notes.json')),
|
|
98
|
+
events: new SpoolEventPublisher(path.join(dir, 'queue'), () => 'm'),
|
|
99
|
+
newId: () => 'id',
|
|
100
|
+
now: () => '2024-01-01T00:00:00.000Z',
|
|
101
|
+
log: createLogger({}, () => {}),
|
|
102
|
+
},
|
|
103
|
+
{ staticDir },
|
|
104
|
+
);
|
|
105
|
+
await new Promise<void>((resolve) => server.listen(0, resolve));
|
|
106
|
+
return { server, base: `http://127.0.0.1:${(server.address() as AddressInfo).port}` };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Windows has no POSIX mode bits, and root reads a 0o000 file regardless — in
|
|
110
|
+
// both cases the "unreadable file" this test needs cannot be built at all.
|
|
111
|
+
const cannotMakeFileUnreadable = process.platform === 'win32' || process.getuid?.() === 0;
|
|
112
|
+
// Creating a symlink on Windows needs a privilege the test runner may not hold.
|
|
113
|
+
const cannotSymlink = process.platform === 'win32';
|
|
114
|
+
|
|
80
115
|
describe('static serving (the built web bundle)', () => {
|
|
81
116
|
let staticServer: Server;
|
|
82
117
|
let staticBase: string;
|
|
@@ -84,18 +119,9 @@ describe('static serving (the built web bundle)', () => {
|
|
|
84
119
|
beforeEach(async () => {
|
|
85
120
|
await mkdir(path.join(dir, 'static'), { recursive: true });
|
|
86
121
|
await writeFile(path.join(dir, 'static', 'index.html'), '<h1>web shell</h1>');
|
|
87
|
-
staticServer =
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
events: new SpoolEventPublisher(path.join(dir, 'queue'), () => 'm'),
|
|
91
|
-
newId: () => 'id',
|
|
92
|
-
now: () => '2024-01-01T00:00:00.000Z',
|
|
93
|
-
log: createLogger({}, () => {}),
|
|
94
|
-
},
|
|
95
|
-
{ staticDir: path.join(dir, 'static') },
|
|
96
|
-
);
|
|
97
|
-
await new Promise<void>((resolve) => staticServer.listen(0, resolve));
|
|
98
|
-
staticBase = `http://127.0.0.1:${(staticServer.address() as AddressInfo).port}`;
|
|
122
|
+
({ server: staticServer, base: staticBase } = await startStaticServer(
|
|
123
|
+
path.join(dir, 'static'),
|
|
124
|
+
));
|
|
99
125
|
});
|
|
100
126
|
|
|
101
127
|
afterEach(async () => {
|
|
@@ -120,4 +146,63 @@ describe('static serving (the built web bundle)', () => {
|
|
|
120
146
|
expect(response.status).toBe(200);
|
|
121
147
|
expect(response.headers.get('content-type')).toContain('application/json');
|
|
122
148
|
});
|
|
149
|
+
|
|
150
|
+
it('answers 400 to an undecodable URL and keeps serving afterwards', async () => {
|
|
151
|
+
// The signal is a leash, not a relaxation: a server that dies on this URL
|
|
152
|
+
// never answers at all, and the run should say so in seconds.
|
|
153
|
+
const response = await fetch(staticBase + '/%', { signal: AbortSignal.timeout(5000) });
|
|
154
|
+
expect(response.status).toBe(400);
|
|
155
|
+
|
|
156
|
+
const afterwards = await fetch(staticBase + '/');
|
|
157
|
+
expect(afterwards.status).toBe(200);
|
|
158
|
+
expect(await afterwards.text()).toContain('web shell');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it.skipIf(cannotMakeFileUnreadable)(
|
|
162
|
+
'answers a file it cannot open and keeps serving afterwards',
|
|
163
|
+
async () => {
|
|
164
|
+
// A file that passes stat() but fails open() (EACCES here; in production
|
|
165
|
+
// the stat/open race gives ENOENT). Before the headers are held back
|
|
166
|
+
// until the stream opens, that error reached no handler at all and the
|
|
167
|
+
// process paid for one unreadable file.
|
|
168
|
+
const locked = path.join(dir, 'static', 'locked.txt');
|
|
169
|
+
await writeFile(locked, 'unreadable');
|
|
170
|
+
await chmod(locked, 0o000);
|
|
171
|
+
try {
|
|
172
|
+
const response = await fetch(staticBase + '/locked.txt', {
|
|
173
|
+
signal: AbortSignal.timeout(5000),
|
|
174
|
+
});
|
|
175
|
+
expect(response.status).toBe(500);
|
|
176
|
+
|
|
177
|
+
const afterwards = await fetch(staticBase + '/');
|
|
178
|
+
expect(afterwards.status).toBe(200);
|
|
179
|
+
expect(await afterwards.text()).toContain('web shell');
|
|
180
|
+
} finally {
|
|
181
|
+
// Restore before afterEach removes the temp dir.
|
|
182
|
+
await chmod(locked, 0o600);
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
it.skipIf(cannotSymlink)('refuses a symlink that escapes the static dir', async () => {
|
|
188
|
+
// A lexical guard inspects only the resolved string, while stat and the
|
|
189
|
+
// read stream follow the link wherever it points — which is why the guard
|
|
190
|
+
// asks realpath as well.
|
|
191
|
+
await writeFile(path.join(dir, 'secret-target.txt'), 'nope');
|
|
192
|
+
await symlink(path.join(dir, 'secret-target.txt'), path.join(dir, 'static', 'escape.txt'));
|
|
193
|
+
|
|
194
|
+
const response = await fetch(staticBase + '/escape.txt');
|
|
195
|
+
expect(response.status).toBe(404);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('serves the bundle when the configured static dir has a trailing separator', async () => {
|
|
199
|
+
const { server, base } = await startStaticServer(path.join(dir, 'static') + path.sep);
|
|
200
|
+
try {
|
|
201
|
+
const response = await fetch(base + '/');
|
|
202
|
+
expect(response.status).toBe(200);
|
|
203
|
+
expect(await response.text()).toContain('web shell');
|
|
204
|
+
} finally {
|
|
205
|
+
await new Promise((resolve) => server.close(resolve));
|
|
206
|
+
}
|
|
207
|
+
});
|
|
123
208
|
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// The composition root resolves the built web bundle relative to itself; the
|
|
2
|
+
// resolution is a pure function so it can be tested without importing main.ts.
|
|
3
|
+
//
|
|
4
|
+
// What is NOT pinned here: the Windows half of the same defect — the old
|
|
5
|
+
// `new URL(url).pathname` yielded `/C:/…`. Both expressions are identical on a
|
|
6
|
+
// POSIX runner, so observing the difference would mean passing a path flavour
|
|
7
|
+
// into `defaultStaticDirFor`, i.e. a parameter that exists only for the test.
|
|
8
|
+
// The decision was to leave that hook out; percent-decoding below is the same
|
|
9
|
+
// bug's POSIX-visible half, and it fails against the old expression.
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { describe, expect, it } from 'vitest';
|
|
12
|
+
import { defaultStaticDirFor } from '../src/static-dir.js';
|
|
13
|
+
|
|
14
|
+
describe('resolving the default static dir from the module url', () => {
|
|
15
|
+
it('decodes percent-escapes in the path (a directory may contain a space)', () => {
|
|
16
|
+
const resolved = defaultStaticDirFor('file:///Users/a%20b/proj/services/api/src/main.ts');
|
|
17
|
+
|
|
18
|
+
expect(resolved).toContain('a b');
|
|
19
|
+
expect(resolved).not.toContain('%20');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('points three levels up from services/api/src at apps/web/out', () => {
|
|
23
|
+
const resolved = defaultStaticDirFor('file:///Users/a%20b/proj/services/api/src/main.ts');
|
|
24
|
+
|
|
25
|
+
expect(resolved.endsWith(path.join('a b', 'proj', 'apps', 'web', 'out'))).toBe(true);
|
|
26
|
+
expect(path.isAbsolute(resolved)).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
});
|