@playcraft/cli 0.1.0 → 0.1.1

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.
@@ -1,3 +1,4 @@
1
+ import { projectNativeThread } from '@playcraft/common/cloud-workspace/runtime';
1
2
  import { collectedEvent } from './native-adapter.js';
2
3
  /** In-process Codex stand-in. Not the App Server; used to prove the operation/event loop. */
3
4
  export function createLoopbackNativeAdapter() {
@@ -22,7 +23,7 @@ export function createLoopbackNativeAdapter() {
22
23
  },
23
24
  async startThread(input) {
24
25
  const threadId = `thread_${input.sessionId}`;
25
- threads.set(threadId, { sessionId: input.sessionId });
26
+ threads.set(threadId, { sessionId: input.sessionId, items: [] });
26
27
  return { threadId };
27
28
  },
28
29
  async startTurn(input) {
@@ -40,6 +41,18 @@ export function createLoopbackNativeAdapter() {
40
41
  turnStarts += 1;
41
42
  const turnId = `turn_${input.sessionId}_${input.clientRequestId}`;
42
43
  thread.turnId = turnId;
44
+ thread.items.push({
45
+ type: 'userMessage',
46
+ id: `user_${turnId}`,
47
+ content: [{ type: 'text', text: input.text }],
48
+ });
49
+ if (!input.text.includes('[approval]')) {
50
+ thread.items.push({
51
+ type: 'agentMessage',
52
+ id: `agent_${turnId}`,
53
+ text: `loopback:${input.text}`,
54
+ });
55
+ }
43
56
  queueMicrotask(() => {
44
57
  emit(collectedEvent('notification', 'item/agentMessage/delta', {
45
58
  threadId: input.threadId,
@@ -104,7 +117,24 @@ export function createLoopbackNativeAdapter() {
104
117
  turn: { id: thread?.[1].turnId, status: 'completed' },
105
118
  }));
106
119
  });
107
- return { outcome: 'accepted', approvalId: input.approvalId, turnId: thread?.[1].turnId };
120
+ const found = thread?.[1];
121
+ if (found) {
122
+ found.items.push({
123
+ type: 'agentMessage',
124
+ id: `agent_${found.turnId ?? input.approvalId}`,
125
+ text: 'after-approval',
126
+ });
127
+ }
128
+ return { outcome: 'accepted', approvalId: input.approvalId, turnId: found?.turnId };
129
+ },
130
+ async readThread(input) {
131
+ const thread = threads.get(input.threadId);
132
+ if (!thread || thread.sessionId !== input.sessionId)
133
+ return undefined;
134
+ return projectNativeThread(input.threadId, {
135
+ id: input.threadId,
136
+ turns: [{ items: thread.items }],
137
+ });
108
138
  },
109
139
  };
110
140
  }
@@ -12,6 +12,7 @@ import { disposeTerminalHost, terminalHost } from './server/terminal-host.js';
12
12
  import { handleExecutionGroupCrash } from './server/execution-group.js';
13
13
  import { RuntimeProcessLock } from './server/lock.js';
14
14
  import { probeGitBinary } from './workspaces/git.js';
15
+ import { WorkspaceGitCredentialBroker } from './workspaces/git-credential-broker.js';
15
16
  export async function startWorkspaceRuntime(config, options = {}) {
16
17
  process.env.PLAYCRAFT_SKIP_UPDATE_CHECK = '1';
17
18
  const negotiated = negotiateProtocol(config);
@@ -25,6 +26,7 @@ export async function startWorkspaceRuntime(config, options = {}) {
25
26
  const lock = new RuntimeProcessLock(config.stateDir);
26
27
  lock.acquire();
27
28
  let journal;
29
+ let gitCredentials;
28
30
  try {
29
31
  journal = new RuntimeJournal(config.stateDir);
30
32
  await journal.load();
@@ -45,6 +47,10 @@ export async function startWorkspaceRuntime(config, options = {}) {
45
47
  generation: config.generation,
46
48
  runtimeEpoch: state.runtimeEpoch,
47
49
  });
50
+ if (config.workspaceMode === 'multi') {
51
+ gitCredentials = state.gitCredentials = new WorkspaceGitCredentialBroker(state);
52
+ await gitCredentials.start();
53
+ }
48
54
  attachNativeCollector(state);
49
55
  native?.onUnexpectedExit?.((event) => {
50
56
  void handleExecutionGroupCrash(state, event.pid);
@@ -79,6 +85,7 @@ export async function startWorkspaceRuntime(config, options = {}) {
79
85
  state,
80
86
  url: `http://${formatHost(config.listen.host)}:${port}`,
81
87
  async close() {
88
+ await gitCredentials?.close();
82
89
  disposeDesktopHost(state);
83
90
  disposeTerminalHost(state);
84
91
  await closeServer(server);
@@ -94,6 +101,7 @@ export async function startWorkspaceRuntime(config, options = {}) {
94
101
  };
95
102
  }
96
103
  catch (error) {
104
+ await gitCredentials?.close().catch(() => undefined);
97
105
  await journal?.close().catch(() => undefined);
98
106
  lock.release();
99
107
  throw error;
@@ -469,6 +469,7 @@ async function executeRestore(state, scopeKey, operation, payload) {
469
469
  }
470
470
  async function executeDestroy(state, scopeKey, operation) {
471
471
  await state.journal.markDestroyed();
472
+ await state.gitCredentials?.destroy();
472
473
  disposeTerminalHost(state);
473
474
  try {
474
475
  await state.native?.close?.();
@@ -1,7 +1,7 @@
1
1
  import { readObservedFile, withObservedFile } from '../workspaces/revisions.js';
2
2
  import { registerWorkspace, revisionScope, workspaceRoot, WorkspaceContextError } from '../workspaces/registry.js';
3
3
  import { randomUUID } from 'node:crypto';
4
- import { workspaceRegistrationRequestSchema, HTTP_STATUS_BY_ERROR, assertLeaseFence, buildHandshakeDocument, capability, findUntrustedRuntimeFields, hashOperationPayload, instanceRebuildRequestSchema, sourceAckSchema, operationAcceptRequestSchema, operationKindSchema, operationScopeKey, resolveIdempotentAccept, runtimeError, unavailableRuntimeCapabilities, } from '@playcraft/common/cloud-workspace/runtime';
4
+ import { workspaceRegistrationRequestSchema, HTTP_STATUS_BY_ERROR, assertLeaseFence, buildHandshakeDocument, capability, findUntrustedRuntimeFields, hashOperationPayload, instanceRebuildRequestSchema, sourceAckSchema, operationAcceptRequestSchema, operationKindSchema, operationScopeKey, resolveIdempotentAccept, nativeHistoryReadSchema, runtimeError, unavailableRuntimeCapabilities, } from '@playcraft/common/cloud-workspace/runtime';
5
5
  import { controlTokenMatches, extractControlToken } from './auth.js';
6
6
  import { canExecuteKind, executeOperation } from './dispatch.js';
7
7
  import { InvalidWorkspacePathError, listWorkspaceFiles, } from '../workspaces/files.js';
@@ -64,6 +64,11 @@ export async function handleRuntimeRequest(state, request, response) {
64
64
  writeJson(response, 200, handshake(state));
65
65
  return;
66
66
  }
67
+ const historyPath = sessionHistoryPath(url.pathname);
68
+ if (request.method === 'GET' && historyPath) {
69
+ await handleSessionHistory(state, response, historyPath.workspaceId, historyPath.sessionId);
70
+ return;
71
+ }
67
72
  if (isUnimplementedWorkbenchPath(url.pathname)) {
68
73
  if (writeDestroyed(state, response))
69
74
  return;
@@ -540,9 +545,67 @@ function unavailableReason(state, kind) {
540
545
  return 'capability_not_implemented';
541
546
  return 'adapter_not_started';
542
547
  }
548
+ function sessionHistoryPath(pathname) {
549
+ const match = pathname.match(/^\/runtime\/workspaces\/([^/]+)\/sessions\/([^/]+)\/history$/);
550
+ if (!match?.[1] || !match[2])
551
+ return undefined;
552
+ try {
553
+ return {
554
+ workspaceId: decodeURIComponent(match[1]),
555
+ sessionId: decodeURIComponent(match[2]),
556
+ };
557
+ }
558
+ catch {
559
+ return undefined;
560
+ }
561
+ }
543
562
  function isUnimplementedWorkbenchPath(pathname) {
544
563
  const parts = pathname.split('/').filter(Boolean);
545
- return parts[0] === 'runtime' && (parts.includes('previews') || parts.includes('history'));
564
+ return parts[0] === 'runtime' && parts.includes('previews');
565
+ }
566
+ async function handleSessionHistory(state, response, workspaceId, sessionId) {
567
+ if (writeDestroyed(state, response))
568
+ return;
569
+ const session = state.journal.getSession(sessionId);
570
+ if (!session || (session.workspaceId && session.workspaceId !== workspaceId)) {
571
+ writeJson(response, 404, { error: 'session_not_found' });
572
+ return;
573
+ }
574
+ if (state.config.workspaceMode === 'multi' && session.workspaceId !== workspaceId) {
575
+ writeJson(response, 404, { error: 'session_not_found' });
576
+ return;
577
+ }
578
+ const readThread = state.native?.readThread;
579
+ if (!readThread) {
580
+ writeError(response, runtimeError('capability_unavailable', 'native_history_unavailable'));
581
+ return;
582
+ }
583
+ let read;
584
+ try {
585
+ read = await readThread({ threadId: session.threadId, sessionId });
586
+ }
587
+ catch {
588
+ writeError(response, runtimeError('capability_unavailable', 'native_history_unavailable'));
589
+ return;
590
+ }
591
+ if (!read || read.threadId !== session.threadId) {
592
+ writeError(response, runtimeError('capability_unavailable', 'native_history_unavailable'));
593
+ return;
594
+ }
595
+ const body = nativeHistoryReadSchema.safeParse({
596
+ source: 'native',
597
+ sessionId,
598
+ workspaceId,
599
+ threadId: read.threadId,
600
+ ...(read.threadName ? { threadName: read.threadName } : {}),
601
+ truncated: read.truncated,
602
+ items: read.items,
603
+ });
604
+ if (!body.success) {
605
+ writeError(response, runtimeError('capability_unavailable', 'native_history_unavailable'));
606
+ return;
607
+ }
608
+ writeJson(response, 200, body.data);
546
609
  }
547
610
  async function readObservedParameters(state, workspaceId) {
548
611
  const root = await workspaceRoot(state, workspaceId);
@@ -0,0 +1,276 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { execFile } from 'node:child_process';
3
+ import { chmod, chown, lstat, unlink, open, mkdir, mkdtemp, writeFile, readFile, rename, rm } from 'node:fs/promises';
4
+ import { constants } from 'node:fs';
5
+ import { createServer } from 'node:http';
6
+ import { join } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { promisify } from 'node:util';
9
+ import { isLocalWorkspaceGitAuthMode, isLocalWorkspaceGitEnvironment, matchWorkspaceGitGatewayCloneUrl, workspacePreparationSchema } from '@playcraft/common/cloud-workspace/runtime';
10
+ import { WorkspaceGitDirectStore } from './git-direct-store.js';
11
+ const exec = promisify(execFile);
12
+ /** Only this control process sees the callback credential. Development processes use the local socket. */
13
+ export class WorkspaceGitCredentialBroker {
14
+ state;
15
+ socketPath;
16
+ server;
17
+ closed = false;
18
+ ready = false;
19
+ cache = new Map();
20
+ pending = new Map();
21
+ direct;
22
+ constructor(state) {
23
+ this.state = state;
24
+ this.direct = new WorkspaceGitDirectStore(state);
25
+ const id = createHash('sha256').update(state.config.stateDir).digest('hex').slice(0, 24);
26
+ this.socketPath = `/tmp/playcraft-git-${id}.sock`;
27
+ }
28
+ async start() {
29
+ await this.direct.sweep(this.state.journal.destroyed());
30
+ const old = await lstat(this.socketPath).catch(error => {
31
+ if (error.code !== 'ENOENT')
32
+ throw error;
33
+ return undefined;
34
+ });
35
+ if (old) {
36
+ if (!old.isSocket() || old.uid !== process.getuid?.())
37
+ throw new Error('git_broker_socket_conflict');
38
+ await unlink(this.socketPath);
39
+ }
40
+ const server = this.server = createServer({ maxHeaderSize: 2048, requestTimeout: 10_000 }, async (request, response) => {
41
+ try {
42
+ if (request.method !== 'POST' || request.url !== '/credential')
43
+ throw new Error();
44
+ const chunks = [];
45
+ let size = 0;
46
+ for await (const chunk of request) {
47
+ size += chunk.length;
48
+ if (size > 16_384)
49
+ throw new Error();
50
+ chunks.push(Buffer.from(chunk));
51
+ }
52
+ const result = await this.credential(JSON.parse(Buffer.concat(chunks).toString('utf8')));
53
+ response.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
54
+ response.end(JSON.stringify(result));
55
+ }
56
+ catch {
57
+ response.writeHead(403, { 'cache-control': 'no-store', connection: 'close' });
58
+ response.end();
59
+ }
60
+ });
61
+ server.maxConnections = 32;
62
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(this.socketPath, resolve); });
63
+ // The workspace group is the image's development group; other system users cannot connect.
64
+ const directory = await lstat(this.state.config.workspaceRoot);
65
+ await chown(this.socketPath, process.getuid(), directory.gid);
66
+ await chmod(this.socketPath, 0o660);
67
+ this.ready = true;
68
+ }
69
+ async close() {
70
+ this.closed = true;
71
+ this.cache.clear();
72
+ if (this.server?.listening) {
73
+ this.server.closeAllConnections();
74
+ await new Promise(resolve => this.server.close(() => resolve()));
75
+ await unlink(this.socketPath).catch(() => undefined);
76
+ }
77
+ }
78
+ async configure(binding, preparation) {
79
+ preparation = workspacePreparationSchema.parse(preparation);
80
+ const transport = preparation.transport;
81
+ const isDirect = isLocalWorkspaceGitAuthMode(preparation.authMode);
82
+ if (!isDirect && transport?.kind !== 'git-gateway-v2')
83
+ return;
84
+ if (isDirect) {
85
+ if (!isLocalWorkspaceGitEnvironment(this.state.config.environment) || (preparation.directAccess && preparation.directAccess.repositoryUrl !== binding.repositoryUrl))
86
+ throw new Error('git_access_denied');
87
+ }
88
+ else {
89
+ const matched = matchWorkspaceGitGatewayCloneUrl(transport.cloneUrl, this.state.config.gitGatewayOrigins, binding.repositoryUrl);
90
+ if (!matched.ok || matched.requestId !== `v2/${binding.workspaceId}`)
91
+ throw new Error('workspace_git_gateway_unavailable');
92
+ }
93
+ // Revocation and switching to gateway invalidate first; direct rotation keeps the old file until rename.
94
+ if (!isDirect)
95
+ await this.direct.remove(binding.workspaceId);
96
+ else if (preparation.directAccess === null)
97
+ await this.direct.put(binding, null);
98
+ this.cache.delete(binding.workspaceId);
99
+ const cloneUrl = isDirect ? binding.repositoryUrl : transport.cloneUrl;
100
+ const root = join(this.state.config.workspaceRoot, binding.workspaceId);
101
+ const helper = `!${quote(process.execPath)} ${quote(fileURLToPath(new URL('./git-credential-helper.js', import.meta.url)))} ${quote(this.socketPath)} ${quote(binding.workspaceId)}`;
102
+ await configureGitFile(root, this.state.config.stateDir, [
103
+ ...(!isDirect ? [['--replace-all', `url.${cloneUrl}.insteadOf`, binding.repositoryUrl]] : []),
104
+ ['--replace-all', `credential.${cloneUrl}.useHttpPath`, 'true'],
105
+ ['--replace-all', `credential.${cloneUrl}.helper`, ''],
106
+ ['--add', `credential.${cloneUrl}.helper`, helper],
107
+ ['--replace-all', `http.${cloneUrl}.followRedirects`, 'false'],
108
+ ['--replace-all', 'playcraft.gitAuthUrl', cloneUrl],
109
+ ], binding.repositoryUrl);
110
+ if (isDirect)
111
+ await this.direct.put(binding, preparation.directAccess);
112
+ this.cache.delete(binding.workspaceId);
113
+ }
114
+ async credential(input) {
115
+ if (!this.ready || this.closed || this.state.journal.destroyed() || !input || !['get', 'erase'].includes(input.action)
116
+ || typeof input.workspaceId !== 'string' || !/^[a-f0-9-]{36}$/.test(input.workspaceId)
117
+ || !['http', 'https'].includes(input.protocol) || typeof input.host !== 'string'
118
+ || typeof input.path !== 'string')
119
+ throw new Error('git_access_denied');
120
+ const binding = this.state.journal.getWorkspace(input.workspaceId);
121
+ if (!binding)
122
+ throw new Error('git_access_denied');
123
+ const cloneUrl = `${input.protocol}://${input.host}/${input.path}`;
124
+ const direct = await this.direct.get(binding);
125
+ if (direct) {
126
+ if (!direct.access || cloneUrl !== binding.repositoryUrl)
127
+ throw new Error('git_access_denied');
128
+ if (input.action === 'erase') {
129
+ if (input.password === direct.access.password)
130
+ await this.direct.put(binding, null, input.password);
131
+ return {};
132
+ }
133
+ return { username: direct.access.username, password: direct.access.password };
134
+ }
135
+ const matched = matchWorkspaceGitGatewayCloneUrl(cloneUrl, this.state.config.gitGatewayOrigins, binding.repositoryUrl);
136
+ if (!matched.ok || matched.requestId !== `v2/${binding.workspaceId}`)
137
+ throw new Error('git_access_denied');
138
+ if (input.action === 'erase') {
139
+ if (this.cache.get(input.workspaceId)?.preparation.gitAccess?.password === input.password)
140
+ this.cache.delete(input.workspaceId);
141
+ return {};
142
+ }
143
+ const key = JSON.stringify([binding, this.state.config.generation, this.state.runtimeEpoch]);
144
+ const cached = this.cache.get(input.workspaceId);
145
+ let preparation = cached?.key === key && Date.parse(cached.preparation.gitAccess.expiresAt) > Date.now() + 60_000
146
+ ? cached.preparation : undefined;
147
+ if (!preparation) {
148
+ const pendingKey = `${input.workspaceId}:${key}`;
149
+ let pending = this.pending.get(pendingKey);
150
+ if (!pending) {
151
+ pending = this.refresh(binding, key).finally(() => this.pending.delete(pendingKey));
152
+ this.pending.set(pendingKey, pending);
153
+ }
154
+ preparation = await pending;
155
+ }
156
+ if (await this.direct.get(binding) || preparation.transport?.cloneUrl !== cloneUrl || !preparation.gitAccess)
157
+ throw new Error('git_access_denied');
158
+ return { username: preparation.gitAccess.username, password: preparation.gitAccess.password };
159
+ }
160
+ async destroy() {
161
+ await this.close();
162
+ await this.direct.sweep(true);
163
+ }
164
+ async refresh(binding, key) {
165
+ const endpoint = new URL('/runtime/internal/git-credentials', this.state.config.callbackUrl);
166
+ if (endpoint.protocol !== 'https:' && !(endpoint.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(endpoint.hostname)))
167
+ throw new Error('git_broker_unavailable');
168
+ const epoch = this.state.runtimeEpoch;
169
+ const response = await fetch(endpoint, {
170
+ method: 'POST', redirect: 'error', signal: AbortSignal.timeout(10_000),
171
+ headers: { authorization: `Bearer ${this.state.config.controlToken}`, 'content-type': 'application/json' },
172
+ body: JSON.stringify({ runtimeId: this.state.config.runtimeId, generation: this.state.config.generation,
173
+ runtimeEpoch: epoch, workspaceId: binding.workspaceId }),
174
+ });
175
+ if (!response.ok) {
176
+ await response.body?.cancel();
177
+ throw new Error('git_broker_unavailable');
178
+ }
179
+ const preparation = workspacePreparationSchema.parse(await response.json());
180
+ const matched = preparation.transport && matchWorkspaceGitGatewayCloneUrl(preparation.transport.cloneUrl, this.state.config.gitGatewayOrigins, binding.repositoryUrl);
181
+ if (this.closed || this.state.journal.destroyed() || this.state.runtimeEpoch !== epoch
182
+ || JSON.stringify(this.state.journal.getWorkspace(binding.workspaceId)) !== JSON.stringify(binding)
183
+ || preparation.transport?.kind !== 'git-gateway-v2' || !matched?.ok || matched.requestId !== `v2/${binding.workspaceId}`
184
+ || !preparation.gitAccess || Date.parse(preparation.gitAccess.issuedAt) > Date.now()
185
+ || Date.parse(preparation.gitAccess.expiresAt) <= Date.now() + 10_000)
186
+ throw new Error('git_access_denied');
187
+ if (this.cache.size >= 128)
188
+ this.cache.clear();
189
+ this.cache.set(binding.workspaceId, { key, preparation });
190
+ return preparation;
191
+ }
192
+ }
193
+ function quote(value) { return `'${value.replaceAll("'", "'\\''")}'`; }
194
+ /** Pin directories in Linux sandboxes and replace config atomically without following user-owned symlinks. */
195
+ async function configureGitFile(root, stateDir, commands, repositoryUrl) {
196
+ const directoryFlags = constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW;
197
+ const directory = await open(root, directoryFlags);
198
+ const pinnedRoot = process.platform === 'linux' ? `/proc/self/fd/${directory.fd}` : root;
199
+ let gitDirectory;
200
+ let lock;
201
+ let temporary;
202
+ let lockPath;
203
+ try {
204
+ gitDirectory = await open(join(pinnedRoot, '.git'), directoryFlags);
205
+ const gitPath = process.platform === 'linux' ? `/proc/self/fd/${gitDirectory.fd}` : join(root, '.git');
206
+ const configPath = join(gitPath, 'config');
207
+ lock = await open(join(gitPath, 'config.lock'), constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o640);
208
+ lockPath = join(gitPath, 'config.lock');
209
+ const source = await open(configPath, constants.O_RDONLY | constants.O_NOFOLLOW);
210
+ let content;
211
+ try {
212
+ const stat = await source.stat();
213
+ if (!stat.isFile() || stat.size > 1024 * 1024 || stat.nlink !== 1)
214
+ throw new Error('invalid_git_config');
215
+ content = await source.readFile('utf8');
216
+ await lock.chmod(stat.mode & 0o666);
217
+ }
218
+ finally {
219
+ await source.close();
220
+ }
221
+ await mkdir(stateDir, { recursive: true, mode: 0o700 });
222
+ temporary = await mkdtemp(join(stateDir, 'git-config-'));
223
+ const staging = join(temporary, 'config');
224
+ await writeFile(staging, content, { mode: 0o600 });
225
+ await clearManagedGitConfig(staging, repositoryUrl);
226
+ for (const args of commands)
227
+ await exec('git', ['config', '--file', staging, '--no-includes', ...args], {
228
+ timeout: 5000, maxBuffer: 8192, env: { PATH: process.env.PATH, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null' },
229
+ });
230
+ await lock.writeFile(await readFile(staging));
231
+ await lock.sync();
232
+ await lock.close();
233
+ lock = undefined;
234
+ await rename(lockPath, configPath);
235
+ lockPath = undefined;
236
+ }
237
+ finally {
238
+ await lock?.close();
239
+ if (lockPath)
240
+ await unlink(lockPath).catch(() => undefined);
241
+ if (temporary)
242
+ await rm(temporary, { recursive: true, force: true });
243
+ await gitDirectory?.close();
244
+ await directory.close();
245
+ }
246
+ }
247
+ /** Only remove authentication/routing for this binding and previous PlayCraft endpoints. */
248
+ async function clearManagedGitConfig(file, repositoryUrl) {
249
+ const run = (...args) => exec('git', ['config', '--file', file, '--no-includes', ...args], {
250
+ timeout: 5000, maxBuffer: 1024 * 1024,
251
+ env: { PATH: process.env.PATH, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null' },
252
+ });
253
+ const entries = (await run('--null', '--list')).stdout.split('\0').filter(Boolean).map(entry => {
254
+ const newline = entry.indexOf('\n');
255
+ return [entry.slice(0, newline), entry.slice(newline + 1)];
256
+ });
257
+ const urls = new Set([repositoryUrl]);
258
+ const remove = new Set();
259
+ for (const [key, value] of entries) {
260
+ if (key === 'playcraft.gitauthurl')
261
+ urls.add(value);
262
+ const rewrite = /^url\.(.+)\.(?:insteadof|pushinsteadof)$/.exec(key);
263
+ if (rewrite && value === repositoryUrl) {
264
+ urls.add(rewrite[1]);
265
+ remove.add(key);
266
+ }
267
+ }
268
+ for (const [key] of entries) {
269
+ for (const url of urls) {
270
+ if (key.startsWith(`credential.${url}.`) || key.startsWith(`http.${url}.`))
271
+ remove.add(key);
272
+ }
273
+ }
274
+ for (const key of remove)
275
+ await run('--unset-all', key);
276
+ }
@@ -0,0 +1,66 @@
1
+ import { request } from 'node:http';
2
+ // Git invokes this executable directly. Keep standard output exclusively for the credential protocol.
3
+ const [socketPath, workspaceId, action] = process.argv.slice(2);
4
+ try {
5
+ if (action === 'get' || action === 'erase') {
6
+ const fields = {};
7
+ const chunks = [];
8
+ let size = 0;
9
+ for await (const chunk of process.stdin) {
10
+ size += chunk.length;
11
+ if (size > 16_384)
12
+ throw new Error();
13
+ chunks.push(Buffer.from(chunk));
14
+ }
15
+ for (const line of Buffer.concat(chunks).toString('utf8').split('\n')) {
16
+ if (!line)
17
+ break;
18
+ const separator = line.indexOf('=');
19
+ if (separator < 1)
20
+ throw new Error();
21
+ const key = line.slice(0, separator);
22
+ // Git may repeat extension fields such as capability[] and wwwauth[].
23
+ if (!['protocol', 'host', 'path', 'password'].includes(key))
24
+ continue;
25
+ if (key in fields)
26
+ throw new Error();
27
+ fields[key] = line.slice(separator + 1);
28
+ }
29
+ const credential = await new Promise((resolve, reject) => {
30
+ const client = request({ socketPath, path: '/credential', method: 'POST',
31
+ headers: { 'content-type': 'application/json' }, signal: AbortSignal.timeout(12_000) }, async (response) => {
32
+ try {
33
+ if (response.statusCode !== 200) {
34
+ response.resume();
35
+ throw new Error();
36
+ }
37
+ const parts = [];
38
+ let length = 0;
39
+ for await (const chunk of response) {
40
+ length += chunk.length;
41
+ if (length > 16_384)
42
+ throw new Error();
43
+ parts.push(Buffer.from(chunk));
44
+ }
45
+ resolve(JSON.parse(Buffer.concat(parts).toString('utf8')));
46
+ }
47
+ catch {
48
+ reject(new Error());
49
+ }
50
+ });
51
+ client.once('error', reject);
52
+ client.end(JSON.stringify({ workspaceId, action, protocol: fields.protocol, host: fields.host,
53
+ path: fields.path, ...(action === 'erase' ? { password: fields.password } : {}) }));
54
+ });
55
+ if (action === 'get') {
56
+ if (!credential.username || !credential.password || /[\r\n\0]/.test(credential.username + credential.password))
57
+ throw new Error();
58
+ process.stdout.write(`username=${credential.username}\npassword=${credential.password}\n\n`);
59
+ }
60
+ }
61
+ }
62
+ catch {
63
+ if (action === 'get')
64
+ process.stdout.write('quit=true\n\n');
65
+ process.exitCode = 1;
66
+ }