@tangle-network/agent-app 0.49.32 → 0.49.34

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 (71) hide show
  1. package/bin/tangle-agent/home-maintenance.py +280 -0
  2. package/bin/tangle-agent/home.mjs +166 -0
  3. package/bin/tangle-agent/provision.mjs +168 -0
  4. package/bin/tangle-agent/runtime.mjs +151 -0
  5. package/bin/tangle-agent.mjs +26 -0
  6. package/dist/VaultPane-2SJSP6TS.js +7 -0
  7. package/dist/app-auth/index.d.ts +14 -2
  8. package/dist/app-auth/index.js +3 -3
  9. package/dist/app-auth/index.js.map +1 -1
  10. package/dist/assistant/index.js +6 -6
  11. package/dist/chat-routes/index.js +4 -4
  12. package/dist/chat-store/index.js +2 -2
  13. package/dist/{chunk-FCUWRMAF.js → chunk-56SVXAIK.js} +14 -9
  14. package/dist/chunk-56SVXAIK.js.map +1 -0
  15. package/dist/chunk-6CV66YOB.js +99 -0
  16. package/dist/chunk-6CV66YOB.js.map +1 -0
  17. package/dist/{chunk-V4YGTOCS.js → chunk-MIFVOYZ7.js} +5 -5
  18. package/dist/{chunk-FXWNNHPC.js → chunk-MVIIH2EQ.js} +165 -72
  19. package/dist/chunk-MVIIH2EQ.js.map +1 -0
  20. package/dist/{chunk-4PZE7XAM.js → chunk-S3344RBY.js} +4 -4
  21. package/dist/{chunk-4S36HWE4.js → chunk-WAMJSZYB.js} +1 -1
  22. package/dist/{chunk-VIL6MHHQ.js → chunk-XTLYE2VO.js} +6 -6
  23. package/dist/design-canvas-react/index.js +1 -1
  24. package/dist/design-canvas-react/lazy.js +1 -1
  25. package/dist/hosted-agent/general-skill.d.ts +2 -0
  26. package/dist/hosted-agent/general.d.ts +53 -0
  27. package/dist/hosted-agent/index.d.ts +9 -1
  28. package/dist/hosted-agent/index.js +294 -7
  29. package/dist/hosted-agent/index.js.map +1 -1
  30. package/dist/hosted-agent/react/LineBilling.d.ts +3 -0
  31. package/dist/hosted-agent/react/LineMembers.d.ts +3 -0
  32. package/dist/hosted-agent/react/LineSetup.d.ts +3 -0
  33. package/dist/hosted-agent/react/contracts.d.ts +136 -0
  34. package/dist/hosted-agent/react/index.d.ts +5 -0
  35. package/dist/hosted-agent/react/index.js +573 -0
  36. package/dist/hosted-agent/react/index.js.map +1 -0
  37. package/dist/hosted-agent/react/lines.css +110 -0
  38. package/dist/hosted-agent/react/styles.d.ts +2 -0
  39. package/dist/hosted-agent/react/styles.js +1 -0
  40. package/dist/hosted-agent/react/styles.js.map +1 -0
  41. package/dist/hosted-agent/workflows.d.ts +17 -0
  42. package/dist/integrations/index.d.ts +2 -9
  43. package/dist/integrations/index.js +11 -27
  44. package/dist/integrations/index.js.map +1 -1
  45. package/dist/platform/index.js +1 -1
  46. package/dist/platform/sso.d.ts +74 -7
  47. package/dist/profile/home.d.ts +23 -0
  48. package/dist/profile/index.d.ts +1 -0
  49. package/dist/profile/index.js +9 -1
  50. package/dist/profile/index.js.map +1 -1
  51. package/dist/public-consultation/index.js +4 -4
  52. package/dist/stories/hosted-agent/fixtures.d.ts +34 -0
  53. package/dist/teams/index.js +5 -5
  54. package/dist/teams/invitations-api.js +4 -4
  55. package/dist/vault/ConfirmDialog.d.ts +1 -1
  56. package/dist/vault/VaultPane.d.ts +2 -2
  57. package/dist/vault/contracts.d.ts +8 -0
  58. package/dist/vault/index.js +1 -1
  59. package/dist/vault/lazy.d.ts +3 -3
  60. package/dist/vault/lazy.js +1 -1
  61. package/dist/vault/lazy.js.map +1 -1
  62. package/dist/web-react/index.js +22 -22
  63. package/package.json +50 -17
  64. package/dist/VaultPane-32HVWZ6A.js +0 -7
  65. package/dist/chunk-FCUWRMAF.js.map +0 -1
  66. package/dist/chunk-FXWNNHPC.js.map +0 -1
  67. /package/dist/{VaultPane-32HVWZ6A.js.map → VaultPane-2SJSP6TS.js.map} +0 -0
  68. /package/dist/{chunk-V4YGTOCS.js.map → chunk-MIFVOYZ7.js.map} +0 -0
  69. /package/dist/{chunk-4PZE7XAM.js.map → chunk-S3344RBY.js.map} +0 -0
  70. /package/dist/{chunk-4S36HWE4.js.map → chunk-WAMJSZYB.js.map} +0 -0
  71. /package/dist/{chunk-VIL6MHHQ.js.map → chunk-XTLYE2VO.js.map} +0 -0
@@ -0,0 +1,280 @@
1
+ #!/usr/bin/env python3
2
+ """The sole writer of the protected agent home. Installed root-owned, invoked
3
+ through one exact sudo command with Python -I. No caller paths or environment
4
+ choose the home, executable, git configuration, or policy. JSON on stdin/stdout.
5
+ """
6
+ import datetime
7
+ import fcntl
8
+ import hashlib
9
+ import json
10
+ import os
11
+ import pathlib
12
+ import re
13
+ import signal
14
+ import stat
15
+ import subprocess
16
+ import sys
17
+ import tempfile
18
+
19
+ BASE = pathlib.Path('/var/lib/tangle-agent')
20
+ ROOT = BASE / 'home'
21
+ STATE = BASE / 'state'
22
+ CONFIG = pathlib.Path('/etc/tangle-agent/home.json')
23
+ GIT = STATE / 'home.git'
24
+ FIXED = {'AGENTS.md', 'SOUL.md', 'IDENTITY.md', 'USER.md', 'MEMORY.md', 'BOOTSTRAP.md'}
25
+ MAX_FILES = 1024
26
+ MAX_TOTAL_BYTES = 8 * 1024 * 1024
27
+ CAPS = {}
28
+
29
+
30
+ def protected(path, directory=False):
31
+ info = path.lstat()
32
+ if info.st_uid != 0 or info.st_mode & 0o022 or stat.S_ISLNK(info.st_mode):
33
+ raise ValueError('home ownership or permissions are unsafe')
34
+ if directory:
35
+ if not stat.S_ISDIR(info.st_mode):
36
+ raise ValueError('home directory is not a directory')
37
+ elif not stat.S_ISREG(info.st_mode) or info.st_nlink != 1:
38
+ raise ValueError('home entry must be a root-owned regular file with one link')
39
+ return info
40
+
41
+
42
+ def load_policy():
43
+ if os.geteuid() != 0 or len(sys.argv) != 1:
44
+ raise ValueError('use the installed privileged home command without arguments')
45
+ for path in [pathlib.Path('/etc'), CONFIG.parent, pathlib.Path('/var'),
46
+ pathlib.Path('/var/lib'), BASE, ROOT, STATE]:
47
+ protected(path, True)
48
+ if ROOT.stat().st_dev != STATE.stat().st_dev:
49
+ raise ValueError('home and journal must share one persistent filesystem')
50
+ protected(CONFIG)
51
+ policy = json.loads(CONFIG.read_text(encoding='utf8'))
52
+ uid = policy.get('runtimeUid')
53
+ if not isinstance(uid, int) or isinstance(uid, bool) or uid <= 0:
54
+ raise ValueError('home runtime identity is invalid')
55
+ # sudo supplies SUDO_UID and the exact-command sudoers grant disallows
56
+ # SETENV. Direct root invocation is reserved for image/operator setup.
57
+ sudo_uid = os.environ.get('SUDO_UID')
58
+ if sudo_uid is not None and sudo_uid != str(uid):
59
+ raise ValueError('this caller does not own this member home')
60
+ caps = policy.get('caps', {})
61
+ if set(caps) != FIXED | {'dailyNote', 'skill'} or any(
62
+ not isinstance(value, int) or isinstance(value, bool) or value <= 0 or value > 32768
63
+ for value in caps.values()
64
+ ):
65
+ raise ValueError('home caps are invalid')
66
+ CAPS.update(caps)
67
+ protected(ROOT / 'AGENTS.md')
68
+ if hashlib.sha256((ROOT / 'AGENTS.md').read_bytes()).hexdigest() != policy.get('agentsSha256'):
69
+ raise ValueError('platform AGENTS.md attestation failed')
70
+
71
+
72
+ def checked(name):
73
+ if not isinstance(name, str):
74
+ raise ValueError('home path must be text')
75
+ if name not in FIXED:
76
+ note = re.fullmatch(r'memory/(\d{4}-\d{2}-\d{2})\.md', name)
77
+ skill = re.fullmatch(r'skills/[a-z0-9][a-z0-9-]{0,63}/SKILL\.md', name)
78
+ if note:
79
+ datetime.date.fromisoformat(note.group(1))
80
+ elif not skill:
81
+ raise ValueError('path is outside the home allowlist')
82
+ target = ROOT / name
83
+ current = ROOT
84
+ for component in pathlib.PurePosixPath(name).parts:
85
+ current = current / component
86
+ if current.exists() or current.is_symlink():
87
+ protected(current, current != target)
88
+ return target
89
+
90
+
91
+ def cap(name):
92
+ return CAPS[name] if name in FIXED else CAPS['skill' if name.startswith('skills/') else 'dailyNote']
93
+
94
+
95
+ def bounded(name, content):
96
+ checked(name)
97
+ if not isinstance(content, str) or len(content) > cap(name):
98
+ raise ValueError('home character budget exceeded')
99
+ if len(content.encode('utf8')) > cap(name) * 4:
100
+ raise ValueError('home byte budget exceeded')
101
+ return content
102
+
103
+
104
+ def read(name):
105
+ path = checked(name)
106
+ if not path.exists():
107
+ return ''
108
+ if path.stat().st_size > cap(name) * 4:
109
+ raise ValueError('home byte budget exceeded')
110
+ return bounded(name, path.read_text(encoding='utf8'))
111
+
112
+
113
+ def paths():
114
+ result = []
115
+ for directory, directories, files in os.walk(ROOT, followlinks=False):
116
+ for child in directories:
117
+ protected(pathlib.Path(directory) / child, True)
118
+ for child in files:
119
+ name = str((pathlib.Path(directory) / child).relative_to(ROOT))
120
+ checked(name)
121
+ result.append(name)
122
+ if len(result) > MAX_FILES:
123
+ raise ValueError('home file-count budget exceeded')
124
+ return sorted(result)
125
+
126
+
127
+ def atomic(path, content, mode=0o444):
128
+ fd, temporary = tempfile.mkstemp(prefix='.home-', dir=STATE)
129
+ try:
130
+ with os.fdopen(fd, 'w', encoding='utf8') as output:
131
+ output.write(content)
132
+ output.flush()
133
+ os.fchmod(output.fileno(), mode)
134
+ os.fsync(output.fileno())
135
+ os.replace(temporary, path)
136
+ directory = os.open(path.parent, os.O_DIRECTORY)
137
+ try:
138
+ os.fsync(directory)
139
+ finally:
140
+ os.close(directory)
141
+ finally:
142
+ if os.path.exists(temporary):
143
+ os.unlink(temporary)
144
+
145
+
146
+ def git(*args):
147
+ # Neither the caller's PATH/HOME nor git-related environment is inherited.
148
+ env = {'PATH': '/usr/bin:/bin', 'HOME': '/nonexistent', 'LANG': 'C.UTF-8',
149
+ 'GIT_CONFIG_NOSYSTEM': '1', 'GIT_CONFIG_GLOBAL': '/dev/null', 'GIT_TERMINAL_PROMPT': '0'}
150
+ result = subprocess.run(['/usr/bin/git', '--git-dir=' + str(GIT), '--work-tree=' + str(ROOT),
151
+ '-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', '-c', 'core.fsync=all',
152
+ '-c', 'user.name=Tangle home', '-c', 'user.email=home@localhost', *args],
153
+ cwd=ROOT, env=env, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
154
+ timeout=20, check=False)
155
+ if result.returncode:
156
+ raise RuntimeError('home git operation failed')
157
+ return result.stdout.strip()
158
+
159
+
160
+ def checkpoint():
161
+ existing = paths()
162
+ if sum(len(read(name).encode('utf8')) for name in existing) > MAX_TOTAL_BYTES:
163
+ raise ValueError('home total-byte budget exceeded')
164
+ tracked = list(filter(None, git('ls-files', '-z').split('\0')))
165
+ selected = sorted(set(existing + tracked))
166
+ for name in selected:
167
+ checked(name)
168
+ if selected:
169
+ git('add', '--all', '--', *selected)
170
+ changed = bool(git('diff', '--cached', '--name-only'))
171
+ if changed:
172
+ git('commit', '-m', 'home: ' + datetime.datetime.now(datetime.timezone.utc).isoformat())
173
+ return {'commit': git('rev-parse', '--verify', 'HEAD'), 'changed': changed}
174
+
175
+
176
+ def apply(updates):
177
+ # Validate the whole change before writing any document. A durable journal
178
+ # finishes the same transaction after a crash instead of mixing two nightly
179
+ # summaries. Neither the journal nor the git metadata is agent-writable.
180
+ current = {name: read(name) for name in paths()}
181
+ for name, value in updates.items():
182
+ checked(name)
183
+ if name == 'AGENTS.md':
184
+ raise ValueError('AGENTS.md is platform-owned')
185
+ if value is None:
186
+ current.pop(name, None)
187
+ else:
188
+ current[name] = bounded(name, value)
189
+ if len(current) > MAX_FILES or sum(len(value.encode('utf8')) for value in current.values()) > MAX_TOTAL_BYTES:
190
+ raise ValueError('home total budget exceeded')
191
+ atomic(STATE / 'pending.json', json.dumps(updates, ensure_ascii=False), 0o600)
192
+ return finish_pending()
193
+
194
+
195
+ def finish_pending():
196
+ pending = STATE / 'pending.json'
197
+ if not pending.exists():
198
+ return None
199
+ protected(pending)
200
+ updates = json.loads(pending.read_text(encoding='utf8'))
201
+ for name, content in updates.items():
202
+ target = checked(name)
203
+ if name == 'AGENTS.md':
204
+ raise ValueError('AGENTS.md is platform-owned')
205
+ if content is None:
206
+ if target.exists():
207
+ target.unlink()
208
+ else:
209
+ bounded(name, content)
210
+ parent = ROOT
211
+ for component in pathlib.PurePosixPath(name).parts[:-1]:
212
+ parent = parent / component
213
+ if not parent.exists():
214
+ parent.mkdir(mode=0o755)
215
+ parent.chmod(0o755)
216
+ protected(parent, True)
217
+ checked(name)
218
+ atomic(target, content)
219
+ result = checkpoint()
220
+ pending.unlink()
221
+ return result
222
+
223
+
224
+ def main():
225
+ load_policy()
226
+ os.umask(0o077)
227
+ raw = sys.stdin.buffer.read(262145)
228
+ if len(raw) > 262144:
229
+ raise ValueError('home command input exceeds 262144 bytes')
230
+ request = json.loads(raw)
231
+ fields = {'status': set(), 'commit': set(), 'bootstrap': set(), 'read': {'path'},
232
+ 'write': {'path', 'content'}, 'append': {'path', 'content'}, 'delete': {'path'},
233
+ 'consolidate': {'user', 'memory', 'expectedHead'}}
234
+ if not isinstance(request, dict) or request.get('action') not in fields:
235
+ raise ValueError('unknown home operation')
236
+ action = request['action']
237
+ if set(request) != {'action'} | fields[action]:
238
+ raise ValueError('invalid home operation fields')
239
+ lock = STATE / 'home.lock'
240
+ with lock.open('a') as handle:
241
+ protected(lock)
242
+ fcntl.flock(handle, fcntl.LOCK_EX)
243
+ if not GIT.exists():
244
+ git('init')
245
+ protected(GIT, True)
246
+ finish_pending()
247
+ if action == 'read':
248
+ return {'path': request['path'], 'content': read(request['path']),
249
+ 'commit': git('rev-parse', '--verify', 'HEAD')}
250
+ if action in {'write', 'append', 'delete'}:
251
+ name = request['path']
252
+ content = None if action == 'delete' else request['content']
253
+ if action == 'append':
254
+ content = read(name) + content
255
+ result = apply({name: content})
256
+ return {**result, 'path': name, 'ownerNoticeRequired': name == 'SOUL.md'}
257
+ if action == 'bootstrap':
258
+ if not read('IDENTITY.md').strip():
259
+ raise ValueError('fill IDENTITY.md before completing bootstrap')
260
+ return apply({'BOOTSTRAP.md': None})
261
+ if action == 'consolidate':
262
+ if request['expectedHead'] != git('rev-parse', '--verify', 'HEAD'):
263
+ raise ValueError('home changed; reread notes before consolidating')
264
+ return apply({'USER.md': request['user'], 'MEMORY.md': request['memory']})
265
+ result = checkpoint()
266
+ if action == 'status':
267
+ result['files'] = [{'path': name, 'characters': len(read(name)),
268
+ 'sha256': hashlib.sha256(read(name).encode('utf8')).hexdigest()} for name in paths()]
269
+ return result
270
+
271
+
272
+ if __name__ == '__main__':
273
+ signal.alarm(30)
274
+ try:
275
+ print(json.dumps(main(), ensure_ascii=False))
276
+ except Exception as error:
277
+ # Do not reflect file bodies, request data, or subprocess diagnostics.
278
+ print(json.dumps({'error': str(error) if isinstance(error, (ValueError, RuntimeError))
279
+ else type(error).__name__}), file=sys.stderr)
280
+ sys.exit(1)
@@ -0,0 +1,166 @@
1
+ import { mkdir, open, lstat, readFile, rename, unlink } from 'node:fs/promises'
2
+ import { dirname, join } from 'node:path'
3
+ import { execFile, spawn } from 'node:child_process'
4
+ import { createHash, randomUUID } from 'node:crypto'
5
+ import { promisify } from 'node:util'
6
+
7
+ export const PROTECTED_HOME = '/var/lib/tangle-agent/home'
8
+ const BASE = '/var/lib/tangle-agent'
9
+ const STATE = `${BASE}/state`
10
+ const CONFIG = '/etc/tangle-agent/home.json'
11
+ const HELPER = '/usr/local/libexec/tangle-agent-home.py'
12
+ const run = promisify(execFile)
13
+ const cleanEnv = { PATH: '/usr/bin:/bin', LANG: 'C.UTF-8' }
14
+
15
+ async function protectedEntry(path, directory = false) {
16
+ const info = await lstat(path)
17
+ if (info.uid !== 0 || (info.mode & 0o022) || info.isSymbolicLink() ||
18
+ (directory ? !info.isDirectory() : !info.isFile() || info.nlink !== 1)) {
19
+ throw new Error(`Unsafe protected home installation: ${path}`)
20
+ }
21
+ return info
22
+ }
23
+
24
+ /** One installed, exact-command sudo capability. No shell and no caller environment. */
25
+ export async function homeOperation(request, signal) {
26
+ signal?.throwIfAborted()
27
+ const input = JSON.stringify(request)
28
+ if (Buffer.byteLength(input) > 262144) throw new Error('Home request is too large')
29
+ await protectedEntry(HELPER)
30
+ return new Promise((resolve, reject) => {
31
+ const child = spawn('/usr/bin/sudo', ['-n', '--', '/usr/bin/python3', '-I', HELPER], {
32
+ cwd: '/', env: cleanEnv, stdio: ['pipe', 'pipe', 'pipe'],
33
+ })
34
+ let output = '', errorOutput = '', size = 0, inputFailed = false, timedOut = false
35
+ child.stdout.setEncoding('utf8')
36
+ child.stderr.setEncoding('utf8')
37
+ const stop = () => child.kill('SIGTERM')
38
+ const timer = setTimeout(() => { timedOut = true; stop() }, 35_000)
39
+ timer.unref()
40
+ signal?.addEventListener('abort', stop, { once: true })
41
+ child.stdout.on('data', bytes => {
42
+ size += Buffer.byteLength(bytes)
43
+ if (size > 1024 * 1024) stop()
44
+ else output += bytes.toString('utf8')
45
+ })
46
+ child.stderr.on('data', bytes => { if (errorOutput.length < 2048) errorOutput += bytes.toString('utf8') })
47
+ child.stdin.on('error', () => { inputFailed = true })
48
+ child.once('error', finishError)
49
+ child.once('close', (code, killed) => {
50
+ clearTimeout(timer)
51
+ signal?.removeEventListener('abort', stop)
52
+ if (signal?.aborted) return reject(signal.reason)
53
+ if (code !== 0 || killed || inputFailed || timedOut || size > 1024 * 1024) {
54
+ let detail = 'Home operation failed or is unconfirmed; inspect home_status before retrying a write'
55
+ try { const value = JSON.parse(errorOutput); if (typeof value.error === 'string') detail = value.error }
56
+ catch { detail = 'Protected home returned no validation receipt; inspect home_status before retrying a write' }
57
+ return reject(new Error(detail))
58
+ }
59
+ try { resolve(JSON.parse(output)) } catch { reject(new Error('Invalid protected home response')) }
60
+ })
61
+ function finishError(error) {
62
+ clearTimeout(timer)
63
+ signal?.removeEventListener('abort', stop)
64
+ reject(new Error('The protected home command could not start', { cause: error }))
65
+ }
66
+ child.stdin.end(input)
67
+ })
68
+ }
69
+
70
+ /** The runtime cannot create or repair this boundary as its own UID. */
71
+ export async function initializeHome(directoryName) {
72
+ if (directoryName !== PROTECTED_HOME || typeof process.getuid !== 'function' || process.getuid() === 0) {
73
+ throw new Error('General agents require the installed protected home and a non-root runtime UID')
74
+ }
75
+ for (const directory of ['/var', '/var/lib', BASE, PROTECTED_HOME, '/etc', '/etc/tangle-agent', '/usr/local/libexec']) {
76
+ await protectedEntry(directory, true)
77
+ }
78
+ await protectedEntry(CONFIG)
79
+ const policy = JSON.parse(await readFile(CONFIG, 'utf8'))
80
+ if (policy.runtimeUid !== process.getuid()) throw new Error('Protected home belongs to another runtime UID')
81
+ await protectedEntry(`${PROTECTED_HOME}/AGENTS.md`)
82
+ const digest = createHash('sha256').update(await readFile(`${PROTECTED_HOME}/AGENTS.md`)).digest('hex')
83
+ if (digest !== policy.agentsSha256) throw new Error('Platform AGENTS.md attestation failed')
84
+ await homeOperation({ action: 'status' })
85
+ return PROTECTED_HOME
86
+ }
87
+
88
+ export async function checkpointHome(directoryName, signal) {
89
+ if (directoryName !== PROTECTED_HOME) throw new Error('Invalid protected home')
90
+ return { home: PROTECTED_HOME, ...await homeOperation({ action: 'commit' }, signal) }
91
+ }
92
+
93
+ /** Image/operator install only. Uses the same canonical profile seed records. */
94
+ export async function installProtectedHome(runtimeUser) {
95
+ if (process.getuid?.() !== 0 || !/^[a-z_][a-z0-9_-]{0,31}$/.test(runtimeUser ?? '')) {
96
+ throw new Error('install-home requires root and an explicit non-root runtime user')
97
+ }
98
+ const uid = Number((await run('/usr/bin/id', ['-u', runtimeUser])).stdout.trim())
99
+ const gid = Number((await run('/usr/bin/id', ['-g', runtimeUser])).stdout.trim())
100
+ if (!Number.isSafeInteger(uid) || uid <= 0 || !Number.isSafeInteger(gid) || gid < 0) throw new Error('Invalid runtime UID/GID')
101
+ for (const [directory, mode] of [[BASE, 0o755], [PROTECTED_HOME, 0o755], [STATE, 0o700],
102
+ ['/etc/tangle-agent', 0o755], ['/usr/local/libexec', 0o755], ['/etc/sudoers.d', 0o750]]) {
103
+ await mkdir(directory, { recursive: true, mode })
104
+ await protectedEntry(directory, true)
105
+ }
106
+ if ((await lstat(STATE)).dev !== (await lstat(PROTECTED_HOME)).dev) throw new Error('Home and journal must share one persistent filesystem')
107
+ async function replace(path, body, mode) {
108
+ const temporary = join(dirname(path), `.tangle-${randomUUID()}`)
109
+ const file = await open(temporary, 'wx', mode)
110
+ try {
111
+ await file.writeFile(body)
112
+ await file.sync()
113
+ await rename(temporary, path)
114
+ } finally {
115
+ await file.close()
116
+ try { await unlink(temporary) } catch (error) { if (error.code !== 'ENOENT') throw error }
117
+ }
118
+ }
119
+ const { defaultHomeFiles, DEFAULT_HOME_LIMITS } = await import('../../dist/hosted-agent/index.js')
120
+ const seeds = defaultHomeFiles().filter(mount => mount.path !== '.tangle/home.py')
121
+ const agents = seeds.find(mount => mount.path === 'AGENTS.md')?.resource.content
122
+ if (!agents) throw new Error('The installed package has no canonical AGENTS.md')
123
+ let initialized = false
124
+ try { await protectedEntry(`${STATE}/initialized`); initialized = true }
125
+ catch (error) { if (error.code !== 'ENOENT') throw error }
126
+ for (const mount of seeds) {
127
+ if (mount.resource.kind !== 'inline' || !Object.hasOwn(DEFAULT_HOME_LIMITS, mount.path)) throw new Error('Unsupported home seed')
128
+ if (initialized && mount.path !== 'AGENTS.md') continue
129
+ const path = `${PROTECTED_HOME}/${mount.path}`
130
+ try {
131
+ await protectedEntry(path)
132
+ if (mount.path !== 'AGENTS.md') continue
133
+ } catch (error) { if (error.code !== 'ENOENT') throw error }
134
+ await replace(path, mount.resource.content, 0o444)
135
+ }
136
+ await replace(CONFIG, JSON.stringify({ runtimeUid: uid, caps: DEFAULT_HOME_LIMITS,
137
+ agentsSha256: createHash('sha256').update(agents).digest('hex') }), 0o444)
138
+ await replace(HELPER, await readFile(new URL('./home-maintenance.py', import.meta.url)), 0o444)
139
+ // Verify inherited sudoers ordering rather than silently leaving a broad grant.
140
+ const policyFile = '/etc/sudoers.d/zz-tangle-agent-home'
141
+ await replace(policyFile, `Defaults:${runtimeUser} env_reset,secure_path="/usr/bin:/bin"\n` +
142
+ `${runtimeUser} ALL=(ALL:ALL) !ALL\n` +
143
+ `${runtimeUser} ALL=(root) NOPASSWD: NOSETENV: /usr/bin/python3 -I ${HELPER}\n`, 0o440)
144
+ await run('/usr/sbin/visudo', ['-cf', policyFile], { env: cleanEnv })
145
+ let rootCommandDenied = false
146
+ try { await run('/usr/bin/sudo', ['-n', '--', '/usr/bin/id', '-u'], { uid, gid, env: cleanEnv }) }
147
+ catch (error) { if (error.code === 1) rootCommandDenied = true; else throw error }
148
+ if (!rootCommandDenied) throw new Error('Runtime still has a broad sudo grant; refuse the image')
149
+ const status = await new Promise((resolve, reject) => {
150
+ const child = spawn('/usr/bin/sudo', ['-n', '--', '/usr/bin/python3', '-I', HELPER],
151
+ { uid, gid, cwd: '/', env: cleanEnv, stdio: ['pipe', 'pipe', 'pipe'] })
152
+ let output = ''
153
+ child.stdout.setEncoding('utf8')
154
+ child.stdout.on('data', bytes => { output += bytes.toString() })
155
+ child.stderr.resume()
156
+ child.on('error', reject)
157
+ child.on('close', code => {
158
+ try { if (code !== 0) throw new Error('Privileged home command did not pass its real UID probe'); resolve(JSON.parse(output)) }
159
+ catch (error) { reject(error) }
160
+ })
161
+ child.stdin.end(JSON.stringify({ action: 'status' }))
162
+ })
163
+ if (!status.commit) throw new Error('Protected home has no Git checkpoint')
164
+ await replace(`${STATE}/initialized`, 'tangle-agent-home-v2\n', 0o400)
165
+ return { home: PROTECTED_HOME, runtimeUid: uid, commit: status.commit }
166
+ }
@@ -0,0 +1,168 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { z } from 'zod'
3
+ import { Sandbox } from '@tangle-network/sandbox/core'
4
+ import { HubClient } from '@tangle-network/hub-sdk'
5
+ import { agentProfileSchema, defineAgentProfilePublicConfig as publicValue,
6
+ defineAgentProfileSecretRef as secretRef, mergeAgentProfiles } from '@tangle-network/agent-interface'
7
+ import { GENERAL_AGENT_MODEL, GENERAL_AGENT_SYSTEM_PROMPT, DEFAULT_AGENT_HOME,
8
+ withDefaultAgentHome, agentHomeWorkflows } from '../../dist/hosted-agent/index.js'
9
+
10
+ const name = z.string().regex(/^[A-Za-z][A-Za-z0-9_-]{0,63}$/)
11
+ const role = z.enum(['owner', 'manager', 'staff', 'vendor', 'finance', 'practitioner'])
12
+ const envKey = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/)
13
+ const domain = z.string().regex(/^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9-]+$/)
14
+ const protectedProfile = z.record(z.string(), z.unknown()).superRefine((value, context) => {
15
+ if (Object.hasOwn(value, 'interactions')) context.addIssue({ code: 'custom',
16
+ message: 'A general-agent profile cannot override backend permission interactions' })
17
+ }).transform(value => agentProfileSchema.parse(value))
18
+ const backend = z.object({ type: z.literal('opencode').optional(), profile: protectedProfile }).strict()
19
+ .transform(value => ({ ...value, type: 'opencode', interactions: { permission: true } }))
20
+ const configSchema = z.object({
21
+ version: z.literal(1),
22
+ keyPrefix: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:@-]{0,99}$/),
23
+ environment: z.string().min(1),
24
+ model: z.string().min(1).default(GENERAL_AGENT_MODEL),
25
+ imageModel: z.string().min(1), videoModel: z.string().min(1),
26
+ runtimeModule: z.string().regex(/^\//).default('/opt/tangle-agent/node_modules/@tangle-network/agent-app/bin/tangle-agent.mjs'),
27
+ home: z.literal(DEFAULT_AGENT_HOME).default(DEFAULT_AGENT_HOME),
28
+ chromium: z.string().regex(/^\//),
29
+ proxyEnv: envKey.default('HTTPS_PROXY'), routerKeyEnv: envKey.default('OPENCODE_MODEL_API_KEY'),
30
+ allowDomains: z.array(domain).max(95).default([]),
31
+ resources: z.object({ cpuCores: z.number().int().min(2).max(64).default(2),
32
+ memoryMB: z.number().int().min(4096).max(262144).default(4096),
33
+ diskGB: z.number().int().min(10).max(2048).default(20) }).strict().prefault({}),
34
+ baseProfile: protectedProfile.prefault({}),
35
+ scopedServers: z.record(z.string(), z.array(name).min(1).max(16)).default({}),
36
+ members: z.array(z.object({ address: z.string().regex(/^\+[1-9]\d{6,14}$/), role,
37
+ label: z.string().min(1).max(60), backend: backend.optional(),
38
+ secretNames: z.array(z.string().regex(/^TANGLE_AGENT_PRODUCT_[A-Z0-9_]{1,96}$/)).max(16).optional() }).strict()).min(1).max(50),
39
+ lines: z.array(z.object({ id: z.string().regex(/^ln_[A-Za-z0-9_-]+$/),
40
+ transport: z.enum(['imessage', 'whatsapp']),
41
+ voice: z.object({ ph0nyConnectionId: z.string().min(1), ph0nyAgentId: z.string().min(1),
42
+ outboundFrom: z.string().regex(/^\+[1-9]\d{6,14}$/) }).strict().optional(),
43
+ }).strict()).min(1).max(8),
44
+ // No guessed owner timezone. Supplying this installs both disabled jobs;
45
+ // the operator enables them after consent and the deployed boundary proof.
46
+ heartbeat: z.object({ lineId: z.string().min(1), owner: z.string().regex(/^\+[1-9]\d{6,14}$/),
47
+ cron: z.literal('0 8-22 * * *').default('0 8-22 * * *'), timezone: z.string().min(1),
48
+ prompt: z.string().min(1).max(8000).optional(),
49
+ }).strict(),
50
+ }).strict()
51
+
52
+ export function buildGeneralAgent(configInput) {
53
+ const config = configSchema.parse(configInput)
54
+ if (new Set(config.members.map(member => member.address)).size !== config.members.length ||
55
+ new Set(config.lines.map(line => line.id)).size !== config.lines.length) throw new Error('Members and lines must be unique')
56
+ if (config.members.filter(member => member.role === 'owner').length !== 1) throw new Error('Exactly one enrolled owner is required for approvals')
57
+ if (config.baseProfile.prompt?.systemPrompt) throw new Error('Business presets add instructions and skills, not a replacement system prompt')
58
+ for (const line of config.lines) if (line.voice && line.transport !== 'imessage') throw new Error('Hub voice requires the Inkbox iMessage line')
59
+ const roles = {}, grants = {}, members = {}
60
+ for (const member of config.members) {
61
+ const general = ['owner', 'manager'].includes(member.role)
62
+ roles[member.role] = { context: 'own', tools: 'act' }
63
+ if (general) grants[member.role] = { kind: 'general' }
64
+ else {
65
+ const servers = config.scopedServers[member.role]
66
+ if (!servers?.length || !member.backend) throw new Error(`Scoped ${member.role} needs an enrolled product backend and named MCP servers`)
67
+ grants[member.role] = { kind: 'scoped', mcpServers: servers }
68
+ }
69
+ if (member.backend) members[member.address] = { backend: member.backend,
70
+ ...(member.secretNames ? { secretNames: member.secretNames } : {}) }
71
+ }
72
+ const substrate = agentProfileSchema.parse({
73
+ name: 'tangle-agent-v1', model: { default: config.model },
74
+ prompt: { systemPrompt: GENERAL_AGENT_SYSTEM_PROMPT, instructions: [
75
+ `Your durable home is ${config.home}. It is preinstalled and root-owned. Read AGENTS.md and SOUL.md there. Use home_read, home_write, home_append, home_status, home_bootstrap and home_consolidate. Ordinary shell and code remain available outside this protected directory. Never create an unprotected replacement home.`,
76
+ 'The protected writer enforces Unicode-character, byte and file-count caps before any memory mutation. Every successful mutation returns a Git commit. A SOUL.md change requires an immediate owner notice. Keep credentials and third-party private data out of memory.',
77
+ 'Use the published tangle_agent tools for Router search/read, Browser Agent and image/video generation. Use native computer-use tools for the virtual display and explicitly mounted Hub connections for email, calendar and ph0ny. Do not substitute direct provider APIs or disable the allowlist proxy.',
78
+ 'The platform scheduler owns reminders and maintenance. The hourly heartbeat and nightly consolidation use the same consenting owner session. A queued workflow is not a completed reminder; report the actual workflow and eventual line receipts.',
79
+ ] },
80
+ mcp: { tangle_agent: { transport: 'stdio', command: 'node',
81
+ args: [publicValue(config.runtimeModule), publicValue('serve')],
82
+ env: {
83
+ TANGLE_AGENT_ROUTER_KEY: secretRef(config.routerKeyEnv),
84
+ TANGLE_AGENT_BROWSER_PROXY: secretRef(config.proxyEnv),
85
+ TANGLE_AGENT_HOME: publicValue(config.home), TANGLE_AGENT_CHROMIUM: publicValue(config.chromium),
86
+ TANGLE_AGENT_MODEL: publicValue(config.model), TANGLE_AGENT_IMAGE_MODEL: publicValue(config.imageModel),
87
+ TANGLE_AGENT_VIDEO_MODEL: publicValue(config.videoModel),
88
+ }, enabled: true } },
89
+ resources: { failOnError: true },
90
+ })
91
+ const composed = mergeAgentProfiles(config.baseProfile, substrate)
92
+ if (!composed) throw new Error('General profile composition failed')
93
+ const profile = withDefaultAgentHome(composed)
94
+ for (const line of config.lines) {
95
+ if (!line.voice) continue
96
+ const { ph0nyConnectionId, ph0nyAgentId, outboundFrom } = line.voice
97
+ const capabilities = ['phony.start_outbound_call', 'phony.get_call']
98
+ const existing = profile.connections?.find(value => value.connectionId === ph0nyConnectionId)
99
+ if (existing) {
100
+ if (!existing.capabilities.includes('*')) existing.capabilities = [...new Set([...existing.capabilities, ...capabilities])]
101
+ } else profile.connections = [...(profile.connections ?? []), { connectionId: ph0nyConnectionId, capabilities }]
102
+ profile.prompt.instructions.push(`Voice line ${line.id}: use the connected ph0ny tools with agentId ${ph0nyAgentId} and the operator-attested fromNumber ${outboundFrom}. The enrolled owner destination is ${config.members.find(member => member.role === 'owner').address}. Wait for owner approval and actual consent. The call hook rechecks membership and binds the destination member's role and private workspace. Report the actual call id and connected/completed state, not a submission alone.`)
103
+ }
104
+ agentProfileSchema.parse(profile)
105
+ const respond = { kind: 'agent', backend: {
106
+ type: 'opencode', profile, interactions: { permission: true },
107
+ }, rolePolicy: { version: 1, roles: grants, members } }
108
+ const attachments = config.lines.map(line => ({
109
+ number: line.id, mode: 'shared', unknownSenders: 'reject', roles,
110
+ members: config.members.map(({ address, role, label }) => ({ address, role, label })),
111
+ respond, clientReference: config.keyPrefix,
112
+ instance: { keyPrefix: config.keyPrefix, create: {
113
+ name: 'tangle-agent-v1', environment: config.environment, ownerContext: 'isolated', capabilities: ['computer_use'],
114
+ resources: config.resources,
115
+ egressPolicy: { mode: 'strict', includeImplicitDomains: false,
116
+ allowDomains: [...new Set(['router.tangle.tools', 'id.tangle.tools', ...config.allowDomains])] },
117
+ idleTimeoutSeconds: 600, maxLifetimeSeconds: 90 * 86400, deleteAfterStoppedSeconds: 90 * 86400,
118
+ } },
119
+ }))
120
+ const h = config.heartbeat
121
+ if (!config.lines.some(line => line.id === h.lineId) ||
122
+ !config.members.some(member => member.address === h.owner && member.role === 'owner')) {
123
+ throw new Error('Heartbeat must target a configured line and its enrolled owner')
124
+ }
125
+ const workflows = agentHomeWorkflows({ instanceKey: config.keyPrefix, lineId: h.lineId,
126
+ ownerAddress: h.owner, timeZone: h.timezone, heartbeatInstructions: h.prompt })
127
+ return { config, attachments, workflows, heartbeat: workflows.find(job => job.purpose === 'heartbeat').yaml }
128
+ }
129
+
130
+ export async function provision(path, apply = false) {
131
+ const plan = buildGeneralAgent(JSON.parse(await readFile(path, 'utf8')))
132
+ if (!apply) {
133
+ console.log(JSON.stringify({ attachments: plan.attachments, workflows: plan.workflows }, null, 2))
134
+ return
135
+ }
136
+ const key = process.env.TANGLE_API_KEY
137
+ if (!key) throw new Error('TANGLE_API_KEY must be supplied to the trusted operator, never the agent image')
138
+ const sandbox = new Sandbox({ apiKey: key, baseUrl: process.env.SANDBOX_BASE_URL ?? 'https://sandbox.tangle.tools' })
139
+ const hub = new HubClient({ apiKey: key, baseUrl: 'https://id.tangle.tools' })
140
+ // Validate all owned transports first. SDK attach is idempotent for the
141
+ // exact config and refuses a changed active attachment. Never auto-detach.
142
+ for (const line of plan.config.lines) {
143
+ const current = await sandbox.lines.get(line.id)
144
+ if (current.transport !== line.transport || current.status !== 'active') throw new Error('Line is not an active owned line on the declared transport')
145
+ }
146
+ const receipts = []
147
+ for (let i = 0; i < plan.attachments.length; i++) {
148
+ const attachment = await sandbox.lines.attach(plan.attachments[i])
149
+ const line = plan.config.lines[i]
150
+ if (line.voice) {
151
+ const { ph0nyConnectionId, ph0nyAgentId } = line.voice
152
+ await sandbox.lines.enableVoice(line.id, { ph0nyConnectionId, ph0nyAgentId })
153
+ }
154
+ receipts.push({ line: await sandbox.lines.get(line.id), attachment })
155
+ console.log(JSON.stringify({ state: 'attached', lineId: line.id, attachmentId: attachment.id }))
156
+ }
157
+ const existing = await hub.workflows.list()
158
+ const workflows = []
159
+ for (const job of plan.workflows) {
160
+ const matches = existing.filter(value => value.name === job.name)
161
+ if (matches.length > 1) throw new Error('Duplicate home workflows exist; refuse to create another')
162
+ const workflow = matches.length ? await hub.workflows.update(matches[0].id, job.yaml)
163
+ : await hub.workflows.create(job.yaml)
164
+ workflows.push({ purpose: job.purpose, workflow })
165
+ }
166
+ console.log(JSON.stringify({ receipts, workflows,
167
+ next: 'Do not use this attachment until ADC checks exact native approval on each member instance and the local allow/deny effect-sink proof passes. Then the owner must text first. Verify the actual runtime UID, protected home, scoped vendor denial and installed tools before enabling the two disabled Hub workflows.' }, null, 2))
168
+ }