clawcontainer 1.0.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 (150) hide show
  1. package/CONTRIBUTING.md +76 -0
  2. package/DOCS.md +370 -0
  3. package/LICENSE +21 -0
  4. package/README.md +147 -0
  5. package/black_logo.png +0 -0
  6. package/dist/assets/abap-DLDM7-KI.js +1 -0
  7. package/dist/assets/apex-DNDY2TF8.js +1 -0
  8. package/dist/assets/azcli-Y6nb8tq_.js +1 -0
  9. package/dist/assets/bat-BwHxbl9M.js +1 -0
  10. package/dist/assets/bicep-CFznDFnq.js +2 -0
  11. package/dist/assets/cameligo-Bf6VGUru.js +1 -0
  12. package/dist/assets/clojure-Dnu-v4kV.js +1 -0
  13. package/dist/assets/codicon-ngg6Pgfi.ttf +0 -0
  14. package/dist/assets/coffee-Bd8akH9Z.js +1 -0
  15. package/dist/assets/cpp-BbWJElDN.js +1 -0
  16. package/dist/assets/csharp-Co3qMtFm.js +1 -0
  17. package/dist/assets/csp-D-4FJmMZ.js +1 -0
  18. package/dist/assets/css-DdJfP1eB.js +3 -0
  19. package/dist/assets/css.worker-GxEd3MMM.js +93 -0
  20. package/dist/assets/cssMode-DM_ONlf-.js +1 -0
  21. package/dist/assets/cypher-cTPe9QuQ.js +1 -0
  22. package/dist/assets/dart-BOtBlQCF.js +1 -0
  23. package/dist/assets/dockerfile-BG73LgW2.js +1 -0
  24. package/dist/assets/ecl-BEgZUVRK.js +1 -0
  25. package/dist/assets/elixir-BkW5O-1t.js +1 -0
  26. package/dist/assets/flow9-BeJ5waoc.js +1 -0
  27. package/dist/assets/freemarker2-VbwzOQPq.js +3 -0
  28. package/dist/assets/fsharp-PahG7c26.js +1 -0
  29. package/dist/assets/go-acbASCJo.js +1 -0
  30. package/dist/assets/graphql-BxJiqAUM.js +1 -0
  31. package/dist/assets/handlebars-DLvQ802u.js +1 -0
  32. package/dist/assets/hcl-DtV1sZF8.js +1 -0
  33. package/dist/assets/html-DuEPBzmS.js +1 -0
  34. package/dist/assets/html.worker-lU17Tx2m.js +470 -0
  35. package/dist/assets/htmlMode-BfeYTJaB.js +1 -0
  36. package/dist/assets/index-BnBKg8GZ.js +1291 -0
  37. package/dist/assets/index-Dq3FlPWe.css +32 -0
  38. package/dist/assets/ini-Kd9XrMLS.js +1 -0
  39. package/dist/assets/java-CXBNlu9o.js +1 -0
  40. package/dist/assets/javascript-DQO1Leza.js +1 -0
  41. package/dist/assets/json.worker-CUJs-dtA.js +58 -0
  42. package/dist/assets/jsonMode--qsURhHr.js +7 -0
  43. package/dist/assets/julia-cl7-CwDS.js +1 -0
  44. package/dist/assets/kotlin-s7OhZKlX.js +1 -0
  45. package/dist/assets/less-9HpZscsL.js +2 -0
  46. package/dist/assets/lexon-OrD6JF1K.js +1 -0
  47. package/dist/assets/liquid-PL6MZtM8.js +1 -0
  48. package/dist/assets/lspLanguageFeatures-Cy5rDFeq.js +4 -0
  49. package/dist/assets/lua-Cyyb5UIc.js +1 -0
  50. package/dist/assets/m3-B8OfTtLu.js +1 -0
  51. package/dist/assets/markdown-BFxVWTOG.js +1 -0
  52. package/dist/assets/mdx-Cb3Jy14X.js +1 -0
  53. package/dist/assets/mips-CiqrrVzr.js +1 -0
  54. package/dist/assets/msdax-DmeGPVcC.js +1 -0
  55. package/dist/assets/mysql-C_tMU-Nz.js +1 -0
  56. package/dist/assets/objective-c-BDtDVThU.js +1 -0
  57. package/dist/assets/pascal-vHIfCaH5.js +1 -0
  58. package/dist/assets/pascaligo-DtZ0uQbO.js +1 -0
  59. package/dist/assets/perl-Ub6l9XKa.js +1 -0
  60. package/dist/assets/pgsql-BlNEE0v7.js +1 -0
  61. package/dist/assets/php-BBUBE1dy.js +1 -0
  62. package/dist/assets/pla-DSh2-awV.js +1 -0
  63. package/dist/assets/postiats-CocnycG-.js +1 -0
  64. package/dist/assets/powerquery-tScXyioY.js +1 -0
  65. package/dist/assets/powershell-COWaemsV.js +1 -0
  66. package/dist/assets/protobuf-Brw8urJB.js +2 -0
  67. package/dist/assets/pug-8SOpv6rk.js +1 -0
  68. package/dist/assets/python-Usm4OUwq.js +1 -0
  69. package/dist/assets/qsharp-Bw9ernYp.js +1 -0
  70. package/dist/assets/r-j7ic8hl3.js +1 -0
  71. package/dist/assets/razor-BIOole7a.js +1 -0
  72. package/dist/assets/redis-Bu5POkcn.js +1 -0
  73. package/dist/assets/redshift-Bs9aos_-.js +1 -0
  74. package/dist/assets/restructuredtext-CqXO7rUv.js +1 -0
  75. package/dist/assets/ruby-zBfavPgS.js +1 -0
  76. package/dist/assets/rust-BzKRNQWT.js +1 -0
  77. package/dist/assets/sb-BBc9UKZt.js +1 -0
  78. package/dist/assets/scala-D9hQfWCl.js +1 -0
  79. package/dist/assets/scheme-BPhDTwHR.js +1 -0
  80. package/dist/assets/scss-CBJaRo0y.js +3 -0
  81. package/dist/assets/shell-DiJ1NA_G.js +1 -0
  82. package/dist/assets/solidity-Db0IVjzk.js +1 -0
  83. package/dist/assets/sophia-CnS9iZB_.js +1 -0
  84. package/dist/assets/sparql-CJmd_6j2.js +1 -0
  85. package/dist/assets/sql-ClhHkBeG.js +1 -0
  86. package/dist/assets/st-CHwy0fLd.js +1 -0
  87. package/dist/assets/swift-Bqt4WxQ4.js +3 -0
  88. package/dist/assets/systemverilog-Bs9z6M-B.js +1 -0
  89. package/dist/assets/tcl-Dm6ycUr_.js +1 -0
  90. package/dist/assets/ts.worker-Dy9lDQQT.js +67731 -0
  91. package/dist/assets/tsMode-CDjF3DWK.js +11 -0
  92. package/dist/assets/twig-Csy3S7wG.js +1 -0
  93. package/dist/assets/typescript-CJR4sLnG.js +1 -0
  94. package/dist/assets/typespec-Btyra-wh.js +1 -0
  95. package/dist/assets/vb-Db0cS2oM.js +1 -0
  96. package/dist/assets/wgsl-DumH7NcR.js +298 -0
  97. package/dist/assets/xml-CJZS3uh7.js +1 -0
  98. package/dist/assets/yaml-DB88cW5z.js +1 -0
  99. package/dist/audit.d.ts +48 -0
  100. package/dist/container.d.ts +100 -0
  101. package/dist/event-emitter.d.ts +7 -0
  102. package/dist/favicon.png +0 -0
  103. package/dist/git-service.d.ts +31 -0
  104. package/dist/index.html +188 -0
  105. package/dist/logo-sm.png +0 -0
  106. package/dist/logo.png +0 -0
  107. package/dist/main.d.ts +1 -0
  108. package/dist/monaco-editor.d.ts +11 -0
  109. package/dist/monacoeditorwork/css.worker.bundle.js +54264 -0
  110. package/dist/monacoeditorwork/editor.worker.bundle.js +14317 -0
  111. package/dist/monacoeditorwork/html.worker.bundle.js +30449 -0
  112. package/dist/monacoeditorwork/json.worker.bundle.js +22085 -0
  113. package/dist/monacoeditorwork/ts.worker.bundle.js +225552 -0
  114. package/dist/net-intercept.d.ts +2 -0
  115. package/dist/network-hook.d.ts +1 -0
  116. package/dist/plugin.d.ts +20 -0
  117. package/dist/policy.d.ts +58 -0
  118. package/dist/sdk.d.ts +61 -0
  119. package/dist/tab-manager.d.ts +11 -0
  120. package/dist/templates.d.ts +46 -0
  121. package/dist/terminal.d.ts +19 -0
  122. package/dist/types.d.ts +109 -0
  123. package/dist/ui.d.ts +81 -0
  124. package/dist/workspace.d.ts +16 -0
  125. package/index.html +159 -0
  126. package/logo.png +0 -0
  127. package/package.json +31 -0
  128. package/public/favicon.png +0 -0
  129. package/public/logo-sm.png +0 -0
  130. package/public/logo.png +0 -0
  131. package/src/audit.ts +196 -0
  132. package/src/container.ts +723 -0
  133. package/src/event-emitter.ts +28 -0
  134. package/src/git-service.ts +202 -0
  135. package/src/main.ts +9 -0
  136. package/src/monaco-editor.ts +111 -0
  137. package/src/net-intercept.ts +74 -0
  138. package/src/network-hook.ts +248 -0
  139. package/src/plugin.ts +63 -0
  140. package/src/policy.ts +403 -0
  141. package/src/sdk.ts +355 -0
  142. package/src/style.css +432 -0
  143. package/src/tab-manager.ts +30 -0
  144. package/src/templates.ts +271 -0
  145. package/src/terminal.ts +78 -0
  146. package/src/types.ts +113 -0
  147. package/src/ui.ts +1266 -0
  148. package/src/workspace.ts +107 -0
  149. package/tsconfig.json +20 -0
  150. package/vite.config.ts +52 -0
@@ -0,0 +1,723 @@
1
+ import { WebContainer, type WebContainerProcess } from '@webcontainer/api';
2
+ import type { TerminalManager } from './terminal.js';
3
+ import { buildWorkspaceFiles, buildContainerPackageJson, GIT_STUB_JS } from './workspace.js';
4
+ import { AuditLog, type AuditSource } from './audit.js';
5
+ import { NETWORK_HOOK_CJS } from './network-hook.js';
6
+ import { PolicyEngine, PolicyDeniedError, type PolicyAction } from './policy.js';
7
+ import { GitService, type GitFile } from './git-service.js';
8
+ import type { AgentConfig } from './types.js';
9
+
10
+ export type ContainerStatus = 'booting' | 'installing' | 'ready' | 'error';
11
+
12
+ export interface ContainerEnv {
13
+ provider: string;
14
+ model: string;
15
+ envVars: Record<string, string>;
16
+ }
17
+
18
+ export class ContainerManager {
19
+ private wc: WebContainer | null = null;
20
+ private shellProcess: WebContainerProcess | null = null;
21
+ private shellWriter: WritableStreamDefaultWriter<string> | null = null;
22
+ private _status: ContainerStatus = 'booting';
23
+ private onStatusChange?: (s: ContainerStatus) => void;
24
+
25
+ // Stored after configureEnv() so startShell() can inject them directly
26
+ private apiEnvVars: Record<string, string> = {};
27
+ private serverUrls = new Map<number, string>();
28
+ private serverListeners: Array<(port: number, url: string) => void> = [];
29
+ private fileChangeListeners: Array<(path: string) => void> = [];
30
+ private audit: AuditLog | null = null;
31
+ private policy: PolicyEngine | null = null;
32
+ private activeProcessCount = 0;
33
+ private outputLineBuf = '';
34
+ private outputFlushTimer: ReturnType<typeof setTimeout> | null = null;
35
+ private gitService: GitService | null = null;
36
+
37
+ get status(): ContainerStatus { return this._status; }
38
+
39
+ setAuditLog(a: AuditLog): void { this.audit = a; }
40
+ setPolicy(p: PolicyEngine): void { this.policy = p; }
41
+
42
+ private enforcePolicy(action: PolicyAction, subject: string, meta?: Record<string, unknown>): void {
43
+ if (!this.policy) return;
44
+ try {
45
+ this.policy.enforce(action, subject, meta);
46
+ } catch (e) {
47
+ if (e instanceof PolicyDeniedError) {
48
+ this.audit?.log('policy.deny', `${e.action}: ${e.subject}`, { rule: e.rule }, { source: 'policy', level: 'warn' });
49
+ }
50
+ throw e;
51
+ }
52
+ }
53
+
54
+ setStatusListener(fn: (s: ContainerStatus) => void): void {
55
+ this.onStatusChange = fn;
56
+ }
57
+
58
+ private setStatus(s: ContainerStatus): void {
59
+ this._status = s;
60
+ this.audit?.log('status.change', s, undefined, { source: 'boot' });
61
+ this.onStatusChange?.(s);
62
+ }
63
+
64
+ /**
65
+ * Process an output chunk: strip __NET_AUDIT__ markers and log them,
66
+ * pass clean output to terminal and audit stdout buffer.
67
+ */
68
+ private scheduleFlush(terminal: TerminalManager, _source: AuditSource): void {
69
+ if (this.outputFlushTimer) return; // already scheduled
70
+ this.outputFlushTimer = setTimeout(() => {
71
+ this.outputFlushTimer = null;
72
+ if (this.outputLineBuf.length === 0) return;
73
+ // No __NET_AUDIT__ marker possible in a partial line (no newline) — safe to write directly
74
+ terminal.write(this.outputLineBuf);
75
+ this.audit?.logStdout(this.outputLineBuf);
76
+ this.outputLineBuf = '';
77
+ }, 30);
78
+ }
79
+
80
+ private processOutputChunk(
81
+ chunk: string,
82
+ terminal: TerminalManager,
83
+ source: AuditSource,
84
+ ): void {
85
+ const buf = this.outputLineBuf + chunk;
86
+ const lastNewline = buf.lastIndexOf('\n');
87
+
88
+ // No complete line yet — buffer and schedule a flush
89
+ if (lastNewline === -1) {
90
+ this.outputLineBuf = buf;
91
+ this.scheduleFlush(terminal, source);
92
+ return;
93
+ }
94
+
95
+ // We have a complete line — cancel any pending flush
96
+ if (this.outputFlushTimer) {
97
+ clearTimeout(this.outputFlushTimer);
98
+ this.outputFlushTimer = null;
99
+ }
100
+
101
+ // Split into complete lines + remainder
102
+ const complete = buf.slice(0, lastNewline);
103
+ this.outputLineBuf = buf.slice(lastNewline + 1);
104
+
105
+ const lines = complete.split('\n');
106
+ const cleanLines: string[] = [];
107
+
108
+ const MARKER = '__NET_AUDIT__:';
109
+ for (const line of lines) {
110
+ const idx = line.indexOf(MARKER);
111
+ if (idx !== -1) {
112
+ const jsonStr = line.slice(idx + MARKER.length);
113
+ try {
114
+ const data = JSON.parse(jsonStr);
115
+ if (data.type === 'request' || data.type === 'request.body') {
116
+ this.audit?.log('net.request', data.url ?? '', {
117
+ origin: 'container',
118
+ method: data.method,
119
+ ...(data.headers ? { headers: data.headers } : {}),
120
+ ...(data.bodyPreview ? { bodyPreview: data.bodyPreview } : {}),
121
+ }, { source });
122
+ } else if (data.type === 'response') {
123
+ this.audit?.log('net.response', data.url ?? '', {
124
+ origin: 'container',
125
+ method: data.method,
126
+ ...(data.status != null ? { status: data.status } : {}),
127
+ ...(data.error ? { error: data.error } : {}),
128
+ ...(data.headers ? { headers: data.headers } : {}),
129
+ ...(data.durationMs != null ? { durationMs: data.durationMs } : {}),
130
+ }, { source });
131
+ }
132
+ } catch {
133
+ // Malformed marker — ignore
134
+ }
135
+ } else {
136
+ cleanLines.push(line);
137
+ }
138
+ }
139
+
140
+ const cleanOutput = cleanLines.join('\n') + '\n';
141
+ if (cleanLines.length > 0) {
142
+ terminal.write(cleanOutput);
143
+ this.audit?.logStdout(cleanOutput);
144
+ }
145
+ }
146
+
147
+ /** Boot the WebContainer and mount all workspace files. */
148
+ async boot(opts?: { workspace?: Record<string, string>; services?: Record<string, string> }): Promise<void> {
149
+ this.setStatus('booting');
150
+ this.wc = await WebContainer.boot();
151
+
152
+ this.wc.on('server-ready', (port: number, url: string) => {
153
+ if (this.policy) {
154
+ const result = this.policy.check('server.bind', String(port));
155
+ if (!result.allowed) {
156
+ this.audit?.log('policy.deny', `server.bind: ${port}`, { rule: result.rule }, { source: 'policy', level: 'warn' });
157
+ return; // skip URL storage and listeners
158
+ }
159
+ }
160
+ this.serverUrls.set(port, url);
161
+ this.audit?.log('server.ready', `port ${port}`, { port, url }, { source: 'system' });
162
+ for (const fn of this.serverListeners) fn(port, url);
163
+ });
164
+
165
+ await this.wc.mount({
166
+ 'package.json': { file: { contents: buildContainerPackageJson(opts?.services) } },
167
+ 'git-stub.js': { file: { contents: GIT_STUB_JS } },
168
+ 'network-hook.cjs': { file: { contents: NETWORK_HOOK_CJS } },
169
+ workspace: { directory: buildWorkspaceFiles(opts?.workspace) },
170
+ });
171
+
172
+ this.audit?.log('boot.mount', 'mounted workspace files', {
173
+ files: ['package.json', 'git-stub.js', 'network-hook.cjs', 'workspace/'],
174
+ }, { source: 'boot' });
175
+ }
176
+
177
+ /**
178
+ * Use Node.js to chmod git-stub.js and symlink it into node_modules/.bin/git.
179
+ * Node.js fs.chmod works regardless of mount permissions.
180
+ */
181
+ private async linkGitStub(): Promise<void> {
182
+ const script = [
183
+ "const fs = require('fs');",
184
+ "fs.chmodSync('git-stub.js', 0o755);",
185
+ "try { fs.unlinkSync('node_modules/.bin/git'); } catch {}",
186
+ "fs.symlinkSync('../../git-stub.js', 'node_modules/.bin/git');",
187
+ ].join('\n');
188
+ this.audit?.log('process.spawn', 'node -e <link-git-stub>', undefined, { source: 'boot' });
189
+ const proc = await this.wc!.spawn('node', ['-e', script]);
190
+ proc.output.pipeTo(new WritableStream());
191
+ const exitCode = await proc.exit;
192
+ this.audit?.log('process.exit', `link-git-stub exited ${exitCode}`, { exitCode }, { source: 'boot' });
193
+ }
194
+
195
+ /** Run `npm install` inside the container. All output goes to terminal. */
196
+ async runNpmInstall(terminal: TerminalManager): Promise<void> {
197
+ if (!this.wc) throw new Error('Container not booted');
198
+ this.setStatus('installing');
199
+
200
+ this.enforcePolicy('process.spawn', 'npm install --legacy-peer-deps --ignore-scripts', { activeProcesses: this.activeProcessCount });
201
+ this.audit?.log('process.spawn', 'npm install --legacy-peer-deps --ignore-scripts', undefined, { source: 'boot' });
202
+ this.activeProcessCount++;
203
+ const proc = await this.wc.spawn('npm', [
204
+ 'install',
205
+ '--legacy-peer-deps',
206
+ '--ignore-scripts',
207
+ '--cache', '/tmp/npm-cache',
208
+ ], {
209
+ env: {
210
+ HOME: '/root',
211
+ PATH: '/tmp/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
212
+ NODE_OPTIONS: '--require ./network-hook.cjs',
213
+ },
214
+ });
215
+
216
+ const outputChunks: string[] = [];
217
+ const reader = proc.output.getReader();
218
+ const decoder = new TextDecoder();
219
+
220
+ (async () => {
221
+ while (true) {
222
+ const { done, value } = await reader.read();
223
+ if (done) break;
224
+ const text = typeof value === 'string' ? value : decoder.decode(value);
225
+ outputChunks.push(text);
226
+ this.processOutputChunk(text, terminal, 'boot');
227
+ }
228
+ })();
229
+
230
+ const exitCode = await proc.exit;
231
+ this.activeProcessCount--;
232
+ this.audit?.log('process.exit', `npm install exited ${exitCode}`, { exitCode }, { source: 'boot', level: exitCode !== 0 ? 'error' : 'info' });
233
+ if (exitCode !== 0) {
234
+ this.setStatus('error');
235
+ const tail = outputChunks.join('').slice(-800);
236
+ terminal.write(`\r\n\x1b[31m[ClawLess] npm install failed (exit ${exitCode})\x1b[0m\r\n`);
237
+ throw new Error(`npm install failed (exit ${exitCode}):\n${tail}`);
238
+ }
239
+
240
+ // Link git-stub.js into node_modules/.bin/git with proper execute bit
241
+ await this.linkGitStub();
242
+ }
243
+
244
+ /**
245
+ * Store API key and patch agent.yaml with the chosen model.
246
+ * The key is injected directly into the shell env when startShell() is called.
247
+ */
248
+ async configureEnv(env: ContainerEnv): Promise<void> {
249
+ if (!this.wc) throw new Error('Container not booted');
250
+ this.enforcePolicy('file.write', 'workspace/.env');
251
+
252
+ // Use all user-supplied env vars directly
253
+ this.apiEnvVars = { ...env.envVars };
254
+
255
+ // If we have an OpenAI key, fetch an ephemeral realtime token from the
256
+ // browser side (WebContainer's fetch drops Authorization headers)
257
+ const openaiKey = this.apiEnvVars['OPENAI_API_KEY'];
258
+ if (openaiKey) {
259
+ try {
260
+ const resp = await fetch('https://api.openai.com/v1/realtime/sessions', {
261
+ method: 'POST',
262
+ headers: {
263
+ 'Authorization': `Bearer ${openaiKey}`,
264
+ 'Content-Type': 'application/json',
265
+ },
266
+ body: JSON.stringify({ model: 'gpt-4o-realtime-preview' }),
267
+ });
268
+ if (resp.ok) {
269
+ const session = await resp.json() as { client_secret?: { value?: string } };
270
+ const ephemeralKey = session.client_secret?.value;
271
+ if (ephemeralKey) {
272
+ this.apiEnvVars['OPENAI_EPHEMERAL_KEY'] = ephemeralKey;
273
+ console.log('[ClawLess] Fetched ephemeral realtime token from browser');
274
+ }
275
+ }
276
+ } catch {
277
+ // Non-fatal — gitclaw will try its own auth
278
+ }
279
+ }
280
+
281
+ // Log env config with masked keys
282
+ const maskedVars: Record<string, string> = {};
283
+ for (const [k, v] of Object.entries(env.envVars)) {
284
+ maskedVars[k] = AuditLog.maskKey(v);
285
+ }
286
+ this.audit?.log('env.configure', `provider=${env.provider} model=${env.model}`, {
287
+ provider: env.provider,
288
+ model: env.model,
289
+ vars: maskedVars,
290
+ }, { source: 'user' });
291
+
292
+ // Write .env file so gitclaw (and its voice server) can read keys
293
+ const envLines: string[] = [];
294
+ for (const [key, val] of Object.entries(this.apiEnvVars)) {
295
+ envLines.push(`${key}=${val}`);
296
+ }
297
+ await this.wc.fs.writeFile('workspace/.env', envLines.join('\n') + '\n');
298
+ this.audit?.log('file.write', 'workspace/.env', { keys: Object.keys(this.apiEnvVars) }, { source: 'system' });
299
+
300
+ // Patch agent.yaml with the chosen model
301
+ try {
302
+ const yaml = await this.wc.fs.readFile('workspace/agent.yaml', 'utf-8');
303
+ this.audit?.log('file.read', 'workspace/agent.yaml', undefined, { source: 'system' });
304
+ const patched = yaml.replace(
305
+ /preferred:\s*"[^"]*"/,
306
+ `preferred: "${env.model}"`,
307
+ );
308
+ await this.wc.fs.writeFile('workspace/agent.yaml', patched);
309
+ this.audit?.log('file.write', 'workspace/agent.yaml', { action: 'patch-model', model: env.model }, { source: 'system' });
310
+ } catch {
311
+ // agent.yaml might be custom — leave it
312
+ }
313
+ }
314
+
315
+ /** Discover the container's home/project directory via $PWD. */
316
+ private async getHomeDir(): Promise<string> {
317
+ this.audit?.log('process.spawn', 'sh -c "echo $PWD"', undefined, { source: 'system' });
318
+ const proc = await this.wc!.spawn('sh', ['-c', 'echo $PWD']);
319
+ const reader = proc.output.getReader();
320
+ const decoder = new TextDecoder();
321
+ let out = '';
322
+ while (true) {
323
+ const { done, value } = await reader.read();
324
+ if (done) break;
325
+ out += typeof value === 'string' ? value : decoder.decode(value);
326
+ }
327
+ await proc.exit;
328
+ return out.trim();
329
+ }
330
+
331
+ /**
332
+ * Spawn gitclaw DIRECTLY with a PTY — not inside jsh.
333
+ * jsh doesn't forward the PTY to child processes, breaking interactive REPLs.
334
+ */
335
+ async startGitclaw(terminal: TerminalManager): Promise<void> {
336
+ if (!this.wc) throw new Error('Container not booted');
337
+ this.setStatus('ready');
338
+
339
+ const { cols, rows } = terminal.dimensions;
340
+ const homeDir = await this.getHomeDir();
341
+
342
+ const spawnCmd = `node ${homeDir}/node_modules/gitclaw/dist/index.js --dir ${homeDir}/workspace`;
343
+ this.enforcePolicy('process.spawn', spawnCmd, { activeProcesses: this.activeProcessCount });
344
+ this.audit?.log('process.spawn', spawnCmd, undefined, { source: 'agent' });
345
+ this.activeProcessCount++;
346
+
347
+ this.shellProcess = await this.wc.spawn(
348
+ 'node',
349
+ [`${homeDir}/node_modules/gitclaw/dist/index.js`, '--dir', `${homeDir}/workspace`],
350
+ {
351
+ terminal: { cols, rows },
352
+ env: {
353
+ ...this.apiEnvVars,
354
+ PATH: `${homeDir}/node_modules/.bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin`,
355
+ HOME: homeDir,
356
+ NODE_OPTIONS: `--require ${homeDir}/network-hook.cjs`,
357
+ },
358
+ },
359
+ );
360
+
361
+ // Wire output → terminal (with audit + network marker parsing)
362
+ this.shellProcess.output.pipeTo(
363
+ new WritableStream({
364
+ write: (chunk) => {
365
+ this.processOutputChunk(chunk, terminal, 'agent');
366
+ },
367
+ }),
368
+ );
369
+
370
+ // Wire keystrokes directly to gitclaw stdin (no jsh in between)
371
+ this.shellWriter = this.shellProcess.input.getWriter();
372
+ terminal.onData((data) => {
373
+ this.shellWriter?.write(data);
374
+ this.audit?.logStdin(data);
375
+ });
376
+
377
+ window.addEventListener('resize', () => this.resizeShell(terminal));
378
+
379
+ // Restart gitclaw when it exits (e.g. user types /quit)
380
+ this.shellProcess.exit.then((code) => {
381
+ this.activeProcessCount--;
382
+ this.audit?.log('process.exit', `gitclaw exited`, { exitCode: code }, { source: 'agent' });
383
+ terminal.write('\r\n\x1b[90m[ClawLess] gitclaw exited. Restarting in 2s…\x1b[0m\r\n');
384
+ setTimeout(() => this.startGitclaw(terminal), 2000);
385
+ });
386
+ }
387
+
388
+ /**
389
+ * Start a raw jsh shell for file exploration / debugging.
390
+ * Note: interactive Node.js REPLs won't work inside jsh — use startGitclaw() instead.
391
+ */
392
+ async startShell(terminal: TerminalManager): Promise<void> {
393
+ if (!this.wc) throw new Error('Container not booted');
394
+
395
+ const { cols, rows } = terminal.dimensions;
396
+ const homeDir = await this.getHomeDir();
397
+
398
+ this.enforcePolicy('process.spawn', '/bin/jsh --osc', { activeProcesses: this.activeProcessCount });
399
+ this.audit?.log('process.spawn', '/bin/jsh --osc', undefined, { source: 'user' });
400
+ this.activeProcessCount++;
401
+
402
+ this.shellProcess = await this.wc.spawn('/bin/jsh', ['--osc'], {
403
+ terminal: { cols, rows },
404
+ env: {
405
+ ...this.apiEnvVars,
406
+ PATH: `${homeDir}/node_modules/.bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin`,
407
+ HOME: homeDir,
408
+ NODE_OPTIONS: `--require ${homeDir}/network-hook.cjs`,
409
+ },
410
+ });
411
+
412
+ this.shellProcess.output.pipeTo(
413
+ new WritableStream({
414
+ write: (chunk) => {
415
+ this.processOutputChunk(chunk, terminal, 'user');
416
+ },
417
+ }),
418
+ );
419
+
420
+ this.shellWriter = this.shellProcess.input.getWriter();
421
+ terminal.onData((data) => {
422
+ this.shellWriter?.write(data);
423
+ this.audit?.logStdin(data);
424
+ });
425
+
426
+ await this.shellWriter.write('cd workspace\nclear\n');
427
+ window.addEventListener('resize', () => this.resizeShell(terminal));
428
+ }
429
+
430
+ private resizeShell(terminal: TerminalManager): void {
431
+ if (!this.shellProcess) return;
432
+ const { cols, rows } = terminal.dimensions;
433
+ this.shellProcess.resize({ cols, rows });
434
+ }
435
+
436
+ async sendToShell(command: string): Promise<void> {
437
+ await this.shellWriter?.write(command);
438
+ }
439
+
440
+ /** Get the URL for a server running on a given port inside the container. */
441
+ getServerUrl(port: number): Promise<string> {
442
+ const existing = this.serverUrls.get(port);
443
+ if (existing) return Promise.resolve(existing);
444
+ return new Promise((resolve) => {
445
+ const listener = (p: number, url: string) => {
446
+ if (p === port) {
447
+ this.serverListeners = this.serverListeners.filter(l => l !== listener);
448
+ resolve(url);
449
+ }
450
+ };
451
+ this.serverListeners.push(listener);
452
+ });
453
+ }
454
+
455
+ async listWorkspaceFiles(dir = 'workspace'): Promise<string[]> {
456
+ if (!this.wc) return [];
457
+ try {
458
+ return await recursiveList(this.wc, dir, dir);
459
+ } catch {
460
+ return [];
461
+ }
462
+ }
463
+
464
+ async readFile(path: string): Promise<string> {
465
+ if (!this.wc) throw new Error('Container not booted');
466
+ this.enforcePolicy('file.read', path);
467
+ this.audit?.log('file.read', path, undefined, { source: 'user' });
468
+ return this.wc.fs.readFile(path, 'utf-8');
469
+ }
470
+
471
+ /** Read a file as raw bytes (for binary download). */
472
+ async readFileBuffer(path: string): Promise<Uint8Array> {
473
+ if (!this.wc) throw new Error('Container not booted');
474
+ this.enforcePolicy('file.read', path);
475
+ this.audit?.log('file.read', path, { binary: true }, { source: 'user' });
476
+ return this.wc.fs.readFile(path);
477
+ }
478
+
479
+ async writeFile(path: string, contents: string): Promise<void> {
480
+ if (!this.wc) throw new Error('Container not booted');
481
+ this.enforcePolicy('file.write', path, { size: contents.length });
482
+ this.audit?.log('file.write', path, { length: contents.length }, { source: 'user' });
483
+ await this.wc.fs.writeFile(path, contents);
484
+ for (const fn of this.fileChangeListeners) fn(path);
485
+ }
486
+
487
+ /** Register a callback fired whenever a file is written. */
488
+ onFileChange(fn: (path: string) => void): void {
489
+ this.fileChangeListeners.push(fn);
490
+ }
491
+
492
+ /** Clone a GitHub repo into /workspace via the GitHub API. */
493
+ async cloneRepo(url: string, token: string): Promise<void> {
494
+ if (!this.wc) throw new Error('Container not booted');
495
+ const { owner, repo } = GitService.parseRepoUrl(url);
496
+ this.enforcePolicy('git.clone', `${owner}/${repo}`);
497
+ this.audit?.log('git.clone', `${owner}/${repo}`, { url }, { source: 'user' });
498
+
499
+ const svc = new GitService(token, owner, repo);
500
+ await svc.detectDefaultBranch();
501
+ const files = await svc.fetchRepoTree();
502
+
503
+ // Write files to /workspace
504
+ for (const file of files) {
505
+ const fullPath = `workspace/${file.path}`;
506
+ // Ensure parent directories exist
507
+ const parts = fullPath.split('/');
508
+ for (let i = 1; i < parts.length - 1; i++) {
509
+ const dir = parts.slice(0, i + 1).join('/');
510
+ try { await this.wc.fs.mkdir(dir); } catch { /* exists */ }
511
+ }
512
+ await this.wc.fs.writeFile(fullPath, file.content);
513
+ }
514
+
515
+ this.gitService = svc;
516
+ this.audit?.log('git.clone', `Cloned ${files.length} files from ${owner}/${repo}@${svc.repoBranch}`, {
517
+ owner, repo, branch: svc.repoBranch, fileCount: files.length,
518
+ }, { source: 'system' });
519
+ }
520
+
521
+ /** Read all workspace files and push changes to GitHub. */
522
+ async syncToRepo(message?: string): Promise<string> {
523
+ if (!this.wc) throw new Error('Container not booted');
524
+ if (!this.gitService) throw new Error('No repository cloned');
525
+
526
+ const owner = this.gitService.repoOwner;
527
+ const repo = this.gitService.repoName;
528
+ this.enforcePolicy('git.push', `${owner}/${repo}`);
529
+
530
+ // Collect all workspace files (excluding ignored paths)
531
+ const IGNORED = /^(node_modules\/|\.git\/|\.env$)/;
532
+ const allPaths = await this.listWorkspaceFiles();
533
+ const files: GitFile[] = [];
534
+
535
+ for (const relPath of allPaths) {
536
+ if (relPath.endsWith('/')) continue; // skip directories
537
+ if (IGNORED.test(relPath)) continue;
538
+ try {
539
+ const content = await this.wc.fs.readFile(`workspace/${relPath}`, 'utf-8');
540
+ files.push({ path: relPath, content });
541
+ } catch { /* skip unreadable */ }
542
+ }
543
+
544
+ const commitMsg = message ?? `Sync from ClawLess at ${new Date().toISOString()}`;
545
+ const sha = await this.gitService.pushChanges(files, commitMsg);
546
+
547
+ this.audit?.log('git.push', `Pushed ${files.length} files to ${owner}/${repo}`, {
548
+ owner, repo, sha, fileCount: files.length,
549
+ }, { source: 'user' });
550
+
551
+ return sha;
552
+ }
553
+
554
+ /** Check if a repo has been cloned. */
555
+ get hasClonedRepo(): boolean { return this.gitService !== null; }
556
+
557
+ /** Expose the raw WebContainer instance for direct user access. */
558
+ getWebContainer(): WebContainer | null { return this.wc; }
559
+
560
+ /** Launch a generic agent process with PTY, similar to startGitclaw(). */
561
+ async startAgent(config: AgentConfig, terminal: TerminalManager): Promise<void> {
562
+ if (!this.wc) throw new Error('Container not booted');
563
+ this.setStatus('ready');
564
+
565
+ const { cols, rows } = terminal.dimensions;
566
+ const homeDir = await this.getHomeDir();
567
+
568
+ const entry = `${homeDir}/node_modules/${config.package}/${config.entry}`;
569
+ const args = config.args?.map(a => a.replace('<home>', homeDir)) ?? [];
570
+ const spawnCmd = `node ${entry} ${args.join(' ')}`;
571
+
572
+ this.enforcePolicy('process.spawn', spawnCmd, { activeProcesses: this.activeProcessCount });
573
+ this.audit?.log('process.spawn', spawnCmd, undefined, { source: 'agent' });
574
+ this.activeProcessCount++;
575
+
576
+ this.shellProcess = await this.wc.spawn('node', [entry, ...args], {
577
+ terminal: { cols, rows },
578
+ env: {
579
+ ...this.apiEnvVars,
580
+ ...config.env,
581
+ PATH: `${homeDir}/node_modules/.bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin`,
582
+ HOME: homeDir,
583
+ NODE_OPTIONS: `--require ${homeDir}/network-hook.cjs`,
584
+ },
585
+ });
586
+
587
+ this.shellProcess.output.pipeTo(
588
+ new WritableStream({
589
+ write: (chunk) => {
590
+ this.processOutputChunk(chunk, terminal, 'agent');
591
+ },
592
+ }),
593
+ );
594
+
595
+ this.shellWriter = this.shellProcess.input.getWriter();
596
+ terminal.onData((data) => {
597
+ this.shellWriter?.write(data);
598
+ this.audit?.logStdin(data);
599
+ });
600
+
601
+ window.addEventListener('resize', () => this.resizeShell(terminal));
602
+
603
+ this.shellProcess.exit.then((code) => {
604
+ this.activeProcessCount--;
605
+ this.audit?.log('process.exit', `agent exited`, { exitCode: code }, { source: 'agent' });
606
+ terminal.write(`\r\n\x1b[90m[ClawLess] agent exited. Restarting in 2s…\x1b[0m\r\n`);
607
+ setTimeout(() => this.startAgent(config, terminal), 2000);
608
+ });
609
+ }
610
+
611
+ /** Run an arbitrary startup script inside the container shell. */
612
+ async runStartupScript(script: string, terminal: TerminalManager): Promise<void> {
613
+ if (!this.wc) throw new Error('Container not booted');
614
+ const homeDir = await this.getHomeDir();
615
+
616
+ this.enforcePolicy('process.spawn', `sh -c <startup-script>`, { activeProcesses: this.activeProcessCount });
617
+ this.audit?.log('process.spawn', 'startup script', { script: script.slice(0, 200) }, { source: 'boot' });
618
+ this.activeProcessCount++;
619
+
620
+ const proc = await this.wc.spawn('sh', ['-c', `cd workspace && ${script}`], {
621
+ env: {
622
+ ...this.apiEnvVars,
623
+ PATH: `${homeDir}/node_modules/.bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin`,
624
+ HOME: homeDir,
625
+ },
626
+ });
627
+
628
+ proc.output.pipeTo(
629
+ new WritableStream({
630
+ write: (chunk) => {
631
+ this.processOutputChunk(chunk, terminal, 'boot');
632
+ },
633
+ }),
634
+ );
635
+
636
+ const exitCode = await proc.exit;
637
+ this.activeProcessCount--;
638
+ this.audit?.log('process.exit', `startup script exited ${exitCode}`, { exitCode }, { source: 'boot' });
639
+ if (exitCode !== 0) {
640
+ throw new Error(`Startup script failed (exit ${exitCode})`);
641
+ }
642
+ }
643
+
644
+ /** Execute a command and return stdout as a string. */
645
+ async exec(cmd: string): Promise<string> {
646
+ if (!this.wc) throw new Error('Container not booted');
647
+
648
+ this.enforcePolicy('process.spawn', cmd, { activeProcesses: this.activeProcessCount });
649
+ this.audit?.log('process.spawn', cmd, undefined, { source: 'user' });
650
+ this.activeProcessCount++;
651
+
652
+ const proc = await this.wc.spawn('sh', ['-c', cmd]);
653
+ const reader = proc.output.getReader();
654
+ const decoder = new TextDecoder();
655
+ let output = '';
656
+
657
+ while (true) {
658
+ const { done, value } = await reader.read();
659
+ if (done) break;
660
+ output += typeof value === 'string' ? value : decoder.decode(value);
661
+ }
662
+
663
+ const exitCode = await proc.exit;
664
+ this.activeProcessCount--;
665
+ this.audit?.log('process.exit', `exec exited ${exitCode}`, { exitCode, cmd }, { source: 'user' });
666
+
667
+ return output.trimEnd();
668
+ }
669
+
670
+ /** Create a directory inside the container. */
671
+ async mkdir(path: string): Promise<void> {
672
+ if (!this.wc) throw new Error('Container not booted');
673
+ this.enforcePolicy('file.write', path);
674
+ await this.wc.fs.mkdir(path, { recursive: true });
675
+ this.audit?.log('file.write', `mkdir ${path}`, undefined, { source: 'user' });
676
+ }
677
+
678
+ /** Remove a file inside the container. */
679
+ async remove(path: string): Promise<void> {
680
+ if (!this.wc) throw new Error('Container not booted');
681
+ this.enforcePolicy('file.write', path);
682
+ await this.wc.fs.rm(path, { recursive: true });
683
+ this.audit?.log('file.write', `remove ${path}`, undefined, { source: 'user' });
684
+ }
685
+
686
+ /** Start watching the workspace directory for file-system events. */
687
+ startWatching(): void {
688
+ if (!this.wc) return;
689
+ this.wc.fs.watch('/workspace', { recursive: true }, (_event, filename) => {
690
+ if (filename) {
691
+ const path = `workspace/${filename}`;
692
+ for (const fn of this.fileChangeListeners) fn(path);
693
+ }
694
+ });
695
+ }
696
+ }
697
+
698
+ // ─── Helpers ────────────────────────────────────────────────────────────────
699
+
700
+ async function recursiveList(
701
+ wc: WebContainer,
702
+ absDir: string,
703
+ rootDir: string,
704
+ ): Promise<string[]> {
705
+ const entries = await wc.fs.readdir(absDir, { withFileTypes: true });
706
+ const results: string[] = [];
707
+
708
+ for (const entry of entries) {
709
+ if (entry.name === 'node_modules') continue;
710
+ const abs = `${absDir}/${entry.name}`;
711
+ const rel = abs.replace(rootDir + '/', '');
712
+
713
+ if (entry.isDirectory()) {
714
+ results.push(rel + '/');
715
+ const children = await recursiveList(wc, abs, rootDir);
716
+ results.push(...children);
717
+ } else {
718
+ results.push(rel);
719
+ }
720
+ }
721
+
722
+ return results;
723
+ }